Detached QPs and hidden replies (#4878)

Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Eric Bailey 2024-08-21 21:20:45 -05:00 committed by GitHub
parent 56ab5e177f
commit 6616a6467e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2584 additions and 622 deletions

View file

@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type AppModerationCause =
| ModerationCause
| {
type: 'reply-hidden'
source: {type: 'user'; did: string}
priority: 6
downgraded?: boolean
}
export type CommonProps = {
size?: 'sm' | 'lg'
}
@ -40,7 +49,7 @@ export function Row({
}
export type LabelProps = {
cause: ModerationCause
cause: AppModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps

View file

@ -1,39 +1,34 @@
import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyFeedPost,
AppBskyGraphDefs,
AtUri,
BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {createThreadgate} from '#/lib/api'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
threadgateViewToSettings,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme()
const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
/*
* `WhoCanReply` is only used for root posts atm, in case this changes
* unexpectedly, we should check to make sure it's for sure the root URI.
*/
const rootUri =
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = React.useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
postUri: post.uri,
rootPostUri: rootUri,
})
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody'
const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
const description = anyoneCanReply
? _(msg`Everybody can reply`)
: isNobody
: noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressEdit = () => {
const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
}
}
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
return
}
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
}
return false
})
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
}
return (
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
onPress={onPressOpen}
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
]}>
{description}
</Text>
{isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} />
)}
</View>
)}
</Button>
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
/>
{isThreadAuthor && (
<ThreadgateEditorDialog
{isThreadAuthor ? (
<PostInteractionSettingsDialog
postUri={post.uri}
rootPostUri={rootUri}
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
initialThreadgateView={post.threadgate}
/>
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
)}
</>
@ -174,7 +148,7 @@ function Icon({
}: {
color: string
width?: number
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control,
post,
settings,
embeddingDisabled,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
<Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Who can reply?</Trans>
</Text>
<Rules post={post} settings={settings} />
</View>
</Dialog.ScrollableInner>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can interact with this post?</Trans>
</Text>
<Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Rules({
post,
settings,
embeddingDisabled,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
a.leading_tight,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{!settings.length ? (
<Trans>Everybody can reply</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply
</Trans>
<>
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{settings[0].type === 'everybody' ? (
<Trans>Everybody can reply to this post.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this post are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<React.Fragment key={`rule-${i}`}>
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
<Separator i={i} length={settings.length} />
</React.Fragment>
))}{' '}
can reply.
</Trans>
)}{' '}
</Text>
{embeddingDisabled && (
<Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
<Trans>No one but the author can quote this post.</Trans>
</Text>
)}
</Text>
</>
)
}
@ -267,11 +246,10 @@ function Rule({
post,
lists,
}: {
rule: ThreadgateSetting
rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const t = useTheme()
if (rule.type === 'mention') {
return <Trans>mentioned users</Trans>
}
@ -279,12 +257,12 @@ function Rule({
return (
<Trans>
users followed by{' '}
<TextLink
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
style={{color: t.palette.primary_500}}
/>
<InlineLinkText
label={`@${post.author.handle}`}
to={makeProfileLink(post.author)}
style={[a.text_sm, a.leading_snug]}>
@{post.author.handle}
</InlineLinkText>
</Trans>
)
}
@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri)
return (
<Trans>
<TextLink
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
style={{color: t.palette.primary_500}}
/>{' '}
<InlineLinkText
label={list.name}
to={makeListLink(listUrip.hostname, listUrip.rkey)}
style={[a.text_sm, a.leading_snug]}>
{list.name}
</InlineLinkText>{' '}
members
</Trans>
)
@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, </>
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.feed.getPostThread({
uri,
depth: 0,
}),
)
}

View file

@ -0,0 +1,538 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {
createPostgateQueryKey,
getPostgateRecord,
usePostgateQuery,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
embeddingRules,
} from '#/state/queries/postgate/util'
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
} from '#/state/queries/threadgate'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
onSave: () => void
isSaving?: boolean
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
}
export function PostInteractionSettingsControlledDialog({
control,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export type PostInteractionSettingsDialogProps = {
control: Dialog.DialogControlProps
/**
* URI of the post to edit the interaction settings for. Could be a root post
* or could be a reply.
*/
postUri: string
/**
* The URI of the root post in the thread. Used to determine if the viewer
* owns the threadgate record and can therefore edit it.
*/
rootPostUri: string
/**
* Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
* happen to have one before opening the settings dialog.
*/
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
export function PostInteractionSettingsDialogControlledInner(
props: PostInteractionSettingsDialogProps,
) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri})
const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
postUri: props.postUri,
})
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => {
return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
)
}, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => {
return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
)
}, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) {
props.control.close()
return
}
setIsSaving(true)
try {
const requests = []
if (editedPostgate) {
requests.push(
writePostgateRecord({
postUri: props.postUri,
postgate: editedPostgate,
}),
)
}
if (editedAllowUISettings && isThreadgateOwnedByViewer) {
requests.push(
setThreadgateAllow({
postUri: props.rootPostUri,
allow: editedAllowUISettings,
}),
)
}
await Promise.all(requests)
props.control.close()
} catch (e: any) {
logger.error(`Failed to save post interaction settings`, {
context: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message,
})
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
} finally {
setIsSaving(false)
}
}, [
_,
props.postUri,
props.rootPostUri,
props.control,
editedPostgate,
editedAllowUISettings,
setIsSaving,
writePostgateRecord,
setThreadgateAllow,
isThreadgateOwnedByViewer,
])
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</Dialog.ScrollableInner>
)
}
export function PostInteractionSettingsForm({
onSave,
isSaving,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
postgate.embeddingRules &&
postgate.embeddingRules.find(
v => v.$type === embeddingRules.disableRule.$type,
)
),
)
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove boolean values
let newSelected: ThreadgateAllowUISetting[] =
threadgateAllowUISettings.filter(
v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
onChangeThreadgateAllowUISettings(newSelected)
}
const onChangeQuotesEnabled = React.useCallback(
(enabled: boolean) => {
setQuotesEnabled(enabled)
onChangePostgate(
createPostgateRecord({
...postgate,
embeddingRules: enabled ? [] : [embeddingRules.disableRule],
}),
)
},
[setQuotesEnabled, postgate, onChangePostgate],
)
const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody',
)
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
) : (
<Trans>Quote posts disabled</Trans>
)}
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'list' && v.list === list.uri,
)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</>
)}
</View>
</View>
</View>
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
export function usePrefetchPostInteractionSettings({
postUri,
rootPostUri,
}: {
postUri: string
rootPostUri: string
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return React.useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
queryKey: createThreadgateViewQueryKey(rootPostUri),
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
])
} catch (e: any) {
logger.error(`Failed to prefetch post interaction settings`, {
safeMessage: e.message,
})
}
}, [queryClient, agent, postUri, rootPostUri])
}

View file

@ -1,217 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
interface ThreadgateEditorDialogProps {
control: Dialog.DialogControlProps
threadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}
export function ThreadgateEditorDialog({
control,
threadgate,
onChange,
onConfirm,
}: ThreadgateEditorDialogProps) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogContent({
seedThreadgate,
onChange,
onConfirm,
}: {
seedThreadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(seedThreadgate)
const [prevSeedThreadgate, setPrevSeedThreadgate] =
React.useState(seedThreadgate)
if (seedThreadgate !== prevSeedThreadgate) {
// New data flowed from above (e.g. due to update coming through).
setPrevSeedThreadgate(seedThreadgate)
setDraft(seedThreadgate) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
setDraft(nextThreadgate)
onChange?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
updateThreadgate(newSelected)
}
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
return (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => v.type === 'list' && v.list === list.uri)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}

View file

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
})

View file

@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control']
modcause?: ModerationCause
modcause?: ModerationCause | AppModerationCause
}
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme()
const {_} = useLingui()
const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
let name
let description
@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`)
} else if (modcause.type === 'reply-hidden') {
const isYou = currentAccount?.did === modcause.source.did
name = isYou
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`)
description = isYou
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description
@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name}
</Text>
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
{description}
</Text>
{modcause?.type === 'label' && (
<>
<View style={[a.pt_lg]}>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
</Trans>
)}
</Text>
</>
</View>
)}
{isNative && <View style={{height: 40}} />}

View file

@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
@ -9,13 +9,15 @@ export function PostAlerts({
modui,
size = 'sm',
style,
additionalCauses,
}: {
modui: ModerationUI
size?: Pills.CommonProps['size']
includeMute?: boolean
style?: StyleProp<ViewStyle>
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) {
if (!modui.alert && !modui.inform) {
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null
}
@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
{additionalCauses?.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row>
)
}