diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..035daa6e --- /dev/null +++ b/assets/icons/eye_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index 61064804..8cf7e2be 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.13.2", + "@atproto/api": "0.13.2", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/App.native.tsx b/src/App.native.tsx index bce439a7..69c7629b 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -50,6 +50,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import * as Toast from '#/view/com/util/Toast' @@ -122,21 +123,23 @@ function InnerApp() { - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index df6fbf24..9ec79253 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -39,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' +import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' @@ -105,19 +106,21 @@ function InnerApp() { - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx index 2fff9993..742a1166 100644 --- a/src/components/Pills.tsx +++ b/src/components/Pills.tsx @@ -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 diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 1ffb4da3..ab6ef829 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -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 ( <> - - {isThreadAuthor && ( - + ) : ( + )} @@ -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 ( - - - - - ) -} - -function WhoCanReplyDialogInner({ - post, - settings, -}: { - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { const {_} = useLingui() return ( - - - - Who can reply? - - - - + + + + + + Who can interact with this post? + + + + + ) } function Rules({ post, settings, + embeddingDisabled, }: { post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { const t = useTheme() + return ( - - {!settings.length ? ( - Everybody can reply - ) : settings[0].type === 'nobody' ? ( - Replies to this thread are disabled - ) : ( - - Only{' '} - {settings.map((rule, i) => ( - <> - - - - ))}{' '} - can reply - + <> + + {settings[0].type === 'everybody' ? ( + Everybody can reply to this post. + ) : settings[0].type === 'nobody' ? ( + Replies to this post are disabled. + ) : ( + + Only{' '} + {settings.map((rule, i) => ( + + + + + ))}{' '} + can reply. + + )}{' '} + + {embeddingDisabled && ( + + No one but the author can quote this post. + )} - + ) } @@ -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 mentioned users } @@ -279,12 +257,12 @@ function Rule({ return ( users followed by{' '} - + + @{post.author.handle} + ) } @@ -294,12 +272,12 @@ function Rule({ const listUrip = new AtUri(list.uri) return ( - {' '} + + {list.name} + {' '} members ) @@ -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, - }), - ) -} diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx new file mode 100644 index 00000000..a326602b --- /dev/null +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -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 ( + + + + + + + + ) +} + +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 ( + + + + + ) +} + +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() + const [editedAllowUISettings, setEditedAllowUISettings] = + React.useState() + + 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 ( + + {isLoading ? ( + + ) : ( + + )} + + ) +} + +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 ( + + + + Post interaction settings + + + + + Customize who can interact with this post. + + + + + + + Quote settings + + + + + {quotesEnabled ? ( + Quote posts enabled + ) : ( + Quote posts disabled + )} + + + + + + + + {replySettingsDisabled && ( + + + + + Reply settings are chosen by the author of the thread + + + + )} + + + + Reply settings + + + + Allow replies from: + + + + v.type === 'everybody') + } + onPress={() => + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) + } + style={{flex: 1}} + disabled={replySettingsDisabled} + /> + + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) + } + style={{flex: 1}} + disabled={replySettingsDisabled} + /> + + + {!noOneCanReply && ( + <> + + Or combine these options: + + + + v.type === 'mention', + ) + } + onPress={() => onPressAudience({type: 'mention'})} + disabled={replySettingsDisabled} + /> + v.type === 'following', + ) + } + onPress={() => onPressAudience({type: 'following'})} + disabled={replySettingsDisabled} + /> + {lists && lists.length > 0 + ? lists.map(list => ( + 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} + + + )} + + + + + + + ) +} + +function Selectable({ + label, + isSelected, + onPress, + style, + disabled, +}: { + label: string + isSelected: boolean + onPress: () => void + style?: StyleProp + disabled?: boolean +}) { + const t = useTheme() + return ( + + ) +} + +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]) +} diff --git a/src/components/dialogs/ThreadgateEditor.tsx b/src/components/dialogs/ThreadgateEditor.tsx deleted file mode 100644 index 90483b3a..00000000 --- a/src/components/dialogs/ThreadgateEditor.tsx +++ /dev/null @@ -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 ( - - - - - ) -} - -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 ( - - - - Choose who can reply - - - Either choose "Everybody" or "Nobody" - - - - v.type === 'nobody')} - onPress={onPressNobody} - style={{flex: 1}} - /> - - - Or combine these options: - - - v.type === 'mention')} - onPress={() => onPressAudience({type: 'mention'})} - /> - v.type === 'following')} - onPress={() => onPressAudience({type: 'following'})} - /> - {lists && lists.length > 0 - ? lists.map(list => ( - 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} - - - - - - ) -} - -function Selectable({ - label, - isSelected, - onPress, - style, -}: { - label: string - isSelected: boolean - onPress: () => void - style?: StyleProp -}) { - const t = useTheme() - return ( - - ) -} diff --git a/src/components/icons/Eye.tsx b/src/components/icons/Eye.tsx new file mode 100644 index 00000000..afa772e1 --- /dev/null +++ b/src/components/icons/Eye.tsx @@ -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', +}) diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx index b8f02582..d95717cf 100644 --- a/src/components/moderation/ModerationDetailsDialog.tsx +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -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({ {name} - + {description} {modcause?.type === 'label' && ( - <> + {modcause.source.type === 'user' ? ( @@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({ )} - + )} {isNative && } diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index efbf1821..6c4e5f8c 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -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 + 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 => ( + + ))} ) } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 658ed78d..94c8869a 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -3,7 +3,7 @@ import { AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, - AppBskyFeedThreadgate, + AppBskyFeedPostgate, BskyAgent, ComAtprotoLabelDefs, RichText, @@ -11,7 +11,13 @@ import { import {AtUri} from '@atproto/api' import {logger} from '#/logger' -import {ThreadgateSetting} from '#/state/queries/threadgate' +import {writePostgateRecord} from '#/state/queries/postgate' +import { + createThreadgateRecord, + ThreadgateAllowUISetting, + threadgateAllowUISettingToAllowRecordValue, + writeThreadgateRecord, +} from '#/state/queries/threadgate' import {isNetworkError} from 'lib/strings/errors' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' import {isNative} from 'platform/detection' @@ -44,7 +50,8 @@ interface PostOpts { extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] - threadgate?: ThreadgateSetting[] + threadgate: ThreadgateAllowUISetting[] + postgate: AppBskyFeedPostgate.Record onStateChange?: (state: string) => void langs?: string[] } @@ -232,7 +239,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) { labels, }) } catch (e: any) { - console.error(`Failed to create post: ${e.toString()}`) + logger.error(`Failed to create post`, { + safeMessage: e.message, + }) if (isNetworkError(e)) { throw new Error( 'Post failed to upload. Please check your Internet connection and try again.', @@ -242,56 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) { } } - try { - // TODO: this needs to be batch-created with the post! - if (opts.threadgate?.length) { - await createThreadgate(agent, res.uri, opts.threadgate) + if (opts.threadgate.some(tg => tg.type !== 'everybody')) { + try { + // TODO: this needs to be batch-created with the post! + await writeThreadgateRecord({ + agent, + postUri: res.uri, + threadgate: createThreadgateRecord({ + post: res.uri, + allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate), + }), + }) + } catch (e: any) { + logger.error(`Failed to create threadgate`, { + context: 'composer', + safeMessage: e.message, + }) + throw new Error( + 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.', + ) + } + } + + if ( + opts.postgate.embeddingRules?.length || + opts.postgate.detachedEmbeddingUris?.length + ) { + try { + // TODO: this needs to be batch-created with the post! + await writePostgateRecord({ + agent, + postUri: res.uri, + postgate: { + ...opts.postgate, + post: res.uri, + }, + }) + } catch (e: any) { + logger.error(`Failed to create postgate`, { + context: 'composer', + safeMessage: e.message, + }) + throw new Error( + 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.', + ) } - } catch (e: any) { - console.error(`Failed to create threadgate: ${e.toString()}`) - throw new Error( - 'Post reply-controls failed to be set. Your post was created but anyone can reply to it.', - ) } return res } - -export async function createThreadgate( - agent: BskyAgent, - postUri: string, - threadgate: ThreadgateSetting[], -) { - let allow: ( - | AppBskyFeedThreadgate.MentionRule - | AppBskyFeedThreadgate.FollowingRule - | AppBskyFeedThreadgate.ListRule - )[] = [] - if (!threadgate.find(v => v.type === 'nobody')) { - for (const rule of threadgate) { - if (rule.type === 'mention') { - allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) - } else if (rule.type === 'following') { - allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) - } else if (rule.type === 'list') { - allow.push({ - $type: 'app.bsky.feed.threadgate#listRule', - list: rule.list, - }) - } - } - } - - const postUrip = new AtUri(postUri) - await agent.api.com.atproto.repo.putRecord({ - repo: agent.accountDid, - collection: 'app.bsky.feed.threadgate', - rkey: postUrip.rkey, - record: { - $type: 'app.bsky.feed.threadgate', - post: postUri, - allow, - createdAt: new Date().toISOString(), - }, - }) -} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index e3b4ea0c..3d49f523 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -107,6 +107,11 @@ export async function extractBskyMeta( return meta } +export class EmbeddingDisabledError extends Error { + constructor() { + super('Embedding is disabled for this record') + } +} export async function getPostAsQuote( getPost: ReturnType, url: string, @@ -115,6 +120,9 @@ export async function getPostAsQuote( const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const post = await getPost({uri: uri}) + if (post.viewer?.embeddingDisabled) { + throw new EmbeddingDisabledError() + } return { uri: post.uri, cid: post.cid, diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 4105c2c2..3c96deec 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,17 +1,20 @@ import { - ModerationCause, - ModerationUI, - InterpretedLabelValueDefinition, - LABELS, AppBskyLabelerDefs, BskyAgent, + InterpretedLabelValueDefinition, + LABELS, + ModerationCause, ModerationOpts, + ModerationUI, } from '@atproto/api' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' +import {AppModerationCause} from '#/components/Pills' -export function getModerationCauseKey(cause: ModerationCause): string { +export function getModerationCauseKey( + cause: ModerationCause | AppModerationCause, +): string { const source = cause.source.type === 'labeler' ? cause.source.did diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts index 01ffbe5c..9dce0b56 100644 --- a/src/lib/moderation/useModerationCauseDescription.ts +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -8,11 +8,13 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useLabelDefinitions} from '#/state/preferences' +import {useSession} from '#/state/session' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {Props as SVGIconProps} from '#/components/icons/common' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {AppModerationCause} from '#/components/Pills' import {useGlobalLabelStrings} from './useGlobalLabelStrings' import {getDefinition, getLabelStrings} from './useLabelInfo' @@ -27,8 +29,9 @@ export interface ModerationCauseDescription { } export function useModerationCauseDescription( - cause: ModerationCause | undefined, + cause: ModerationCause | AppModerationCause | undefined, ): ModerationCauseDescription { + const {currentAccount} = useSession() const {_, i18n} = useLingui() const {labelDefs, labelers} = useLabelDefinitions() const globalLabelStrings = useGlobalLabelStrings() @@ -111,6 +114,18 @@ export function useModerationCauseDescription( description: _(msg`You have hidden this post`), } } + if (cause.type === 'reply-hidden') { + const isMe = currentAccount?.did === cause.source.did + return { + icon: EyeSlash, + name: isMe + ? _(msg`Reply Hidden by You`) + : _(msg`Reply Hidden by Thread Author`), + description: isMe + ? _(msg`You hid this reply.`) + : _(msg`The author of this thread has hidden this reply.`), + } + } if (cause.type === 'label') { const def = cause.labelDef || getDefinition(labelDefs, cause.label) const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) @@ -150,5 +165,13 @@ export function useModerationCauseDescription( name: '', description: ``, } - }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) + }, [ + labelDefs, + labelers, + globalLabelStrings, + cause, + _, + i18n.locale, + currentAccount?.did, + ]) } diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index b37e9bd4..65300a8e 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -1,5 +1,9 @@ import {useEffect, useMemo, useState} from 'react' -import {AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, +} from '@atproto/api' import {QueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' @@ -16,6 +20,7 @@ export interface PostShadow { likeUri: string | undefined repostUri: string | undefined isDeleted: boolean + embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined } export const POST_TOMBSTONE = Symbol('PostTombstone') @@ -87,8 +92,21 @@ function mergeShadow( repostCount = Math.max(0, repostCount) } + let embed: typeof post.embed + if ('embed' in shadow) { + if ( + (AppBskyEmbedRecord.isView(post.embed) && + AppBskyEmbedRecord.isView(shadow.embed)) || + (AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedRecordWithMedia.isView(shadow.embed)) + ) { + embed = shadow.embed + } + } + return castAsShadow({ ...post, + embed: embed || post.embed, likeCount: likeCount, repostCount: repostCount, viewer: { diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 997076e8..55e04830 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -16,7 +16,7 @@ * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. */ -import {useEffect, useRef} from 'react' +import {useCallback, useEffect, useMemo, useRef} from 'react' import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' import { InfiniteData, @@ -27,6 +27,7 @@ import { } from '@tanstack/react-query' import {useAgent} from '#/state/session' +import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useModerationOpts} from '../../preferences/moderation-opts' import {STALE} from '..' import { @@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: { const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() const enabled = opts?.enabled !== false + const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris() // false: force showing all notifications // undefined: let the server decide const priority = opts?.overridePriorityNotifications ? false : undefined + const selectArgs = useMemo(() => { + return { + hiddenReplyUris, + } + }, [hiddenReplyUris]) + const query = useInfiniteQuery< FeedPage, Error, @@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: { initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, enabled, - select(data: InfiniteData) { - // override 'isRead' using the first page's returned seenAt - // we do this because the `markAllRead()` call above will - // mark subsequent pages as read prematurely - const seenAt = data.pages[0]?.seenAt || new Date() - for (const page of data.pages) { - for (const item of page.items) { - item.notification.isRead = - seenAt > new Date(item.notification.indexedAt) - } - } + select: useCallback( + (data: InfiniteData) => { + const {hiddenReplyUris} = selectArgs - return data - }, + // override 'isRead' using the first page's returned seenAt + // we do this because the `markAllRead()` call above will + // mark subsequent pages as read prematurely + const seenAt = data.pages[0]?.seenAt || new Date() + for (const page of data.pages) { + for (const item of page.items) { + item.notification.isRead = + seenAt > new Date(item.notification.indexedAt) + } + } + + data = { + ...data, + pages: data.pages.map(page => { + return { + ...page, + items: page.items.filter(item => { + const isHiddenReply = + item.type === 'reply' && + item.subjectUri && + hiddenReplyUris.has(item.subjectUri) + return !isHiddenReply + }), + } + }), + } + + return data + }, + [selectArgs], + ), }) // The server may end up returning an empty page, a page with too few items, diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index fd419d1c..3370c361 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -138,6 +138,7 @@ export function sortThread( modCache: ThreadModerationCache, currentDid: string | undefined, justPostedUris: Set, + threadgateRecordHiddenReplies: Set, ): ThreadNode { if (node.type !== 'post') { return node @@ -185,6 +186,14 @@ export function sortThread( return 1 // current account's reply } + const aHidden = threadgateRecordHiddenReplies.has(a.uri) + const bHidden = threadgateRecordHiddenReplies.has(b.uri) + if (aHidden && !aIsBySelf && !bHidden) { + return 1 + } else if (bHidden && !bIsBySelf && !aHidden) { + return -1 + } + const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { @@ -222,7 +231,14 @@ export function sortThread( return b.post.indexedAt.localeCompare(a.post.indexedAt) }) node.replies.forEach(reply => - sortThread(reply, opts, modCache, currentDid, justPostedUris), + sortThread( + reply, + opts, + modCache, + currentDid, + justPostedUris, + threadgateRecordHiddenReplies, + ), ) } return node diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index 071a2e91..197903be 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -73,6 +73,30 @@ export function useGetPost() { ) } +export function useGetPosts() { + const queryClient = useQueryClient() + const agent = useAgent() + return useCallback( + async ({uris}: {uris: string[]}) => { + return queryClient.fetchQuery({ + queryKey: RQKEY(uris.join(',') || ''), + async queryFn() { + const res = await agent.getPosts({ + uris, + }) + + if (res.success) { + return res.data.posts + } else { + throw new Error('useGetPosts failed') + } + }, + }) + }, + [queryClient, agent], + ) +} + export function usePostLikeMutationQueue( post: Shadow, logContext: LogEvents['post:like']['logContext'] & diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts new file mode 100644 index 00000000..149b9cbe --- /dev/null +++ b/src/state/queries/postgate/index.ts @@ -0,0 +1,295 @@ +import React from 'react' +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPostgate, + AtUri, + BskyAgent, +} from '@atproto/api' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {networkRetry, retry} from '#/lib/async/retry' +import {logger} from '#/logger' +import {updatePostShadow} from '#/state/cache/post-shadow' +import {STALE} from '#/state/queries' +import {useGetPosts} from '#/state/queries/post' +import { + createMaybeDetachedQuoteEmbed, + createPostgateRecord, + mergePostgateRecords, + POSTGATE_COLLECTION, +} from '#/state/queries/postgate/util' +import {useAgent} from '#/state/session' + +export async function getPostgateRecord({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}): Promise { + const urip = new AtUri(postUri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + const {data} = await retry( + 2, + e => { + /* + * If the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: POSTGATE_COLLECTION, + rkey: urip.rkey, + }), + ) + + if (data.value && AppBskyFeedPostgate.isRecord(data.value)) { + return data.value + } else { + return undefined + } + } catch (e: any) { + /* + * If the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return undefined + } else { + throw e + } + } +} + +export async function writePostgateRecord({ + agent, + postUri, + postgate, +}: { + agent: BskyAgent + postUri: string + postgate: AppBskyFeedPostgate.Record +}) { + const postUrip = new AtUri(postUri) + + await networkRetry(2, () => + agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: POSTGATE_COLLECTION, + rkey: postUrip.rkey, + record: postgate, + }), + ) +} + +export async function upsertPostgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + postgate: AppBskyFeedPostgate.Record | undefined, + ) => Promise, +) { + const prev = await getPostgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writePostgateRecord({ + agent, + postUri, + postgate: next, + }) +} + +export const createPostgateQueryKey = (postUri: string) => [ + 'postgate-record', + postUri, +] +export function usePostgateQuery({postUri}: {postUri: string}) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.SECONDS.THIRTY, + queryKey: createPostgateQueryKey(postUri), + async queryFn() { + return (await getPostgateRecord({agent, postUri})) ?? null + }, + }) +} + +export function useWritePostgateMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ + postUri, + postgate, + }: { + postUri: string + postgate: AppBskyFeedPostgate.Record + }) => { + return writePostgateRecord({ + agent, + postUri, + postgate, + }) + }, + onSuccess(_, {postUri}) { + queryClient.invalidateQueries({ + queryKey: createPostgateQueryKey(postUri), + }) + }, + }) +} + +export function useToggleQuoteDetachmentMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + const getPosts = useGetPosts() + const prevEmbed = React.useRef() + + return useMutation({ + mutationFn: async ({ + post, + quoteUri, + action, + }: { + post: AppBskyFeedDefs.PostView + quoteUri: string + action: 'detach' | 'reattach' + }) => { + // cache here since post shadow mutates original object + prevEmbed.current = post.embed + + if (action === 'detach') { + updatePostShadow(queryClient, post.uri, { + embed: createMaybeDetachedQuoteEmbed({ + post, + quote: undefined, + quoteUri, + detached: true, + }), + }) + } + + await upsertPostgate({agent, postUri: quoteUri}, async prev => { + if (prev) { + if (action === 'detach') { + return mergePostgateRecords(prev, { + detachedEmbeddingUris: [post.uri], + }) + } else if (action === 'reattach') { + return { + ...prev, + detachedEmbeddingUris: + prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) || + [], + } + } + } else { + if (action === 'detach') { + return createPostgateRecord({ + post: quoteUri, + detachedEmbeddingUris: [post.uri], + }) + } + } + }) + }, + async onSuccess(_data, {post, quoteUri, action}) { + if (action === 'reattach') { + try { + const [quote] = await getPosts({uris: [quoteUri]}) + updatePostShadow(queryClient, post.uri, { + embed: createMaybeDetachedQuoteEmbed({ + post, + quote, + quoteUri: undefined, + detached: false, + }), + }) + } catch (e: any) { + // ok if this fails, it's just optimistic UI + logger.error(`Postgate: failed to get quote post for re-attachment`, { + safeMessage: e.message, + }) + } + } + }, + onError(_, {post, action}) { + if (action === 'detach' && prevEmbed.current) { + // detach failed, add the embed back + if ( + AppBskyEmbedRecord.isView(prevEmbed.current) || + AppBskyEmbedRecordWithMedia.isView(prevEmbed.current) + ) { + updatePostShadow(queryClient, post.uri, { + embed: prevEmbed.current, + }) + } + } + }, + onSettled() { + prevEmbed.current = undefined + }, + }) +} + +export function useToggleQuotepostEnabledMutation() { + const agent = useAgent() + + return useMutation({ + mutationFn: async ({ + postUri, + action, + }: { + postUri: string + action: 'enable' | 'disable' + }) => { + await upsertPostgate({agent, postUri: postUri}, async prev => { + if (prev) { + if (action === 'disable') { + return mergePostgateRecords(prev, { + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], + }) + } else if (action === 'enable') { + return { + ...prev, + embeddingRules: [], + } + } + } else { + if (action === 'disable') { + return createPostgateRecord({ + post: postUri, + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], + }) + } + } + }) + }, + }) +} diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts new file mode 100644 index 00000000..21509c3a --- /dev/null +++ b/src/state/queries/postgate/util.ts @@ -0,0 +1,196 @@ +import { + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPostgate, + AtUri, +} from '@atproto/api' + +export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' + +export function createPostgateRecord( + postgate: Partial & { + post: AppBskyFeedPostgate.Record['post'] + }, +): AppBskyFeedPostgate.Record { + return { + $type: POSTGATE_COLLECTION, + createdAt: new Date().toISOString(), + post: postgate.post, + detachedEmbeddingUris: postgate.detachedEmbeddingUris || [], + embeddingRules: postgate.embeddingRules || [], + } +} + +export function mergePostgateRecords( + prev: AppBskyFeedPostgate.Record, + next: Partial, +) { + const detachedEmbeddingUris = Array.from( + new Set([ + ...(prev.detachedEmbeddingUris || []), + ...(next.detachedEmbeddingUris || []), + ]), + ) + const embeddingRules = [ + ...(prev.embeddingRules || []), + ...(next.embeddingRules || []), + ].filter( + (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i, + ) + return createPostgateRecord({ + post: prev.post, + detachedEmbeddingUris, + embeddingRules, + }) +} + +export function createEmbedViewDetachedRecord({uri}: {uri: string}) { + const record: AppBskyEmbedRecord.ViewDetached = { + $type: 'app.bsky.embed.record#viewDetached', + uri, + detached: true, + } + return { + $type: 'app.bsky.embed.record#view', + record, + } +} + +export function createMaybeDetachedQuoteEmbed({ + post, + quote, + quoteUri, + detached, +}: + | { + post: AppBskyFeedDefs.PostView + quote: AppBskyFeedDefs.PostView + quoteUri: undefined + detached: false + } + | { + post: AppBskyFeedDefs.PostView + quote: undefined + quoteUri: string + detached: true + }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined { + if (AppBskyEmbedRecord.isView(post.embed)) { + if (detached) { + return createEmbedViewDetachedRecord({uri: quoteUri}) + } else { + return createEmbedRecordView({post: quote}) + } + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + if (detached) { + return { + ...post.embed, + record: createEmbedViewDetachedRecord({uri: quoteUri}), + } + } else { + return createEmbedRecordWithMediaView({post, quote}) + } + } +} + +export function createEmbedViewRecordFromPost( + post: AppBskyFeedDefs.PostView, +): AppBskyEmbedRecord.ViewRecord { + return { + $type: 'app.bsky.embed.record#viewRecord', + uri: post.uri, + cid: post.cid, + author: post.author, + value: post.record, + labels: post.labels, + replyCount: post.replyCount, + repostCount: post.repostCount, + likeCount: post.likeCount, + indexedAt: post.indexedAt, + } +} + +export function createEmbedRecordView({ + post, +}: { + post: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecord.View { + return { + $type: 'app.bsky.embed.record#view', + record: createEmbedViewRecordFromPost(post), + } +} + +export function createEmbedRecordWithMediaView({ + post, + quote, +}: { + post: AppBskyFeedDefs.PostView + quote: AppBskyFeedDefs.PostView +}): AppBskyEmbedRecordWithMedia.View | undefined { + if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return + return { + ...(post.embed || {}), + record: { + record: createEmbedViewRecordFromPost(quote), + }, + } +} + +export function getMaybeDetachedQuoteEmbed({ + viewerDid, + post, +}: { + viewerDid: string + post: AppBskyFeedDefs.PostView +}) { + if (AppBskyEmbedRecord.isView(post.embed)) { + // detached + if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) { + const urip = new AtUri(post.embed.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: true, + } + } + + // post + if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) { + const urip = new AtUri(post.embed.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: false, + } + } + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { + // detached + if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) { + const urip = new AtUri(post.embed.record.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: true, + } + } + + // post + if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) { + const urip = new AtUri(post.embed.record.record.uri) + return { + embed: post.embed, + uri: urip.toString(), + isOwnedByViewer: urip.host === viewerDid, + isDetached: false, + } + } + } +} + +export const embeddingRules = { + disableRule: {$type: 'app.bsky.feed.postgate#disableRule'}, +} diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts deleted file mode 100644 index 8b6aeba6..00000000 --- a/src/state/queries/threadgate.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' - -export type ThreadgateSetting = - | {type: 'nobody'} - | {type: 'mention'} - | {type: 'following'} - | {type: 'list'; list: unknown} - -export function threadgateViewToSettings( - threadgate: AppBskyFeedDefs.ThreadgateView | undefined, -): ThreadgateSetting[] { - const record = - threadgate && - AppBskyFeedThreadgate.isRecord(threadgate.record) && - AppBskyFeedThreadgate.validateRecord(threadgate.record).success - ? threadgate.record - : null - if (!record) { - return [] - } - if (!record.allow?.length) { - return [{type: 'nobody'}] - } - const settings: ThreadgateSetting[] = record.allow - .map(allow => { - let setting: ThreadgateSetting | undefined - if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { - setting = {type: 'mention'} - } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { - setting = {type: 'following'} - } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { - setting = {type: 'list', list: allow.list} - } - return setting - }) - .filter(n => !!n) - return settings -} diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts new file mode 100644 index 00000000..a88197cd --- /dev/null +++ b/src/state/queries/threadgate/index.ts @@ -0,0 +1,358 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetPostThread, + AppBskyFeedThreadgate, + AtUri, + BskyAgent, +} from '@atproto/api' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {networkRetry, retry} from '#/lib/async/retry' +import {until} from '#/lib/async/until' +import {STALE} from '#/state/queries' +import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' +import { + createThreadgateRecord, + mergeThreadgateRecords, + threadgateAllowUISettingToAllowRecordValue, + threadgateViewToAllowUISetting, +} from '#/state/queries/threadgate/util' +import {useAgent} from '#/state/session' +import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies' + +export * from '#/state/queries/threadgate/types' +export * from '#/state/queries/threadgate/util' + +export const threadgateRecordQueryKeyRoot = 'threadgate-record' +export const createThreadgateRecordQueryKey = (uri: string) => [ + threadgateRecordQueryKeyRoot, + uri, +] + +export function useThreadgateRecordQuery({ + enabled, + postUri, + initialData, +}: { + enabled?: boolean + postUri?: string + initialData?: AppBskyFeedThreadgate.Record +} = {}) { + const agent = useAgent() + + return useQuery({ + enabled: enabled ?? !!postUri, + queryKey: createThreadgateRecordQueryKey(postUri || ''), + placeholderData: initialData, + staleTime: STALE.MINUTES.ONE, + async queryFn() { + return getThreadgateRecord({ + agent, + postUri: postUri!, + }) + }, + }) +} + +export const threadgateViewQueryKeyRoot = 'threadgate-view' +export const createThreadgateViewQueryKey = (uri: string) => [ + threadgateViewQueryKeyRoot, + uri, +] +export function useThreadgateViewQuery({ + postUri, + initialData, +}: { + postUri?: string + initialData?: AppBskyFeedDefs.ThreadgateView +} = {}) { + const agent = useAgent() + + return useQuery({ + enabled: !!postUri, + queryKey: createThreadgateViewQueryKey(postUri || ''), + placeholderData: initialData, + staleTime: STALE.MINUTES.ONE, + async queryFn() { + return getThreadgateView({ + agent, + postUri: postUri!, + }) + }, + }) +} + +export async function getThreadgateView({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}) { + const {data} = await agent.app.bsky.feed.getPostThread({ + uri: postUri!, + depth: 0, + }) + + if (AppBskyFeedDefs.isThreadViewPost(data.thread)) { + return data.thread.post.threadgate ?? null + } + + return null +} + +export async function getThreadgateRecord({ + agent, + postUri, +}: { + agent: BskyAgent + postUri: string +}): Promise { + const urip = new AtUri(postUri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + const {data} = await retry( + 2, + e => { + /* + * If the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: 'app.bsky.feed.threadgate', + rkey: urip.rkey, + }), + ) + + if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) { + return data.value + } else { + return null + } + } catch (e: any) { + /* + * If the record doesn't exist, we want to return null instead of + * throwing an error. NB: This will also catch reference errors, such as + * a typo in the URI. + */ + if (e.message.includes(`Could not locate record:`)) { + return null + } else { + throw e + } + } +} + +export async function writeThreadgateRecord({ + agent, + postUri, + threadgate, +}: { + agent: BskyAgent + postUri: string + threadgate: AppBskyFeedThreadgate.Record +}) { + const postUrip = new AtUri(postUri) + const record = createThreadgateRecord({ + post: postUri, + allow: threadgate.allow, // can/should be undefined! + hiddenReplies: threadgate.hiddenReplies || [], + }) + + await networkRetry(2, () => + agent.api.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.feed.threadgate', + rkey: postUrip.rkey, + record, + }), + ) +} + +export async function upsertThreadgate( + { + agent, + postUri, + }: { + agent: BskyAgent + postUri: string + }, + callback: ( + threadgate: AppBskyFeedThreadgate.Record | null, + ) => Promise, +) { + const prev = await getThreadgateRecord({ + agent, + postUri, + }) + const next = await callback(prev) + if (!next) return + await writeThreadgateRecord({ + agent, + postUri, + threadgate: next, + }) +} + +/** + * Update the allow list for a threadgate record. + */ +export async function updateThreadgateAllow({ + agent, + postUri, + allow, +}: { + agent: BskyAgent + postUri: string + allow: ThreadgateAllowUISetting[] +}) { + return upsertThreadgate({agent, postUri}, async prev => { + if (prev) { + return { + ...prev, + allow: threadgateAllowUISettingToAllowRecordValue(allow), + } + } else { + return createThreadgateRecord({ + post: postUri, + allow: threadgateAllowUISettingToAllowRecordValue(allow), + }) + } + }) +} + +export function useSetThreadgateAllowMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + postUri, + allow, + }: { + postUri: string + allow: ThreadgateAllowUISetting[] + }) => { + return upsertThreadgate({agent, postUri}, async prev => { + if (prev) { + return { + ...prev, + allow: threadgateAllowUISettingToAllowRecordValue(allow), + } + } else { + return createThreadgateRecord({ + post: postUri, + allow: threadgateAllowUISettingToAllowRecordValue(allow), + }) + } + }) + }, + async onSuccess(_, {postUri, allow}) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + (res: AppBskyFeedGetPostThread.Response) => { + const thread = res.data.thread + if (AppBskyFeedDefs.isThreadViewPost(thread)) { + const fetchedSettings = threadgateViewToAllowUISetting( + thread.post.threadgate, + ) + return JSON.stringify(fetchedSettings) === JSON.stringify(allow) + } + return false + }, + () => { + return agent.app.bsky.feed.getPostThread({ + uri: postUri, + depth: 0, + }) + }, + ) + + queryClient.invalidateQueries({ + queryKey: [postThreadQueryKeyRoot], + }) + queryClient.invalidateQueries({ + queryKey: [threadgateRecordQueryKeyRoot], + }) + queryClient.invalidateQueries({ + queryKey: [threadgateViewQueryKeyRoot], + }) + }, + }) +} + +export function useToggleReplyVisibilityMutation() { + const agent = useAgent() + const queryClient = useQueryClient() + const hiddenReplies = useThreadgateHiddenReplyUrisAPI() + + return useMutation({ + mutationFn: async ({ + postUri, + replyUri, + action, + }: { + postUri: string + replyUri: string + action: 'hide' | 'show' + }) => { + if (action === 'hide') { + hiddenReplies.addHiddenReplyUri(replyUri) + } else if (action === 'show') { + hiddenReplies.removeHiddenReplyUri(replyUri) + } + + await upsertThreadgate({agent, postUri}, async prev => { + if (prev) { + if (action === 'hide') { + return mergeThreadgateRecords(prev, { + hiddenReplies: [replyUri], + }) + } else if (action === 'show') { + return { + ...prev, + hiddenReplies: + prev.hiddenReplies?.filter(uri => uri !== replyUri) || [], + } + } + } else { + if (action === 'hide') { + return createThreadgateRecord({ + post: postUri, + hiddenReplies: [replyUri], + }) + } + } + }) + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: [threadgateRecordQueryKeyRoot], + }) + }, + onError(_, {replyUri, action}) { + if (action === 'hide') { + hiddenReplies.removeHiddenReplyUri(replyUri) + } else if (action === 'show') { + hiddenReplies.addHiddenReplyUri(replyUri) + } + }, + }) +} diff --git a/src/state/queries/threadgate/types.ts b/src/state/queries/threadgate/types.ts new file mode 100644 index 00000000..0cbea311 --- /dev/null +++ b/src/state/queries/threadgate/types.ts @@ -0,0 +1,6 @@ +export type ThreadgateAllowUISetting = + | {type: 'everybody'} + | {type: 'nobody'} + | {type: 'mention'} + | {type: 'following'} + | {type: 'list'; list: unknown} diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts new file mode 100644 index 00000000..09ae0a0c --- /dev/null +++ b/src/state/queries/threadgate/util.ts @@ -0,0 +1,141 @@ +import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' + +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' + +export function threadgateViewToAllowUISetting( + threadgateView: AppBskyFeedDefs.ThreadgateView | undefined, +): ThreadgateAllowUISetting[] { + const threadgate = + threadgateView && + AppBskyFeedThreadgate.isRecord(threadgateView.record) && + AppBskyFeedThreadgate.validateRecord(threadgateView.record).success + ? threadgateView.record + : undefined + return threadgateRecordToAllowUISetting(threadgate) +} + +/** + * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of + * {@link ThreadgateAllowUISetting}, for use by app UI. + */ +export function threadgateRecordToAllowUISetting( + threadgate: AppBskyFeedThreadgate.Record | undefined, +): ThreadgateAllowUISetting[] { + /* + * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means + * anyone can reply. + * + * If `threadgate.allow === []` it means no one can reply, and we translate to UI code + * here. This was a historical choice, and we have no lexicon representation + * for 'replies disabled' other than an empty array. + */ + if (!threadgate || threadgate.allow === undefined) { + return [{type: 'everybody'}] + } + if (threadgate.allow.length === 0) { + return [{type: 'nobody'}] + } + + const settings: ThreadgateAllowUISetting[] = threadgate.allow + .map(allow => { + let setting: ThreadgateAllowUISetting | undefined + if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { + setting = {type: 'mention'} + } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { + setting = {type: 'following'} + } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { + setting = {type: 'list', list: allow.list} + } + return setting + }) + .filter(n => !!n) + return settings +} + +/** + * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on + * {@link AppBskyFeedThreadgate.Record}. + * + * If the `allow` property on the record is undefined, we infer that to mean + * that everyone can reply. If it's an empty array, we infer that to mean that + * no one can reply. + */ +export function threadgateAllowUISettingToAllowRecordValue( + threadgate: ThreadgateAllowUISetting[], +): AppBskyFeedThreadgate.Record['allow'] { + if (threadgate.find(v => v.type === 'everybody')) { + return undefined + } + + let allow: ( + | AppBskyFeedThreadgate.MentionRule + | AppBskyFeedThreadgate.FollowingRule + | AppBskyFeedThreadgate.ListRule + )[] = [] + + if (!threadgate.find(v => v.type === 'nobody')) { + for (const rule of threadgate) { + if (rule.type === 'mention') { + allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) + } else if (rule.type === 'following') { + allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) + } else if (rule.type === 'list') { + allow.push({ + $type: 'app.bsky.feed.threadgate#listRule', + list: rule.list, + }) + } + } + } + + return allow +} + +/** + * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their + * `allow` and `hiddenReplies` arrays and de-deduplicating them. + * + * Note: `allow` can be undefined here, be sure you don't accidentally set it + * to an empty array. See other comments in this file. + */ +export function mergeThreadgateRecords( + prev: AppBskyFeedThreadgate.Record, + next: Partial, +): AppBskyFeedThreadgate.Record { + // can be undefined if everyone can reply! + const allow: AppBskyFeedThreadgate.Record['allow'] | undefined = + prev.allow || next.allow + ? [...(prev.allow || []), ...(next.allow || [])].filter( + (v, i, a) => a.findIndex(t => t.$type === v.$type) === i, + ) + : undefined + const hiddenReplies = Array.from( + new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]), + ) + + return createThreadgateRecord({ + post: prev.post, + allow, // can be undefined! + hiddenReplies, + }) +} + +/** + * Create a new {@link AppBskyFeedThreadgate.Record} object with the given + * properties. + */ +export function createThreadgateRecord( + threadgate: Partial, +): AppBskyFeedThreadgate.Record { + if (!threadgate.post) { + throw new Error('Cannot create a threadgate record without a post URI') + } + + return { + $type: 'app.bsky.feed.threadgate', + post: threadgate.post, + createdAt: new Date().toISOString(), + allow: threadgate.allow, // can be undefined! + hiddenReplies: threadgate.hiddenReplies || [], + } +} diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx new file mode 100644 index 00000000..06fc2236 --- /dev/null +++ b/src/state/threadgate-hidden-replies.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +type StateContext = { + uris: Set + recentlyUnhiddenUris: Set +} +type ApiContext = { + addHiddenReplyUri: (uri: string) => void + removeHiddenReplyUri: (uri: string) => void +} + +const StateContext = React.createContext({ + uris: new Set(), + recentlyUnhiddenUris: new Set(), +}) + +const ApiContext = React.createContext({ + addHiddenReplyUri: () => {}, + removeHiddenReplyUri: () => {}, +}) + +export function Provider({children}: {children: React.ReactNode}) { + const [uris, setHiddenReplyUris] = React.useState>(new Set()) + const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState< + Set + >(new Set()) + + const stateCtx = React.useMemo( + () => ({ + uris, + recentlyUnhiddenUris, + }), + [uris, recentlyUnhiddenUris], + ) + + const apiCtx = React.useMemo( + () => ({ + addHiddenReplyUri(uri: string) { + setHiddenReplyUris(prev => new Set(prev.add(uri))) + setRecentlyUnhiddenUris(prev => { + prev.delete(uri) + return new Set(prev) + }) + }, + removeHiddenReplyUri(uri: string) { + setHiddenReplyUris(prev => { + prev.delete(uri) + return new Set(prev) + }) + setRecentlyUnhiddenUris(prev => new Set(prev.add(uri))) + }, + }), + [setHiddenReplyUris], + ) + + return ( + + {children} + + ) +} + +export function useThreadgateHiddenReplyUris() { + return React.useContext(StateContext) +} + +export function useThreadgateHiddenReplyUrisAPI() { + return React.useContext(ApiContext) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 0efbe70e..eefd0aff 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -58,9 +58,11 @@ import { useLanguagePrefs, useLanguagePrefsApi, } from '#/state/preferences/languages' +import {createPostgateRecord} from '#/state/queries/postgate/util' import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' -import {ThreadgateSetting} from '#/state/queries/threadgate' +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' +import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import {useUploadVideo} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -81,9 +83,12 @@ import {State as VideoUploadState} from 'state/queries/video/video' import {ComposerOpts} from 'state/shell/composer' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {atoms as a, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' +import {Text as NewText} from '#/components/Typography' import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' @@ -182,10 +187,14 @@ export const ComposePost = observer(function ComposePost({ }) const [publishOnUpload, setPublishOnUpload] = useState(false) - const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) const [extGif, setExtGif] = useState() const [labels, setLabels] = useState([]) - const [threadgate, setThreadgate] = useState([]) + const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = + useState( + threadgateViewToAllowUISetting(undefined), + ) + const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) const gallery = useMemo( () => new GalleryModel(initImageUris), @@ -335,7 +344,8 @@ export const ComposePost = observer(function ComposePost({ quote, extLink, labels, - threadgate, + threadgate: threadgateAllowUISettings, + postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), }) @@ -581,15 +591,40 @@ export const ComposePost = observer(function ComposePost({ )} {error !== '' && ( - - - + + + + + {error} + + - {error} )} @@ -680,8 +715,12 @@ export const ComposePost = observer(function ComposePost({ {replyTo ? null : ( )} diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 6cf2eea2..666473af 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -1,27 +1,33 @@ import React from 'react' import {Keyboard, StyleProp, ViewStyle} from 'react-native' import Animated, {AnimatedStyle} from 'react-native-reanimated' +import {AppBskyFeedPostgate} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {isNative} from '#/platform/detection' -import {ThreadgateSetting} from '#/state/queries/threadgate' +import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {useAnalytics} from 'lib/analytics/analytics' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor' -import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' +import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' export function ThreadgateBtn({ - threadgate, - onChange, + postgate, + onChangePostgate, + threadgateAllowUISettings, + onChangeThreadgateAllowUISettings, style, }: { - threadgate: ThreadgateSetting[] - onChange: (v: ThreadgateSetting[]) => void + postgate: AppBskyFeedPostgate.Record + onChangePostgate: (v: AppBskyFeedPostgate.Record) => void + + threadgateAllowUISettings: ThreadgateAllowUISetting[] + onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void + style?: StyleProp> }) { const {track} = useAnalytics() @@ -38,13 +44,15 @@ export function ThreadgateBtn({ control.open() } - const isEverybody = threadgate.length === 0 - const isNobody = !!threadgate.find(gate => gate.type === 'nobody') - const label = isEverybody - ? _(msg`Everybody can reply`) - : isNobody - ? _(msg`Nobody can reply`) - : _(msg`Some people can reply`) + const anyoneCanReply = + threadgateAllowUISettings.length === 1 && + threadgateAllowUISettings[0].type === 'everybody' + const anyoneCanQuote = + !postgate.embeddingRules || postgate.embeddingRules.length === 0 + const anyoneCanInteract = anyoneCanReply && anyoneCanQuote + const label = anyoneCanInteract + ? _(msg`Anybody can interact`) + : _(msg`Interaction limited`) return ( <> @@ -59,16 +67,19 @@ export function ThreadgateBtn({ accessibilityHint={_( msg`Opens a dialog to choose who can reply to this thread`, )}> - + {label} - { + control.close() + }} + postgate={postgate} + onChangePostgate={onChangePostgate} + threadgateAllowUISettings={threadgateAllowUISettings} + onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} /> ) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 2938ea25..31751443 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -1,4 +1,6 @@ import {useEffect, useState} from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {logger} from '#/logger' import {useFetchDid} from '#/state/queries/handle' @@ -7,6 +9,7 @@ import {useAgent} from '#/state/session' import * as apilib from 'lib/api/index' import {POST_IMG_MAX} from 'lib/constants' import { + EmbeddingDisabledError, getFeedAsEmbed, getListAsEmbed, getPostAsQuote, @@ -28,9 +31,12 @@ import {ComposerOpts} from 'state/shell/composer' export function useExternalLinkFetch({ setQuote, + setError, }: { setQuote: (opts: ComposerOpts['quote']) => void + setError: (err: string) => void }) { + const {_} = useLingui() const [extLink, setExtLink] = useState( undefined, ) @@ -57,9 +63,13 @@ export function useExternalLinkFetch({ setExtLink(undefined) }, err => { - logger.error('Failed to fetch post for quote embedding', { - message: err.toString(), - }) + if (err instanceof EmbeddingDisabledError) { + setError(_(msg`This post's author has disabled quote posts.`)) + } else { + logger.error('Failed to fetch post for quote embedding', { + message: err.toString(), + }) + } setExtLink(undefined) }, ) @@ -170,7 +180,7 @@ export function useExternalLinkFetch({ }) } return cleanup - }, [extLink, setQuote, getPost, fetchDid, agent]) + }, [_, extLink, setQuote, getPost, fetchDid, agent, setError]) return {extLink, setExtLink} } diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx index d573d27a..f91a041d 100644 --- a/src/view/com/post-thread/PostQuotes.tsx +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -10,7 +10,6 @@ import {useLingui} from '@lingui/react' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePostQuotesQuery} from '#/state/queries/post-quotes' import {useResolveUriQuery} from '#/state/queries/resolve-uri' @@ -25,16 +24,14 @@ import {List} from '../util/List' function renderItem({ item, - index, }: { item: { post: AppBskyFeedDefs.PostView moderation: ModerationDecision record: AppBskyFeedPost.Record } - index: number }) { - return + return } function keyExtractor(item: { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index c64be8d6..bd778fd9 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -3,7 +3,12 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native' import {runOnJS} from 'react-native-reanimated' import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedThreadgate, + AtUri, +} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -23,6 +28,7 @@ import { usePostThreadQuery, } from '#/state/queries/post-thread' import {usePreferencesQuery} from '#/state/queries/preferences' +import {useThreadgateRecordQuery} from '#/state/queries/threadgate' import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' @@ -113,6 +119,28 @@ export function PostThread({uri}: {uri: string | undefined}) { ) const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined + const replyRef = + rootPostRecord && AppBskyFeedPost.isRecord(rootPostRecord) + ? rootPostRecord.reply + : undefined + const rootPostUri = replyRef ? replyRef.root.uri : rootPost?.uri + + const isOP = + currentAccount && + rootPostUri && + currentAccount?.did === new AtUri(rootPostUri).host + const {data: threadgateRecord} = useThreadgateRecordQuery({ + /** + * If the user is the OP and the root post has a threadgate, we should load + * the threadgate record. Otherwise, fallback to initialData, which is taken + * from the response from `getPostThread`. + */ + enabled: Boolean(isOP && rootPostUri), + postUri: rootPostUri, + initialData: rootPost?.threadgate?.record as + | AppBskyFeedThreadgate.Record + | undefined, + }) const moderationOpts = useModerationOpts() const isNoPwi = React.useMemo(() => { @@ -167,6 +195,9 @@ export function PostThread({uri}: {uri: string | undefined}) { const skeleton = React.useMemo(() => { const threadViewPrefs = preferences?.threadViewPrefs if (!threadViewPrefs || !thread) return null + const threadgateRecordHiddenReplies = new Set( + threadgateRecord?.hiddenReplies || [], + ) return createThreadSkeleton( sortThread( @@ -175,11 +206,13 @@ export function PostThread({uri}: {uri: string | undefined}) { threadModerationCache, currentDid, justPostedUris, + threadgateRecordHiddenReplies, ), - !!currentDid, + currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, + threadgateRecordHiddenReplies, ) }, [ thread, @@ -189,6 +222,7 @@ export function PostThread({uri}: {uri: string | undefined}) { threadModerationCache, hiddenRepliesState, justPostedUris, + threadgateRecord, ]) const error = React.useMemo(() => { @@ -425,6 +459,7 @@ export function PostThread({uri}: {uri: string | undefined}) { , ): ThreadSkeletonParts | null { if (!node) return null return { - parents: Array.from(flattenThreadParents(node, hasSession)), + parents: Array.from(flattenThreadParents(node, !!currentDid)), highlightedPost: node, replies: Array.from( flattenThreadReplies( node, - hasSession, + currentDid, treeView, modCache, showHiddenReplies, + threadgateRecordHiddenReplies, ), ), } @@ -594,14 +631,15 @@ enum HiddenReplyType { function* flattenThreadReplies( node: ThreadNode, - hasSession: boolean, + currentDid: string | undefined, treeView: boolean, modCache: ThreadModerationCache, showHiddenReplies: boolean, + threadgateRecordHiddenReplies: Set, ): Generator { if (node.type === 'post') { // dont show pwi-opted-out posts to logged out users - if (!hasSession && hasPwiOptOut(node)) { + if (!currentDid && hasPwiOptOut(node)) { return HiddenReplyType.None } @@ -616,6 +654,16 @@ function* flattenThreadReplies( return HiddenReplyType.Hidden } } + + if (!showHiddenReplies) { + const hiddenByThreadgate = threadgateRecordHiddenReplies.has( + node.post.uri, + ) + const authorIsViewer = node.post.author.did === currentDid + if (hiddenByThreadgate && !authorIsViewer) { + return HiddenReplyType.Hidden + } + } } if (!node.ctx.isHighlightedPost) { @@ -627,10 +675,11 @@ function* flattenThreadReplies( for (const reply of node.replies) { let hiddenReply = yield* flattenThreadReplies( reply, - hasSession, + currentDid, treeView, modCache, showHiddenReplies, + threadgateRecordHiddenReplies, ) if (hiddenReply > hiddenReplies) { hiddenReplies = hiddenReply diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 26a5f2f0..da187f5d 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, + AppBskyFeedThreadgate, AtUri, ModerationDecision, RichText as RichTextAPI, @@ -29,6 +30,7 @@ import {isWeb} from 'platform/detection' import {useSession} from 'state/session' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {atoms as a} from '#/alf' +import {AppModerationCause} from '#/components/Pills' import {RichText} from '#/components/RichText' import {ContentHider} from '../../../components/moderation/ContentHider' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' @@ -61,6 +63,7 @@ export function PostThreadItem({ overrideBlur, onPostReply, hideTopBorder, + threadgateRecord, }: { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record @@ -77,6 +80,7 @@ export function PostThreadItem({ overrideBlur: boolean onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean + threadgateRecord?: AppBskyFeedThreadgate.Record }) { const postShadowed = usePostShadow(post) const richText = useMemo( @@ -111,6 +115,7 @@ export function PostThreadItem({ overrideBlur={overrideBlur} onPostReply={onPostReply} hideTopBorder={hideTopBorder} + threadgateRecord={threadgateRecord} /> ) } @@ -154,6 +159,7 @@ let PostThreadItemLoaded = ({ overrideBlur, onPostReply, hideTopBorder, + threadgateRecord, }: { post: Shadow record: AppBskyFeedPost.Record @@ -171,6 +177,7 @@ let PostThreadItemLoaded = ({ overrideBlur: boolean onPostReply: (postUri: string | undefined) => void hideTopBorder?: boolean + threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const pal = usePalette('default') const {_} = useLingui() @@ -199,6 +206,24 @@ let PostThreadItemLoaded = ({ return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) const repostsTitle = _(msg`Reposts of this post`) + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { + const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes( + post.uri, + ) + const isControlledByViewer = + threadgateRecord && + new AtUri(threadgateRecord.post).host === currentAccount?.did + if (!isControlledByViewer) return [] + return threadgateRecord && isPostHiddenByThreadgate + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: new AtUri(threadgateRecord.post).host}, + priority: 6, + }, + ] + : [] + }, [post, threadgateRecord, currentAccount?.did]) const quotesHref = React.useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') @@ -320,6 +345,7 @@ let PostThreadItemLoaded = ({ size="lg" includeMute style={[a.pt_2xs, a.pb_sm]} + additionalCauses={additionalPostAlerts} /> {richText?.text ? ( @@ -540,6 +567,7 @@ let PostThreadItemLoaded = ({ {richText?.text ? ( @@ -571,6 +599,7 @@ let PostThreadItemLoaded = ({ richText={richText} onPressReply={onPressReply} logContext="PostThreadItem" + threadgateRecord={threadgateRecord} /> @@ -677,6 +706,7 @@ function ExpandedPostDetails({ const pal = usePalette('default') const {_} = useLingui() const openLink = useOpenLink() + const isRootPost = !('reply' in post.record) const onTranslatePress = React.useCallback(() => { openLink(translatorUrl) @@ -693,7 +723,9 @@ function ExpandedPostDetails({ s.mb10, ]}> {niceDate(post.indexedAt)} - + {isRootPost && ( + + )} {needsTranslation && ( <> · diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 0fef4c5a..e90e8b88 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -4,6 +4,7 @@ import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, + AppBskyFeedThreadgate, AtUri, ModerationDecision, RichText as RichTextAPI, @@ -21,6 +22,7 @@ import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' +import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' import {MAX_POST_LINES} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' @@ -33,6 +35,7 @@ import {precacheProfile} from 'state/queries/profile' import {atoms as a} from '#/alf' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {ContentHider} from '#/components/moderation/ContentHider' +import {AppModerationCause} from '#/components/Pills' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' @@ -80,7 +83,11 @@ export function FeedItem({ hideTopBorder, isParentBlocked, isParentNotFound, -}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { + rootPost, +}: FeedItemProps & { + post: AppBskyFeedDefs.PostView + rootPost: AppBskyFeedDefs.PostView +}): React.ReactNode { const postShadowed = usePostShadow(post) const richText = useMemo( () => @@ -112,6 +119,7 @@ export function FeedItem({ hideTopBorder={hideTopBorder} isParentBlocked={isParentBlocked} isParentNotFound={isParentNotFound} + rootPost={rootPost} /> ) } @@ -133,9 +141,11 @@ let FeedItemInner = ({ hideTopBorder, isParentBlocked, isParentNotFound, + rootPost, }: FeedItemProps & { richText: RichTextAPI post: Shadow + rootPost: AppBskyFeedDefs.PostView }): React.ReactNode => { const queryClient = useQueryClient() const {openComposer} = useComposerControls() @@ -217,6 +227,12 @@ let FeedItemInner = ({ AppBskyFeedDefs.isReasonRepost(reason) && reason.by.did === currentAccount?.did + const threadgateRecord = AppBskyFeedThreadgate.isRecord( + rootPost.threadgate?.record, + ) + ? rootPost.threadgate.record + : undefined + return ( @@ -381,23 +400,63 @@ let FeedItemInner = ({ FeedItemInner = memo(FeedItemInner) let PostContent = ({ + post, moderation, richText, postEmbed, postAuthor, onOpenEmbed, + threadgateRecord, }: { moderation: ModerationDecision richText: RichTextAPI postEmbed: AppBskyFeedDefs.PostView['embed'] postAuthor: AppBskyFeedDefs.PostView['author'] onOpenEmbed: () => void + post: AppBskyFeedDefs.PostView + threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const pal = usePalette('default') const {_} = useLingui() + const {currentAccount} = useSession() const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, ) + const {uris: hiddenReplyUris, recentlyUnhiddenUris} = + useThreadgateHiddenReplyUris() + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { + const isPostHiddenByHiddenReplyCache = hiddenReplyUris.has(post.uri) + const isPostHiddenByThreadgate = + !recentlyUnhiddenUris.has(post.uri) && + !!threadgateRecord?.hiddenReplies?.includes(post.uri) + const isHidden = isPostHiddenByHiddenReplyCache || isPostHiddenByThreadgate + const isControlledByViewer = + isPostHiddenByHiddenReplyCache || + (threadgateRecord && + new AtUri(threadgateRecord.post).host === currentAccount?.did) + if (!isControlledByViewer) return [] + const alertSource = + threadgateRecord && isPostHiddenByThreadgate + ? new AtUri(threadgateRecord.post).host + : isPostHiddenByHiddenReplyCache + ? currentAccount?.did + : undefined + return isHidden && alertSource + ? [ + { + type: 'reply-hidden', + source: {type: 'user', did: alertSource}, + priority: 6, + }, + ] + : [] + }, [ + post, + hiddenReplyUris, + recentlyUnhiddenUris, + threadgateRecord, + currentAccount?.did, + ]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -409,7 +468,11 @@ let PostContent = ({ modui={moderation.ui('contentList')} ignoreMute childContainerStyle={styles.contentHiderChild}> - + {richText.text ? ( Reply to a blocked post } else if (notFound) { - label = Reply to an unknown post + label = Reply to a post } else if (profile != null) { const isMe = profile.did === currentAccount?.did if (isMe) { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 9676eff1..0920026f 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -37,6 +37,7 @@ let FeedSlice = ({ hideTopBorder={hideTopBorder} isParentBlocked={slice.items[0].isParentBlocked} isParentNotFound={slice.items[0].isParentNotFound} + rootPost={slice.items[0].post} /> ) @@ -95,6 +98,7 @@ let FeedSlice = ({ isParentBlocked={slice.items[i].isParentBlocked} isParentNotFound={slice.items[i].isParentNotFound} hideTopBorder={hideTopBorder && i === 0} + rootPost={slice.items[0].post} /> ))} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 6c82ec8c..b293b0df 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,5 +1,6 @@ import React, {memo} from 'react' import { + Platform, Pressable, type PressableProps, type StyleProp, @@ -9,6 +10,7 @@ import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, + AppBskyFeedThreadgate, AtUri, RichText as RichTextAPI, } from '@atproto/api' @@ -31,7 +33,11 @@ import { usePostDeleteMutation, useThreadMuteMutationQueue, } from '#/state/queries/post' +import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' +import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' +import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useSession} from '#/state/session' +import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {getCurrentRoute} from 'lib/routes/helpers' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' @@ -40,6 +46,10 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {EmbedDialog} from '#/components/dialogs/Embed' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' @@ -50,13 +60,16 @@ import { EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, } from '#/components/icons/Emoji' +import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' @@ -73,6 +86,7 @@ let PostDropdownBtn = ({ hitSlop, size, timestamp, + threadgateRecord, }: { testID: string post: Shadow @@ -83,6 +97,7 @@ let PostDropdownBtn = ({ hitSlop?: PressableProps['hitSlop'] size?: 'lg' | 'md' | 'sm' timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const theme = useTheme() @@ -104,17 +119,46 @@ let PostDropdownBtn = ({ const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() + const postInteractionSettingsDialogControl = useDialogControl() + const quotePostDetachConfirmControl = useDialogControl() + const hideReplyConfirmControl = useDialogControl() + const {mutateAsync: toggleReplyVisibility} = + useToggleReplyVisibilityMutation() + const {uris: hiddenReplies, recentlyUnhiddenUris} = + useThreadgateHiddenReplyUris() + const postUri = post.uri const postCid = post.cid const postAuthor = post.author + const quoteEmbed = React.useMemo(() => { + if (!currentAccount || !post.embed) return + return getMaybeDetachedQuoteEmbed({ + viewerDid: currentAccount.did, + post, + }) + }, [post, currentAccount]) const rootUri = record.reply?.root?.uri || postUri + const isReply = Boolean(record.reply) const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( post, rootUri, ) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isAuthor = postAuthor.did === currentAccount?.did + const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did + const isReplyHiddenByThreadgate = + hiddenReplies.has(postUri) || + (!recentlyUnhiddenUris.has(postUri) && + threadgateRecord?.hiddenReplies?.includes(postUri)) + + const {mutateAsync: toggleQuoteDetachment, isPending} = + useToggleQuoteDetachmentMutation() + + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) const href = React.useMemo(() => { const urip = new AtUri(postUri) @@ -242,7 +286,65 @@ let PostDropdownBtn = ({ [navigation, postUri], ) + const onToggleQuotePostAttachment = React.useCallback(async () => { + if (!quoteEmbed) return + + const action = quoteEmbed.isDetached ? 'reattach' : 'detach' + const isDetach = action === 'detach' + + try { + await toggleQuoteDetachment({ + post, + quoteUri: quoteEmbed.uri, + action: quoteEmbed.isDetached ? 'reattach' : 'detach', + }) + Toast.show( + isDetach + ? _(msg`Quote post was successfully detached`) + : _(msg`Quote post was re-attached`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating quote attachment failed`)) + logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) + } + }, [_, quoteEmbed, post, toggleQuoteDetachment]) + + const canHidePostForMe = !isAuthor && !isPostHidden const canEmbed = isWeb && gtMobile && !hideInPWI + const canHideReplyForEveryone = + !isAuthor && isRootPostAuthor && !isPostHidden && isReply + const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer + + const onToggleReplyVisibility = React.useCallback(async () => { + // TODO no threadgate? + if (!canHideReplyForEveryone) return + + const action = isReplyHiddenByThreadgate ? 'show' : 'hide' + const isHide = action === 'hide' + + try { + await toggleReplyVisibility({ + postUri: rootUri, + replyUri: postUri, + action, + }) + Toast.show( + isHide + ? _(msg`Reply was successfully hidden`) + : _(msg`Reply visibility updated`), + ) + } catch (e: any) { + Toast.show(_(msg`Updating reply visibility failed`)) + logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) + } + }, [ + _, + isReplyHiddenByThreadgate, + rootUri, + postUri, + canHideReplyForEveryone, + toggleReplyVisibility, + ]) return ( @@ -383,20 +485,92 @@ let PostDropdownBtn = ({ {_(msg`Mute words & tags`)} - - {!isAuthor && !isPostHidden && ( - - {_(msg`Hide post`)} - - - )} )} + {hasSession && + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( + <> + + + {canHidePostForMe && ( + + + {isReply + ? _(msg`Hide reply for me`) + : _(msg`Hide post for me`)} + + + + )} + {canHideReplyForEveryone && ( + hideReplyConfirmControl.open() + }> + + {isReplyHiddenByThreadgate + ? _(msg`Show reply for everyone`) + : _(msg`Hide reply for everyone`)} + + + + )} + + {canDetachQuote && ( + quotePostDetachConfirmControl.open() + }> + + {quoteEmbed.isDetached + ? _(msg`Re-attach quote`) + : _(msg`Detach quote`)} + + + + )} + + + )} + {hasSession && ( <> @@ -412,13 +586,34 @@ let PostDropdownBtn = ({ )} {isAuthor && ( - - {_(msg`Delete post`)} - - + <> + + + {_(msg`Edit interaction settings`)} + + + + + {_(msg`Delete post`)} + + + )} @@ -439,8 +634,10 @@ let PostDropdownBtn = ({ ) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index ad586384..0cfa3fc4 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -10,6 +10,7 @@ import * as Clipboard from 'expo-clipboard' import { AppBskyFeedDefs, AppBskyFeedPost, + AppBskyFeedThreadgate, AtUri, RichText as RichTextAPI, } from '@atproto/api' @@ -60,6 +61,7 @@ let PostCtrls = ({ onPressReply, onPostReply, logContext, + threadgateRecord, }: { big?: boolean post: Shadow @@ -70,6 +72,7 @@ let PostCtrls = ({ onPressReply: () => void onPostReply?: (postUri: string | undefined) => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const t = useTheme() const {_} = useLingui() @@ -256,6 +259,7 @@ let PostCtrls = ({ onRepost={onRepost} onQuote={onQuote} big={big} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} /> @@ -344,6 +348,7 @@ let PostCtrls = ({ style={{padding: 5}} hitSlop={POST_CTRL_HITSLOP} timestamp={post.indexedAt} + threadgateRecord={threadgateRecord} /> {gate('debug_show_feedcontext') && feedContext && ( diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index d49cda44..5994b7ef 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -20,6 +20,7 @@ interface Props { onRepost: () => void onQuote: () => void big?: boolean + embeddingDisabled: boolean } let RepostButton = ({ @@ -28,6 +29,7 @@ let RepostButton = ({ onRepost, onQuote, big, + embeddingDisabled, }: Props): React.ReactNode => { const t = useTheme() const {_} = useLingui() @@ -111,9 +113,14 @@ let RepostButton = ({ diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 17ab736c..9a8776b9 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -20,6 +20,7 @@ interface Props { onRepost: () => void onQuote: () => void big?: boolean + embeddingDisabled: boolean } export const RepostButton = ({ @@ -28,6 +29,7 @@ export const RepostButton = ({ onRepost, onQuote, big, + embeddingDisabled, }: Props) => { const t = useTheme() const {_} = useLingui() @@ -76,10 +78,19 @@ export const RepostButton = ({ - {_(msg`Quote post`)} + + {embeddingDisabled + ? _(msg`Quote posts disabled`) + : _(msg`Quote post`)} + diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 20c05b69..192aea70 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -26,6 +26,7 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {s} from '#/lib/styles' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' import {usePalette} from 'lib/hooks/usePalette' import {InfoCircleIcon} from 'lib/icons' import {makeProfileLink} from 'lib/routes/links' @@ -52,6 +53,7 @@ export function MaybeQuoteEmbed({ allowNestedQuotes?: boolean }) { const pal = usePalette('default') + const {currentAccount} = useSession() if ( AppBskyEmbedRecord.isViewRecord(embed.record) && AppBskyFeedPost.isRecord(embed.record.value) && @@ -84,6 +86,22 @@ export function MaybeQuoteEmbed({ ) + } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { + const isViewerOwner = currentAccount?.did + ? embed.record.uri.includes(currentAccount.did) + : false + return ( + + + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + + + ) } return null } diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index 7d0d2fb0..9c609348 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -807,6 +807,7 @@ function MockPostFeedItem({ showReplyTo={false} reason={undefined} feedContext={''} + rootPost={post} /> ) } diff --git a/yarn.lock b/yarn.lock index 995c548b..da842c89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -72,10 +72,10 @@ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== -"@atproto/api@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" - integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== +"@atproto/api@0.13.2": + version "0.13.2" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" + integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.1" @@ -85,10 +85,10 @@ multiformats "^9.9.0" tlds "^1.234.0" -"@atproto/api@^0.13.2": - version "0.13.2" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" - integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== +"@atproto/api@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" + integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.1"