my fork of the bluesky client
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Detached QPs and hidden replies (#4878)

Co-authored-by: Hailey <me@haileyok.com>

authored by

Eric Bailey
Hailey
and committed by
GitHub
6616a646 56ab5e17

+2579 -617
+1
assets/icons/eye_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1 -1
package.json
··· 52 52 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 53 53 }, 54 54 "dependencies": { 55 - "@atproto/api": "^0.13.2", 55 + "@atproto/api": "0.13.2", 56 56 "@bam.tech/react-native-image-resizer": "^3.0.4", 57 57 "@braintree/sanitize-url": "^6.0.2", 58 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+18 -15
src/App.native.tsx
··· 50 50 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 51 51 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 52 52 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 53 + import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 53 54 import {TestCtrls} from '#/view/com/testing/TestCtrls' 54 55 import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' 55 56 import * as Toast from '#/view/com/util/Toast' ··· 122 123 <ModerationOptsProvider> 123 124 <LoggedOutViewProvider> 124 125 <SelectedFeedProvider> 125 - <UnreadNotifsProvider> 126 - <BackgroundNotificationPreferencesProvider> 127 - <MutedThreadsProvider> 128 - <TourProvider> 129 - <ProgressGuideProvider> 130 - <GestureHandlerRootView 131 - style={s.h100pct}> 132 - <TestCtrls /> 133 - <Shell /> 134 - </GestureHandlerRootView> 135 - </ProgressGuideProvider> 136 - </TourProvider> 137 - </MutedThreadsProvider> 138 - </BackgroundNotificationPreferencesProvider> 139 - </UnreadNotifsProvider> 126 + <HiddenRepliesProvider> 127 + <UnreadNotifsProvider> 128 + <BackgroundNotificationPreferencesProvider> 129 + <MutedThreadsProvider> 130 + <TourProvider> 131 + <ProgressGuideProvider> 132 + <GestureHandlerRootView 133 + style={s.h100pct}> 134 + <TestCtrls /> 135 + <Shell /> 136 + </GestureHandlerRootView> 137 + </ProgressGuideProvider> 138 + </TourProvider> 139 + </MutedThreadsProvider> 140 + </BackgroundNotificationPreferencesProvider> 141 + </UnreadNotifsProvider> 142 + </HiddenRepliesProvider> 140 143 </SelectedFeedProvider> 141 144 </LoggedOutViewProvider> 142 145 </ModerationOptsProvider>
+16 -13
src/App.web.tsx
··· 39 39 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 40 40 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 41 41 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 42 + import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 42 43 import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' 43 44 import * as Toast from '#/view/com/util/Toast' 44 45 import {ToastContainer} from '#/view/com/util/Toast.web' ··· 105 106 <ModerationOptsProvider> 106 107 <LoggedOutViewProvider> 107 108 <SelectedFeedProvider> 108 - <UnreadNotifsProvider> 109 - <BackgroundNotificationPreferencesProvider> 110 - <MutedThreadsProvider> 111 - <SafeAreaProvider> 112 - <TourProvider> 113 - <ProgressGuideProvider> 114 - <Shell /> 115 - </ProgressGuideProvider> 116 - </TourProvider> 117 - </SafeAreaProvider> 118 - </MutedThreadsProvider> 119 - </BackgroundNotificationPreferencesProvider> 120 - </UnreadNotifsProvider> 109 + <HiddenRepliesProvider> 110 + <UnreadNotifsProvider> 111 + <BackgroundNotificationPreferencesProvider> 112 + <MutedThreadsProvider> 113 + <SafeAreaProvider> 114 + <TourProvider> 115 + <ProgressGuideProvider> 116 + <Shell /> 117 + </ProgressGuideProvider> 118 + </TourProvider> 119 + </SafeAreaProvider> 120 + </MutedThreadsProvider> 121 + </BackgroundNotificationPreferencesProvider> 122 + </UnreadNotifsProvider> 123 + </HiddenRepliesProvider> 121 124 </SelectedFeedProvider> 122 125 </LoggedOutViewProvider> 123 126 </ModerationOptsProvider>
+10 -1
src/components/Pills.tsx
··· 13 13 } from '#/components/moderation/ModerationDetailsDialog' 14 14 import {Text} from '#/components/Typography' 15 15 16 + export type AppModerationCause = 17 + | ModerationCause 18 + | { 19 + type: 'reply-hidden' 20 + source: {type: 'user'; did: string} 21 + priority: 6 22 + downgraded?: boolean 23 + } 24 + 16 25 export type CommonProps = { 17 26 size?: 'sm' | 'lg' 18 27 } ··· 40 49 } 41 50 42 51 export type LabelProps = { 43 - cause: ModerationCause 52 + cause: AppModerationCause 44 53 disableDetailsDialog?: boolean 45 54 noBg?: boolean 46 55 } & CommonProps
+127 -166
src/components/WhoCanReply.tsx
··· 1 1 import React from 'react' 2 - import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' 2 + import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native' 3 3 import { 4 4 AppBskyFeedDefs, 5 - AppBskyFeedGetPostThread, 5 + AppBskyFeedPost, 6 6 AppBskyGraphDefs, 7 7 AtUri, 8 - BskyAgent, 9 8 } from '@atproto/api' 10 9 import {msg, Trans} from '@lingui/macro' 11 10 import {useLingui} from '@lingui/react' 12 - import {useQueryClient} from '@tanstack/react-query' 13 11 14 - import {createThreadgate} from '#/lib/api' 15 - import {until} from '#/lib/async/until' 16 12 import {HITSLOP_10} from '#/lib/constants' 17 13 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 18 - import {logger} from '#/logger' 19 14 import {isNative} from '#/platform/detection' 20 - import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' 21 15 import { 22 - ThreadgateSetting, 23 - threadgateViewToSettings, 16 + ThreadgateAllowUISetting, 17 + threadgateViewToAllowUISetting, 24 18 } from '#/state/queries/threadgate' 25 - import {useAgent} from '#/state/session' 26 - import * as Toast from 'view/com/util/Toast' 27 19 import {atoms as a, useTheme} from '#/alf' 28 20 import {Button} from '#/components/Button' 29 21 import * as Dialog from '#/components/Dialog' 30 22 import {useDialogControl} from '#/components/Dialog' 23 + import { 24 + PostInteractionSettingsDialog, 25 + usePrefetchPostInteractionSettings, 26 + } from '#/components/dialogs/PostInteractionSettingsDialog' 31 27 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 32 28 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 33 29 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 30 + import {InlineLinkText} from '#/components/Link' 34 31 import {Text} from '#/components/Typography' 35 - import {TextLink} from '../view/com/util/Link' 36 - import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor' 37 32 import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 38 33 39 34 interface WhoCanReplyProps { ··· 47 42 const t = useTheme() 48 43 const infoDialogControl = useDialogControl() 49 44 const editDialogControl = useDialogControl() 50 - const agent = useAgent() 51 - const queryClient = useQueryClient() 52 45 53 - const settings = React.useMemo( 54 - () => threadgateViewToSettings(post.threadgate), 55 - [post], 56 - ) 57 - const isRootPost = !('reply' in post.record) 46 + /* 47 + * `WhoCanReply` is only used for root posts atm, in case this changes 48 + * unexpectedly, we should check to make sure it's for sure the root URI. 49 + */ 50 + const rootUri = 51 + AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root 52 + ? post.record.reply.root.uri 53 + : post.uri 54 + const settings = React.useMemo(() => { 55 + return threadgateViewToAllowUISetting(post.threadgate) 56 + }, [post.threadgate]) 58 57 59 - if (!isRootPost) { 60 - return null 61 - } 62 - if (!settings.length && !isThreadAuthor) { 63 - return null 64 - } 58 + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ 59 + postUri: post.uri, 60 + rootPostUri: rootUri, 61 + }) 65 62 66 - const isEverybody = settings.length === 0 67 - const isNobody = !!settings.find(gate => gate.type === 'nobody') 68 - const description = isEverybody 63 + const anyoneCanReply = 64 + settings.length === 1 && settings[0].type === 'everybody' 65 + const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody' 66 + const description = anyoneCanReply 69 67 ? _(msg`Everybody can reply`) 70 - : isNobody 68 + : noOneCanReply 71 69 ? _(msg`Replies disabled`) 72 70 : _(msg`Some people can reply`) 73 71 74 - const onPressEdit = () => { 72 + const onPressOpen = () => { 75 73 if (isNative && Keyboard.isVisible()) { 76 74 Keyboard.dismiss() 77 75 } ··· 82 80 } 83 81 } 84 82 85 - const onEditConfirm = async (newSettings: ThreadgateSetting[]) => { 86 - if (JSON.stringify(settings) === JSON.stringify(newSettings)) { 87 - return 88 - } 89 - try { 90 - if (newSettings.length) { 91 - await createThreadgate(agent, post.uri, newSettings) 92 - } else { 93 - await agent.api.com.atproto.repo.deleteRecord({ 94 - repo: agent.session!.did, 95 - collection: 'app.bsky.feed.threadgate', 96 - rkey: new AtUri(post.uri).rkey, 97 - }) 98 - } 99 - await whenAppViewReady(agent, post.uri, res => { 100 - const thread = res.data.thread 101 - if (AppBskyFeedDefs.isThreadViewPost(thread)) { 102 - const fetchedSettings = threadgateViewToSettings( 103 - thread.post.threadgate, 104 - ) 105 - return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) 106 - } 107 - return false 108 - }) 109 - Toast.show(_(msg`Thread settings updated`)) 110 - queryClient.invalidateQueries({ 111 - queryKey: [POST_THREAD_RQKEY_ROOT], 112 - }) 113 - } catch (err) { 114 - Toast.show( 115 - _( 116 - msg`There was an issue. Please check your internet connection and try again.`, 117 - ), 118 - 'xmark', 119 - ) 120 - logger.error('Failed to edit threadgate', {message: err}) 121 - } 122 - } 123 - 124 83 return ( 125 84 <> 126 85 <Button 127 86 label={ 128 87 isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) 129 88 } 130 - onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} 89 + onPress={onPressOpen} 90 + {...(isThreadAuthor 91 + ? Platform.select({ 92 + web: { 93 + onHoverIn: prefetchPostInteractionSettings, 94 + }, 95 + native: { 96 + onPressIn: prefetchPostInteractionSettings, 97 + }, 98 + }) 99 + : {})} 131 100 hitSlop={HITSLOP_10}> 132 101 {({hovered}) => ( 133 102 <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> ··· 145 114 ]}> 146 115 {description} 147 116 </Text> 117 + 148 118 {isThreadAuthor && ( 149 119 <PencilLine width={12} fill={t.palette.primary_500} /> 150 120 )} 151 121 </View> 152 122 )} 153 123 </Button> 154 - <WhoCanReplyDialog 155 - control={infoDialogControl} 156 - post={post} 157 - settings={settings} 158 - /> 159 - {isThreadAuthor && ( 160 - <ThreadgateEditorDialog 124 + 125 + {isThreadAuthor ? ( 126 + <PostInteractionSettingsDialog 127 + postUri={post.uri} 128 + rootPostUri={rootUri} 161 129 control={editDialogControl} 162 - threadgate={settings} 163 - onConfirm={onEditConfirm} 130 + initialThreadgateView={post.threadgate} 131 + /> 132 + ) : ( 133 + <WhoCanReplyDialog 134 + control={infoDialogControl} 135 + post={post} 136 + settings={settings} 137 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 164 138 /> 165 139 )} 166 140 </> ··· 174 148 }: { 175 149 color: string 176 150 width?: number 177 - settings: ThreadgateSetting[] 151 + settings: ThreadgateAllowUISetting[] 178 152 }) { 179 153 const isEverybody = settings.length === 0 180 154 const isNobody = !!settings.find(gate => gate.type === 'nobody') ··· 186 160 control, 187 161 post, 188 162 settings, 163 + embeddingDisabled, 189 164 }: { 190 165 control: Dialog.DialogControlProps 191 166 post: AppBskyFeedDefs.PostView 192 - settings: ThreadgateSetting[] 167 + settings: ThreadgateAllowUISetting[] 168 + embeddingDisabled: boolean 193 169 }) { 170 + const {_} = useLingui() 194 171 return ( 195 172 <Dialog.Outer control={control}> 196 173 <Dialog.Handle /> 197 - <WhoCanReplyDialogInner post={post} settings={settings} /> 174 + <Dialog.ScrollableInner 175 + label={_(msg`Dialog: adjust who can interact with this post`)} 176 + style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> 177 + <View style={[a.gap_sm]}> 178 + <Text style={[a.font_bold, a.text_xl, a.pb_sm]}> 179 + <Trans>Who can interact with this post?</Trans> 180 + </Text> 181 + <Rules 182 + post={post} 183 + settings={settings} 184 + embeddingDisabled={embeddingDisabled} 185 + /> 186 + </View> 187 + </Dialog.ScrollableInner> 198 188 </Dialog.Outer> 199 189 ) 200 190 } 201 191 202 - function WhoCanReplyDialogInner({ 203 - post, 204 - settings, 205 - }: { 206 - post: AppBskyFeedDefs.PostView 207 - settings: ThreadgateSetting[] 208 - }) { 209 - const {_} = useLingui() 210 - return ( 211 - <Dialog.ScrollableInner 212 - label={_(msg`Who can reply dialog`)} 213 - style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> 214 - <View style={[a.gap_sm]}> 215 - <Text style={[a.font_bold, a.text_xl]}> 216 - <Trans>Who can reply?</Trans> 217 - </Text> 218 - <Rules post={post} settings={settings} /> 219 - </View> 220 - </Dialog.ScrollableInner> 221 - ) 222 - } 223 - 224 192 function Rules({ 225 193 post, 226 194 settings, 195 + embeddingDisabled, 227 196 }: { 228 197 post: AppBskyFeedDefs.PostView 229 - settings: ThreadgateSetting[] 198 + settings: ThreadgateAllowUISetting[] 199 + embeddingDisabled: boolean 230 200 }) { 231 201 const t = useTheme() 202 + 232 203 return ( 233 - <Text 234 - style={[ 235 - a.text_md, 236 - a.leading_tight, 237 - a.flex_wrap, 238 - t.atoms.text_contrast_medium, 239 - ]}> 240 - {!settings.length ? ( 241 - <Trans>Everybody can reply</Trans> 242 - ) : settings[0].type === 'nobody' ? ( 243 - <Trans>Replies to this thread are disabled</Trans> 244 - ) : ( 245 - <Trans> 246 - Only{' '} 247 - {settings.map((rule, i) => ( 248 - <> 249 - <Rule 250 - key={`rule-${i}`} 251 - rule={rule} 252 - post={post} 253 - lists={post.threadgate!.lists} 254 - /> 255 - <Separator key={`sep-${i}`} i={i} length={settings.length} /> 256 - </> 257 - ))}{' '} 258 - can reply 259 - </Trans> 204 + <> 205 + <Text 206 + style={[ 207 + a.text_sm, 208 + a.leading_snug, 209 + a.flex_wrap, 210 + t.atoms.text_contrast_medium, 211 + ]}> 212 + {settings[0].type === 'everybody' ? ( 213 + <Trans>Everybody can reply to this post.</Trans> 214 + ) : settings[0].type === 'nobody' ? ( 215 + <Trans>Replies to this post are disabled.</Trans> 216 + ) : ( 217 + <Trans> 218 + Only{' '} 219 + {settings.map((rule, i) => ( 220 + <React.Fragment key={`rule-${i}`}> 221 + <Rule rule={rule} post={post} lists={post.threadgate!.lists} /> 222 + <Separator i={i} length={settings.length} /> 223 + </React.Fragment> 224 + ))}{' '} 225 + can reply. 226 + </Trans> 227 + )}{' '} 228 + </Text> 229 + {embeddingDisabled && ( 230 + <Text 231 + style={[ 232 + a.text_sm, 233 + a.leading_snug, 234 + a.flex_wrap, 235 + t.atoms.text_contrast_medium, 236 + ]}> 237 + <Trans>No one but the author can quote this post.</Trans> 238 + </Text> 260 239 )} 261 - </Text> 240 + </> 262 241 ) 263 242 } 264 243 ··· 267 246 post, 268 247 lists, 269 248 }: { 270 - rule: ThreadgateSetting 249 + rule: ThreadgateAllowUISetting 271 250 post: AppBskyFeedDefs.PostView 272 251 lists: AppBskyGraphDefs.ListViewBasic[] | undefined 273 252 }) { 274 - const t = useTheme() 275 253 if (rule.type === 'mention') { 276 254 return <Trans>mentioned users</Trans> 277 255 } ··· 279 257 return ( 280 258 <Trans> 281 259 users followed by{' '} 282 - <TextLink 283 - type="sm" 284 - href={makeProfileLink(post.author)} 285 - text={`@${post.author.handle}`} 286 - style={{color: t.palette.primary_500}} 287 - /> 260 + <InlineLinkText 261 + label={`@${post.author.handle}`} 262 + to={makeProfileLink(post.author)} 263 + style={[a.text_sm, a.leading_snug]}> 264 + @{post.author.handle} 265 + </InlineLinkText> 288 266 </Trans> 289 267 ) 290 268 } ··· 294 272 const listUrip = new AtUri(list.uri) 295 273 return ( 296 274 <Trans> 297 - <TextLink 298 - type="sm" 299 - href={makeListLink(listUrip.hostname, listUrip.rkey)} 300 - text={list.name} 301 - style={{color: t.palette.primary_500}} 302 - />{' '} 275 + <InlineLinkText 276 + label={list.name} 277 + to={makeListLink(listUrip.hostname, listUrip.rkey)} 278 + style={[a.text_sm, a.leading_snug]}> 279 + {list.name} 280 + </InlineLinkText>{' '} 303 281 members 304 282 </Trans> 305 283 ) ··· 320 298 } 321 299 return <>, </> 322 300 } 323 - 324 - async function whenAppViewReady( 325 - agent: BskyAgent, 326 - uri: string, 327 - fn: (res: AppBskyFeedGetPostThread.Response) => boolean, 328 - ) { 329 - await until( 330 - 5, // 5 tries 331 - 1e3, // 1s delay between tries 332 - fn, 333 - () => 334 - agent.app.bsky.feed.getPostThread({ 335 - uri, 336 - depth: 0, 337 - }), 338 - ) 339 - }
+538
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 1 + import React from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 + import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useQueryClient} from '@tanstack/react-query' 7 + import isEqual from 'lodash.isequal' 8 + 9 + import {logger} from '#/logger' 10 + import {STALE} from '#/state/queries' 11 + import {useMyListsQuery} from '#/state/queries/my-lists' 12 + import { 13 + createPostgateQueryKey, 14 + getPostgateRecord, 15 + usePostgateQuery, 16 + useWritePostgateMutation, 17 + } from '#/state/queries/postgate' 18 + import { 19 + createPostgateRecord, 20 + embeddingRules, 21 + } from '#/state/queries/postgate/util' 22 + import { 23 + createThreadgateViewQueryKey, 24 + getThreadgateView, 25 + ThreadgateAllowUISetting, 26 + threadgateViewToAllowUISetting, 27 + useSetThreadgateAllowMutation, 28 + useThreadgateViewQuery, 29 + } from '#/state/queries/threadgate' 30 + import {useAgent, useSession} from '#/state/session' 31 + import * as Toast from '#/view/com/util/Toast' 32 + import {atoms as a, useTheme} from '#/alf' 33 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 34 + import * as Dialog from '#/components/Dialog' 35 + import {Divider} from '#/components/Divider' 36 + import * as Toggle from '#/components/forms/Toggle' 37 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 38 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 39 + import {Loader} from '#/components/Loader' 40 + import {Text} from '#/components/Typography' 41 + 42 + export type PostInteractionSettingsFormProps = { 43 + onSave: () => void 44 + isSaving?: boolean 45 + 46 + postgate: AppBskyFeedPostgate.Record 47 + onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 48 + 49 + threadgateAllowUISettings: ThreadgateAllowUISetting[] 50 + onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void 51 + 52 + replySettingsDisabled?: boolean 53 + } 54 + 55 + export function PostInteractionSettingsControlledDialog({ 56 + control, 57 + ...rest 58 + }: PostInteractionSettingsFormProps & { 59 + control: Dialog.DialogControlProps 60 + }) { 61 + const {_} = useLingui() 62 + return ( 63 + <Dialog.Outer control={control}> 64 + <Dialog.Handle /> 65 + <Dialog.ScrollableInner 66 + label={_(msg`Edit post interaction settings`)} 67 + style={[{maxWidth: 500}, a.w_full]}> 68 + <PostInteractionSettingsForm {...rest} /> 69 + <Dialog.Close /> 70 + </Dialog.ScrollableInner> 71 + </Dialog.Outer> 72 + ) 73 + } 74 + 75 + export type PostInteractionSettingsDialogProps = { 76 + control: Dialog.DialogControlProps 77 + /** 78 + * URI of the post to edit the interaction settings for. Could be a root post 79 + * or could be a reply. 80 + */ 81 + postUri: string 82 + /** 83 + * The URI of the root post in the thread. Used to determine if the viewer 84 + * owns the threadgate record and can therefore edit it. 85 + */ 86 + rootPostUri: string 87 + /** 88 + * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we 89 + * happen to have one before opening the settings dialog. 90 + */ 91 + initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 92 + } 93 + 94 + export function PostInteractionSettingsDialog( 95 + props: PostInteractionSettingsDialogProps, 96 + ) { 97 + return ( 98 + <Dialog.Outer control={props.control}> 99 + <Dialog.Handle /> 100 + <PostInteractionSettingsDialogControlledInner {...props} /> 101 + </Dialog.Outer> 102 + ) 103 + } 104 + 105 + export function PostInteractionSettingsDialogControlledInner( 106 + props: PostInteractionSettingsDialogProps, 107 + ) { 108 + const {_} = useLingui() 109 + const {currentAccount} = useSession() 110 + const [isSaving, setIsSaving] = React.useState(false) 111 + 112 + const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 113 + useThreadgateViewQuery({postUri: props.rootPostUri}) 114 + const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({ 115 + postUri: props.postUri, 116 + }) 117 + 118 + const {mutateAsync: writePostgateRecord} = useWritePostgateMutation() 119 + const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 120 + 121 + const [editedPostgate, setEditedPostgate] = 122 + React.useState<AppBskyFeedPostgate.Record>() 123 + const [editedAllowUISettings, setEditedAllowUISettings] = 124 + React.useState<ThreadgateAllowUISetting[]>() 125 + 126 + const isLoading = isLoadingThreadgate || isLoadingPostgate 127 + const threadgateView = threadgateViewLoaded || props.initialThreadgateView 128 + const isThreadgateOwnedByViewer = React.useMemo(() => { 129 + return currentAccount?.did === new AtUri(props.rootPostUri).host 130 + }, [props.rootPostUri, currentAccount?.did]) 131 + 132 + const postgateValue = React.useMemo(() => { 133 + return ( 134 + editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 135 + ) 136 + }, [postgate, editedPostgate, props.postUri]) 137 + const allowUIValue = React.useMemo(() => { 138 + return ( 139 + editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 140 + ) 141 + }, [threadgateView, editedAllowUISettings]) 142 + 143 + const onSave = React.useCallback(async () => { 144 + if (!editedPostgate && !editedAllowUISettings) { 145 + props.control.close() 146 + return 147 + } 148 + 149 + setIsSaving(true) 150 + 151 + try { 152 + const requests = [] 153 + 154 + if (editedPostgate) { 155 + requests.push( 156 + writePostgateRecord({ 157 + postUri: props.postUri, 158 + postgate: editedPostgate, 159 + }), 160 + ) 161 + } 162 + 163 + if (editedAllowUISettings && isThreadgateOwnedByViewer) { 164 + requests.push( 165 + setThreadgateAllow({ 166 + postUri: props.rootPostUri, 167 + allow: editedAllowUISettings, 168 + }), 169 + ) 170 + } 171 + 172 + await Promise.all(requests) 173 + 174 + props.control.close() 175 + } catch (e: any) { 176 + logger.error(`Failed to save post interaction settings`, { 177 + context: 'PostInteractionSettingsDialogControlledInner', 178 + safeMessage: e.message, 179 + }) 180 + Toast.show( 181 + _( 182 + msg`There was an issue. Please check your internet connection and try again.`, 183 + ), 184 + 'xmark', 185 + ) 186 + } finally { 187 + setIsSaving(false) 188 + } 189 + }, [ 190 + _, 191 + props.postUri, 192 + props.rootPostUri, 193 + props.control, 194 + editedPostgate, 195 + editedAllowUISettings, 196 + setIsSaving, 197 + writePostgateRecord, 198 + setThreadgateAllow, 199 + isThreadgateOwnedByViewer, 200 + ]) 201 + 202 + return ( 203 + <Dialog.ScrollableInner 204 + label={_(msg`Edit post interaction settings`)} 205 + style={[{maxWidth: 500}, a.w_full]}> 206 + {isLoading ? ( 207 + <Loader size="xl" /> 208 + ) : ( 209 + <PostInteractionSettingsForm 210 + replySettingsDisabled={!isThreadgateOwnedByViewer} 211 + isSaving={isSaving} 212 + onSave={onSave} 213 + postgate={postgateValue} 214 + onChangePostgate={setEditedPostgate} 215 + threadgateAllowUISettings={allowUIValue} 216 + onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 217 + /> 218 + )} 219 + </Dialog.ScrollableInner> 220 + ) 221 + } 222 + 223 + export function PostInteractionSettingsForm({ 224 + onSave, 225 + isSaving, 226 + postgate, 227 + onChangePostgate, 228 + threadgateAllowUISettings, 229 + onChangeThreadgateAllowUISettings, 230 + replySettingsDisabled, 231 + }: PostInteractionSettingsFormProps) { 232 + const t = useTheme() 233 + const {_} = useLingui() 234 + const control = Dialog.useDialogContext() 235 + const {data: lists} = useMyListsQuery('curate') 236 + const [quotesEnabled, setQuotesEnabled] = React.useState( 237 + !( 238 + postgate.embeddingRules && 239 + postgate.embeddingRules.find( 240 + v => v.$type === embeddingRules.disableRule.$type, 241 + ) 242 + ), 243 + ) 244 + 245 + const onPressAudience = (setting: ThreadgateAllowUISetting) => { 246 + // remove boolean values 247 + let newSelected: ThreadgateAllowUISetting[] = 248 + threadgateAllowUISettings.filter( 249 + v => v.type !== 'nobody' && v.type !== 'everybody', 250 + ) 251 + // toggle 252 + const i = newSelected.findIndex(v => isEqual(v, setting)) 253 + if (i === -1) { 254 + newSelected.push(setting) 255 + } else { 256 + newSelected.splice(i, 1) 257 + } 258 + 259 + onChangeThreadgateAllowUISettings(newSelected) 260 + } 261 + 262 + const onChangeQuotesEnabled = React.useCallback( 263 + (enabled: boolean) => { 264 + setQuotesEnabled(enabled) 265 + onChangePostgate( 266 + createPostgateRecord({ 267 + ...postgate, 268 + embeddingRules: enabled ? [] : [embeddingRules.disableRule], 269 + }), 270 + ) 271 + }, 272 + [setQuotesEnabled, postgate, onChangePostgate], 273 + ) 274 + 275 + const noOneCanReply = !!threadgateAllowUISettings.find( 276 + v => v.type === 'nobody', 277 + ) 278 + 279 + return ( 280 + <View> 281 + <View style={[a.flex_1, a.gap_md]}> 282 + <Text style={[a.text_2xl, a.font_bold]}> 283 + <Trans>Post interaction settings</Trans> 284 + </Text> 285 + 286 + <View style={[a.gap_lg]}> 287 + <Text style={[a.text_md]}> 288 + <Trans>Customize who can interact with this post.</Trans> 289 + </Text> 290 + 291 + <Divider /> 292 + 293 + <View style={[a.gap_sm]}> 294 + <Text style={[a.font_bold, a.text_lg]}> 295 + <Trans>Quote settings</Trans> 296 + </Text> 297 + 298 + <Toggle.Item 299 + name="quoteposts" 300 + type="checkbox" 301 + label={ 302 + quotesEnabled 303 + ? _(msg`Click to disable quote posts of this post.`) 304 + : _(msg`Click to enable quote posts of this post.`) 305 + } 306 + value={quotesEnabled} 307 + onChange={onChangeQuotesEnabled} 308 + style={[, a.justify_between, a.pt_xs]}> 309 + <Text style={[t.atoms.text_contrast_medium]}> 310 + {quotesEnabled ? ( 311 + <Trans>Quote posts enabled</Trans> 312 + ) : ( 313 + <Trans>Quote posts disabled</Trans> 314 + )} 315 + </Text> 316 + <Toggle.Switch /> 317 + </Toggle.Item> 318 + </View> 319 + 320 + <Divider /> 321 + 322 + {replySettingsDisabled && ( 323 + <View 324 + style={[ 325 + a.px_md, 326 + a.py_sm, 327 + a.rounded_sm, 328 + a.flex_row, 329 + a.align_center, 330 + a.gap_sm, 331 + t.atoms.bg_contrast_25, 332 + ]}> 333 + <CircleInfo fill={t.atoms.text_contrast_low.color} /> 334 + <Text 335 + style={[ 336 + a.flex_1, 337 + a.leading_snug, 338 + t.atoms.text_contrast_medium, 339 + ]}> 340 + <Trans> 341 + Reply settings are chosen by the author of the thread 342 + </Trans> 343 + </Text> 344 + </View> 345 + )} 346 + 347 + <View 348 + style={[ 349 + a.gap_sm, 350 + { 351 + opacity: replySettingsDisabled ? 0.3 : 1, 352 + }, 353 + ]}> 354 + <Text style={[a.font_bold, a.text_lg]}> 355 + <Trans>Reply settings</Trans> 356 + </Text> 357 + 358 + <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 359 + <Trans>Allow replies from:</Trans> 360 + </Text> 361 + 362 + <View style={[a.flex_row, a.gap_sm]}> 363 + <Selectable 364 + label={_(msg`Everybody`)} 365 + isSelected={ 366 + !!threadgateAllowUISettings.find(v => v.type === 'everybody') 367 + } 368 + onPress={() => 369 + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 370 + } 371 + style={{flex: 1}} 372 + disabled={replySettingsDisabled} 373 + /> 374 + <Selectable 375 + label={_(msg`Nobody`)} 376 + isSelected={noOneCanReply} 377 + onPress={() => 378 + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 379 + } 380 + style={{flex: 1}} 381 + disabled={replySettingsDisabled} 382 + /> 383 + </View> 384 + 385 + {!noOneCanReply && ( 386 + <> 387 + <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 388 + <Trans>Or combine these options:</Trans> 389 + </Text> 390 + 391 + <View style={[a.gap_sm]}> 392 + <Selectable 393 + label={_(msg`Mentioned users`)} 394 + isSelected={ 395 + !!threadgateAllowUISettings.find( 396 + v => v.type === 'mention', 397 + ) 398 + } 399 + onPress={() => onPressAudience({type: 'mention'})} 400 + disabled={replySettingsDisabled} 401 + /> 402 + <Selectable 403 + label={_(msg`Followed users`)} 404 + isSelected={ 405 + !!threadgateAllowUISettings.find( 406 + v => v.type === 'following', 407 + ) 408 + } 409 + onPress={() => onPressAudience({type: 'following'})} 410 + disabled={replySettingsDisabled} 411 + /> 412 + {lists && lists.length > 0 413 + ? lists.map(list => ( 414 + <Selectable 415 + key={list.uri} 416 + label={_(msg`Users in "${list.name}"`)} 417 + isSelected={ 418 + !!threadgateAllowUISettings.find( 419 + v => v.type === 'list' && v.list === list.uri, 420 + ) 421 + } 422 + onPress={() => 423 + onPressAudience({type: 'list', list: list.uri}) 424 + } 425 + disabled={replySettingsDisabled} 426 + /> 427 + )) 428 + : // No loading states to avoid jumps for the common case (no lists) 429 + null} 430 + </View> 431 + </> 432 + )} 433 + </View> 434 + </View> 435 + </View> 436 + 437 + <Button 438 + label={_(msg`Save`)} 439 + onPress={onSave} 440 + onAccessibilityEscape={control.close} 441 + color="primary" 442 + size="medium" 443 + variant="solid" 444 + style={a.mt_xl}> 445 + <ButtonText>{_(msg`Save`)}</ButtonText> 446 + {isSaving && <ButtonIcon icon={Loader} position="right" />} 447 + </Button> 448 + </View> 449 + ) 450 + } 451 + 452 + function Selectable({ 453 + label, 454 + isSelected, 455 + onPress, 456 + style, 457 + disabled, 458 + }: { 459 + label: string 460 + isSelected: boolean 461 + onPress: () => void 462 + style?: StyleProp<ViewStyle> 463 + disabled?: boolean 464 + }) { 465 + const t = useTheme() 466 + return ( 467 + <Button 468 + disabled={disabled} 469 + onPress={onPress} 470 + label={label} 471 + accessibilityRole="checkbox" 472 + aria-checked={isSelected} 473 + accessibilityState={{ 474 + checked: isSelected, 475 + }} 476 + style={a.flex_1}> 477 + {({hovered, focused}) => ( 478 + <View 479 + style={[ 480 + a.flex_1, 481 + a.flex_row, 482 + a.align_center, 483 + a.justify_between, 484 + a.rounded_sm, 485 + a.p_md, 486 + {height: 40}, // for consistency with checkmark icon visible or not 487 + t.atoms.bg_contrast_50, 488 + (hovered || focused) && t.atoms.bg_contrast_100, 489 + isSelected && { 490 + backgroundColor: t.palette.primary_100, 491 + }, 492 + style, 493 + ]}> 494 + <Text style={[a.text_sm, isSelected && a.font_semibold]}> 495 + {label} 496 + </Text> 497 + {isSelected ? ( 498 + <Check size="sm" fill={t.palette.primary_500} /> 499 + ) : ( 500 + <View /> 501 + )} 502 + </View> 503 + )} 504 + </Button> 505 + ) 506 + } 507 + 508 + export function usePrefetchPostInteractionSettings({ 509 + postUri, 510 + rootPostUri, 511 + }: { 512 + postUri: string 513 + rootPostUri: string 514 + }) { 515 + const queryClient = useQueryClient() 516 + const agent = useAgent() 517 + 518 + return React.useCallback(async () => { 519 + try { 520 + await Promise.all([ 521 + queryClient.prefetchQuery({ 522 + queryKey: createPostgateQueryKey(postUri), 523 + queryFn: () => getPostgateRecord({agent, postUri}), 524 + staleTime: STALE.SECONDS.THIRTY, 525 + }), 526 + queryClient.prefetchQuery({ 527 + queryKey: createThreadgateViewQueryKey(rootPostUri), 528 + queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 529 + staleTime: STALE.SECONDS.THIRTY, 530 + }), 531 + ]) 532 + } catch (e: any) { 533 + logger.error(`Failed to prefetch post interaction settings`, { 534 + safeMessage: e.message, 535 + }) 536 + } 537 + }, [queryClient, agent, postUri, rootPostUri]) 538 + }
-217
src/components/dialogs/ThreadgateEditor.tsx
··· 1 - import React from 'react' 2 - import {StyleProp, View, ViewStyle} from 'react-native' 3 - import {msg, Trans} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - import isEqual from 'lodash.isequal' 6 - 7 - import {useMyListsQuery} from '#/state/queries/my-lists' 8 - import {ThreadgateSetting} from '#/state/queries/threadgate' 9 - import {atoms as a, useTheme} from '#/alf' 10 - import {Button, ButtonText} from '#/components/Button' 11 - import * as Dialog from '#/components/Dialog' 12 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 13 - import {Text} from '#/components/Typography' 14 - 15 - interface ThreadgateEditorDialogProps { 16 - control: Dialog.DialogControlProps 17 - threadgate: ThreadgateSetting[] 18 - onChange?: (v: ThreadgateSetting[]) => void 19 - onConfirm?: (v: ThreadgateSetting[]) => void 20 - } 21 - 22 - export function ThreadgateEditorDialog({ 23 - control, 24 - threadgate, 25 - onChange, 26 - onConfirm, 27 - }: ThreadgateEditorDialogProps) { 28 - return ( 29 - <Dialog.Outer control={control}> 30 - <Dialog.Handle /> 31 - <DialogContent 32 - seedThreadgate={threadgate} 33 - onChange={onChange} 34 - onConfirm={onConfirm} 35 - /> 36 - </Dialog.Outer> 37 - ) 38 - } 39 - 40 - function DialogContent({ 41 - seedThreadgate, 42 - onChange, 43 - onConfirm, 44 - }: { 45 - seedThreadgate: ThreadgateSetting[] 46 - onChange?: (v: ThreadgateSetting[]) => void 47 - onConfirm?: (v: ThreadgateSetting[]) => void 48 - }) { 49 - const {_} = useLingui() 50 - const control = Dialog.useDialogContext() 51 - const {data: lists} = useMyListsQuery('curate') 52 - const [draft, setDraft] = React.useState(seedThreadgate) 53 - 54 - const [prevSeedThreadgate, setPrevSeedThreadgate] = 55 - React.useState(seedThreadgate) 56 - if (seedThreadgate !== prevSeedThreadgate) { 57 - // New data flowed from above (e.g. due to update coming through). 58 - setPrevSeedThreadgate(seedThreadgate) 59 - setDraft(seedThreadgate) // Reset draft. 60 - } 61 - 62 - function updateThreadgate(nextThreadgate: ThreadgateSetting[]) { 63 - setDraft(nextThreadgate) 64 - onChange?.(nextThreadgate) 65 - } 66 - 67 - const onPressEverybody = () => { 68 - updateThreadgate([]) 69 - } 70 - 71 - const onPressNobody = () => { 72 - updateThreadgate([{type: 'nobody'}]) 73 - } 74 - 75 - const onPressAudience = (setting: ThreadgateSetting) => { 76 - // remove nobody 77 - let newSelected: ThreadgateSetting[] = draft.filter( 78 - v => v.type !== 'nobody', 79 - ) 80 - // toggle 81 - const i = newSelected.findIndex(v => isEqual(v, setting)) 82 - if (i === -1) { 83 - newSelected.push(setting) 84 - } else { 85 - newSelected.splice(i, 1) 86 - } 87 - updateThreadgate(newSelected) 88 - } 89 - 90 - const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`) 91 - return ( 92 - <Dialog.ScrollableInner 93 - label={_(msg`Choose who can reply`)} 94 - style={[{maxWidth: 500}, a.w_full]}> 95 - <View style={[a.flex_1, a.gap_md]}> 96 - <Text style={[a.text_2xl, a.font_bold]}> 97 - <Trans>Choose who can reply</Trans> 98 - </Text> 99 - <Text style={a.mt_xs}> 100 - <Trans>Either choose "Everybody" or "Nobody"</Trans> 101 - </Text> 102 - <View style={[a.flex_row, a.gap_sm]}> 103 - <Selectable 104 - label={_(msg`Everybody`)} 105 - isSelected={draft.length === 0} 106 - onPress={onPressEverybody} 107 - style={{flex: 1}} 108 - /> 109 - <Selectable 110 - label={_(msg`Nobody`)} 111 - isSelected={!!draft.find(v => v.type === 'nobody')} 112 - onPress={onPressNobody} 113 - style={{flex: 1}} 114 - /> 115 - </View> 116 - <Text style={a.mt_md}> 117 - <Trans>Or combine these options:</Trans> 118 - </Text> 119 - <View style={[a.gap_sm]}> 120 - <Selectable 121 - label={_(msg`Mentioned users`)} 122 - isSelected={!!draft.find(v => v.type === 'mention')} 123 - onPress={() => onPressAudience({type: 'mention'})} 124 - /> 125 - <Selectable 126 - label={_(msg`Followed users`)} 127 - isSelected={!!draft.find(v => v.type === 'following')} 128 - onPress={() => onPressAudience({type: 'following'})} 129 - /> 130 - {lists && lists.length > 0 131 - ? lists.map(list => ( 132 - <Selectable 133 - key={list.uri} 134 - label={_(msg`Users in "${list.name}"`)} 135 - isSelected={ 136 - !!draft.find(v => v.type === 'list' && v.list === list.uri) 137 - } 138 - onPress={() => 139 - onPressAudience({type: 'list', list: list.uri}) 140 - } 141 - /> 142 - )) 143 - : // No loading states to avoid jumps for the common case (no lists) 144 - null} 145 - </View> 146 - </View> 147 - <Button 148 - label={doneLabel} 149 - onPress={() => { 150 - control.close() 151 - onConfirm?.(draft) 152 - }} 153 - onAccessibilityEscape={control.close} 154 - color="primary" 155 - size="medium" 156 - variant="solid" 157 - style={a.mt_xl}> 158 - <ButtonText>{doneLabel}</ButtonText> 159 - </Button> 160 - <Dialog.Close /> 161 - </Dialog.ScrollableInner> 162 - ) 163 - } 164 - 165 - function Selectable({ 166 - label, 167 - isSelected, 168 - onPress, 169 - style, 170 - }: { 171 - label: string 172 - isSelected: boolean 173 - onPress: () => void 174 - style?: StyleProp<ViewStyle> 175 - }) { 176 - const t = useTheme() 177 - return ( 178 - <Button 179 - onPress={onPress} 180 - label={label} 181 - accessibilityHint="Select this option" 182 - accessibilityRole="checkbox" 183 - aria-checked={isSelected} 184 - accessibilityState={{ 185 - checked: isSelected, 186 - }} 187 - style={a.flex_1}> 188 - {({hovered, focused}) => ( 189 - <View 190 - style={[ 191 - a.flex_1, 192 - a.flex_row, 193 - a.align_center, 194 - a.justify_between, 195 - a.rounded_sm, 196 - a.p_md, 197 - {height: 40}, // for consistency with checkmark icon visible or not 198 - t.atoms.bg_contrast_50, 199 - (hovered || focused) && t.atoms.bg_contrast_100, 200 - isSelected && { 201 - backgroundColor: t.palette.primary_100, 202 - }, 203 - style, 204 - ]}> 205 - <Text style={[a.text_sm, isSelected && a.font_semibold]}> 206 - {label} 207 - </Text> 208 - {isSelected ? ( 209 - <Check size="sm" fill={t.palette.primary_500} /> 210 - ) : ( 211 - <View /> 212 - )} 213 - </View> 214 - )} 215 - </Button> 216 - ) 217 - }
+5
src/components/icons/Eye.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+15 -4
src/components/moderation/ModerationDetailsDialog.tsx
··· 8 8 import {makeProfileLink} from '#/lib/routes/links' 9 9 import {listUriToHref} from '#/lib/strings/url-helpers' 10 10 import {isNative} from '#/platform/detection' 11 + import {useSession} from '#/state/session' 11 12 import {atoms as a, useTheme} from '#/alf' 12 13 import * as Dialog from '#/components/Dialog' 13 14 import {Divider} from '#/components/Divider' 14 15 import {InlineLinkText} from '#/components/Link' 16 + import {AppModerationCause} from '#/components/Pills' 15 17 import {Text} from '#/components/Typography' 16 18 17 19 export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' 18 20 19 21 export interface ModerationDetailsDialogProps { 20 22 control: Dialog.DialogOuterProps['control'] 21 - modcause?: ModerationCause 23 + modcause?: ModerationCause | AppModerationCause 22 24 } 23 25 24 26 export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { ··· 39 41 const t = useTheme() 40 42 const {_} = useLingui() 41 43 const desc = useModerationCauseDescription(modcause) 44 + const {currentAccount} = useSession() 42 45 43 46 let name 44 47 let description ··· 105 108 } else if (modcause.type === 'hidden') { 106 109 name = _(msg`Post Hidden by You`) 107 110 description = _(msg`You have hidden this post.`) 111 + } else if (modcause.type === 'reply-hidden') { 112 + const isYou = currentAccount?.did === modcause.source.did 113 + name = isYou 114 + ? _(msg`Reply Hidden by You`) 115 + : _(msg`Reply Hidden by Thread Author`) 116 + description = isYou 117 + ? _(msg`You hid this reply.`) 118 + : _(msg`The author of this thread has hidden this reply.`) 108 119 } else if (modcause.type === 'label') { 109 120 name = desc.name 110 121 description = desc.description ··· 119 130 <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> 120 131 {name} 121 132 </Text> 122 - <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> 133 + <Text style={[t.atoms.text, a.text_md, a.leading_snug]}> 123 134 {description} 124 135 </Text> 125 136 126 137 {modcause?.type === 'label' && ( 127 - <> 138 + <View style={[a.pt_lg]}> 128 139 <Divider /> 129 140 <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> 130 141 {modcause.source.type === 'user' ? ( ··· 143 154 </Trans> 144 155 )} 145 156 </Text> 146 - </> 157 + </View> 147 158 )} 148 159 149 160 {isNative && <View style={{height: 40}} />}
+12 -2
src/components/moderation/PostAlerts.tsx
··· 1 1 import React from 'react' 2 2 import {StyleProp, ViewStyle} from 'react-native' 3 - import {ModerationUI} from '@atproto/api' 3 + import {ModerationCause, ModerationUI} from '@atproto/api' 4 4 5 5 import {getModerationCauseKey} from '#/lib/moderation' 6 6 import * as Pills from '#/components/Pills' ··· 9 9 modui, 10 10 size = 'sm', 11 11 style, 12 + additionalCauses, 12 13 }: { 13 14 modui: ModerationUI 14 15 size?: Pills.CommonProps['size'] 15 16 includeMute?: boolean 16 17 style?: StyleProp<ViewStyle> 18 + additionalCauses?: ModerationCause[] | Pills.AppModerationCause[] 17 19 }) { 18 - if (!modui.alert && !modui.inform) { 20 + if (!modui.alert && !modui.inform && !additionalCauses?.length) { 19 21 return null 20 22 } 21 23 ··· 30 32 /> 31 33 ))} 32 34 {modui.informs.map(cause => ( 35 + <Pills.Label 36 + key={getModerationCauseKey(cause)} 37 + cause={cause} 38 + size={size} 39 + noBg={size === 'sm'} 40 + /> 41 + ))} 42 + {additionalCauses?.map(cause => ( 33 43 <Pills.Label 34 44 key={getModerationCauseKey(cause)} 35 45 cause={cause}
+55 -50
src/lib/api/index.ts
··· 3 3 AppBskyEmbedImages, 4 4 AppBskyEmbedRecord, 5 5 AppBskyEmbedRecordWithMedia, 6 - AppBskyFeedThreadgate, 6 + AppBskyFeedPostgate, 7 7 BskyAgent, 8 8 ComAtprotoLabelDefs, 9 9 RichText, ··· 11 11 import {AtUri} from '@atproto/api' 12 12 13 13 import {logger} from '#/logger' 14 - import {ThreadgateSetting} from '#/state/queries/threadgate' 14 + import {writePostgateRecord} from '#/state/queries/postgate' 15 + import { 16 + createThreadgateRecord, 17 + ThreadgateAllowUISetting, 18 + threadgateAllowUISettingToAllowRecordValue, 19 + writeThreadgateRecord, 20 + } from '#/state/queries/threadgate' 15 21 import {isNetworkError} from 'lib/strings/errors' 16 22 import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' 17 23 import {isNative} from 'platform/detection' ··· 44 50 extLink?: ExternalEmbedDraft 45 51 images?: ImageModel[] 46 52 labels?: string[] 47 - threadgate?: ThreadgateSetting[] 53 + threadgate: ThreadgateAllowUISetting[] 54 + postgate: AppBskyFeedPostgate.Record 48 55 onStateChange?: (state: string) => void 49 56 langs?: string[] 50 57 } ··· 232 239 labels, 233 240 }) 234 241 } catch (e: any) { 235 - console.error(`Failed to create post: ${e.toString()}`) 242 + logger.error(`Failed to create post`, { 243 + safeMessage: e.message, 244 + }) 236 245 if (isNetworkError(e)) { 237 246 throw new Error( 238 247 'Post failed to upload. Please check your Internet connection and try again.', ··· 242 251 } 243 252 } 244 253 245 - try { 246 - // TODO: this needs to be batch-created with the post! 247 - if (opts.threadgate?.length) { 248 - await createThreadgate(agent, res.uri, opts.threadgate) 254 + if (opts.threadgate.some(tg => tg.type !== 'everybody')) { 255 + try { 256 + // TODO: this needs to be batch-created with the post! 257 + await writeThreadgateRecord({ 258 + agent, 259 + postUri: res.uri, 260 + threadgate: createThreadgateRecord({ 261 + post: res.uri, 262 + allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate), 263 + }), 264 + }) 265 + } catch (e: any) { 266 + logger.error(`Failed to create threadgate`, { 267 + context: 'composer', 268 + safeMessage: e.message, 269 + }) 270 + throw new Error( 271 + 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.', 272 + ) 249 273 } 250 - } catch (e: any) { 251 - console.error(`Failed to create threadgate: ${e.toString()}`) 252 - throw new Error( 253 - 'Post reply-controls failed to be set. Your post was created but anyone can reply to it.', 254 - ) 255 274 } 256 275 257 - return res 258 - } 259 - 260 - export async function createThreadgate( 261 - agent: BskyAgent, 262 - postUri: string, 263 - threadgate: ThreadgateSetting[], 264 - ) { 265 - let allow: ( 266 - | AppBskyFeedThreadgate.MentionRule 267 - | AppBskyFeedThreadgate.FollowingRule 268 - | AppBskyFeedThreadgate.ListRule 269 - )[] = [] 270 - if (!threadgate.find(v => v.type === 'nobody')) { 271 - for (const rule of threadgate) { 272 - if (rule.type === 'mention') { 273 - allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) 274 - } else if (rule.type === 'following') { 275 - allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) 276 - } else if (rule.type === 'list') { 277 - allow.push({ 278 - $type: 'app.bsky.feed.threadgate#listRule', 279 - list: rule.list, 280 - }) 281 - } 276 + if ( 277 + opts.postgate.embeddingRules?.length || 278 + opts.postgate.detachedEmbeddingUris?.length 279 + ) { 280 + try { 281 + // TODO: this needs to be batch-created with the post! 282 + await writePostgateRecord({ 283 + agent, 284 + postUri: res.uri, 285 + postgate: { 286 + ...opts.postgate, 287 + post: res.uri, 288 + }, 289 + }) 290 + } catch (e: any) { 291 + logger.error(`Failed to create postgate`, { 292 + context: 'composer', 293 + safeMessage: e.message, 294 + }) 295 + throw new Error( 296 + 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.', 297 + ) 282 298 } 283 299 } 284 300 285 - const postUrip = new AtUri(postUri) 286 - await agent.api.com.atproto.repo.putRecord({ 287 - repo: agent.accountDid, 288 - collection: 'app.bsky.feed.threadgate', 289 - rkey: postUrip.rkey, 290 - record: { 291 - $type: 'app.bsky.feed.threadgate', 292 - post: postUri, 293 - allow, 294 - createdAt: new Date().toISOString(), 295 - }, 296 - }) 301 + return res 297 302 }
+8 -5
src/lib/moderation.ts
··· 1 1 import { 2 - ModerationCause, 3 - ModerationUI, 2 + AppBskyLabelerDefs, 3 + BskyAgent, 4 4 InterpretedLabelValueDefinition, 5 5 LABELS, 6 - AppBskyLabelerDefs, 7 - BskyAgent, 6 + ModerationCause, 8 7 ModerationOpts, 8 + ModerationUI, 9 9 } from '@atproto/api' 10 10 11 11 import {sanitizeDisplayName} from '#/lib/strings/display-names' 12 12 import {sanitizeHandle} from '#/lib/strings/handles' 13 + import {AppModerationCause} from '#/components/Pills' 13 14 14 - export function getModerationCauseKey(cause: ModerationCause): string { 15 + export function getModerationCauseKey( 16 + cause: ModerationCause | AppModerationCause, 17 + ): string { 15 18 const source = 16 19 cause.source.type === 'labeler' 17 20 ? cause.source.did
+25 -2
src/lib/moderation/useModerationCauseDescription.ts
··· 8 8 import {useLingui} from '@lingui/react' 9 9 10 10 import {useLabelDefinitions} from '#/state/preferences' 11 + import {useSession} from '#/state/session' 11 12 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 12 13 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 13 14 import {Props as SVGIconProps} from '#/components/icons/common' 14 15 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 15 16 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 17 + import {AppModerationCause} from '#/components/Pills' 16 18 import {useGlobalLabelStrings} from './useGlobalLabelStrings' 17 19 import {getDefinition, getLabelStrings} from './useLabelInfo' 18 20 ··· 27 29 } 28 30 29 31 export function useModerationCauseDescription( 30 - cause: ModerationCause | undefined, 32 + cause: ModerationCause | AppModerationCause | undefined, 31 33 ): ModerationCauseDescription { 34 + const {currentAccount} = useSession() 32 35 const {_, i18n} = useLingui() 33 36 const {labelDefs, labelers} = useLabelDefinitions() 34 37 const globalLabelStrings = useGlobalLabelStrings() ··· 111 114 description: _(msg`You have hidden this post`), 112 115 } 113 116 } 117 + if (cause.type === 'reply-hidden') { 118 + const isMe = currentAccount?.did === cause.source.did 119 + return { 120 + icon: EyeSlash, 121 + name: isMe 122 + ? _(msg`Reply Hidden by You`) 123 + : _(msg`Reply Hidden by Thread Author`), 124 + description: isMe 125 + ? _(msg`You hid this reply.`) 126 + : _(msg`The author of this thread has hidden this reply.`), 127 + } 128 + } 114 129 if (cause.type === 'label') { 115 130 const def = cause.labelDef || getDefinition(labelDefs, cause.label) 116 131 const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) ··· 150 165 name: '', 151 166 description: ``, 152 167 } 153 - }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) 168 + }, [ 169 + labelDefs, 170 + labelers, 171 + globalLabelStrings, 172 + cause, 173 + _, 174 + i18n.locale, 175 + currentAccount?.did, 176 + ]) 154 177 }
+19 -1
src/state/cache/post-shadow.ts
··· 1 1 import {useEffect, useMemo, useState} from 'react' 2 - import {AppBskyFeedDefs} from '@atproto/api' 2 + import { 3 + AppBskyEmbedRecord, 4 + AppBskyEmbedRecordWithMedia, 5 + AppBskyFeedDefs, 6 + } from '@atproto/api' 3 7 import {QueryClient} from '@tanstack/react-query' 4 8 import EventEmitter from 'eventemitter3' 5 9 ··· 16 20 likeUri: string | undefined 17 21 repostUri: string | undefined 18 22 isDeleted: boolean 23 + embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 19 24 } 20 25 21 26 export const POST_TOMBSTONE = Symbol('PostTombstone') ··· 87 92 repostCount = Math.max(0, repostCount) 88 93 } 89 94 95 + let embed: typeof post.embed 96 + if ('embed' in shadow) { 97 + if ( 98 + (AppBskyEmbedRecord.isView(post.embed) && 99 + AppBskyEmbedRecord.isView(shadow.embed)) || 100 + (AppBskyEmbedRecordWithMedia.isView(post.embed) && 101 + AppBskyEmbedRecordWithMedia.isView(shadow.embed)) 102 + ) { 103 + embed = shadow.embed 104 + } 105 + } 106 + 90 107 return castAsShadow({ 91 108 ...post, 109 + embed: embed || post.embed, 92 110 likeCount: likeCount, 93 111 repostCount: repostCount, 94 112 viewer: {
+42 -13
src/state/queries/notifications/feed.ts
··· 16 16 * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. 17 17 */ 18 18 19 - import {useEffect, useRef} from 'react' 19 + import {useCallback, useEffect, useMemo, useRef} from 'react' 20 20 import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' 21 21 import { 22 22 InfiniteData, ··· 27 27 } from '@tanstack/react-query' 28 28 29 29 import {useAgent} from '#/state/session' 30 + import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' 30 31 import {useModerationOpts} from '../../preferences/moderation-opts' 31 32 import {STALE} from '..' 32 33 import { ··· 58 59 const moderationOpts = useModerationOpts() 59 60 const unreads = useUnreadNotificationsApi() 60 61 const enabled = opts?.enabled !== false 62 + const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris() 61 63 62 64 // false: force showing all notifications 63 65 // undefined: let the server decide 64 66 const priority = opts?.overridePriorityNotifications ? false : undefined 65 67 68 + const selectArgs = useMemo(() => { 69 + return { 70 + hiddenReplyUris, 71 + } 72 + }, [hiddenReplyUris]) 73 + 66 74 const query = useInfiniteQuery< 67 75 FeedPage, 68 76 Error, ··· 101 109 initialPageParam: undefined, 102 110 getNextPageParam: lastPage => lastPage.cursor, 103 111 enabled, 104 - select(data: InfiniteData<FeedPage>) { 105 - // override 'isRead' using the first page's returned seenAt 106 - // we do this because the `markAllRead()` call above will 107 - // mark subsequent pages as read prematurely 108 - const seenAt = data.pages[0]?.seenAt || new Date() 109 - for (const page of data.pages) { 110 - for (const item of page.items) { 111 - item.notification.isRead = 112 - seenAt > new Date(item.notification.indexedAt) 112 + select: useCallback( 113 + (data: InfiniteData<FeedPage>) => { 114 + const {hiddenReplyUris} = selectArgs 115 + 116 + // override 'isRead' using the first page's returned seenAt 117 + // we do this because the `markAllRead()` call above will 118 + // mark subsequent pages as read prematurely 119 + const seenAt = data.pages[0]?.seenAt || new Date() 120 + for (const page of data.pages) { 121 + for (const item of page.items) { 122 + item.notification.isRead = 123 + seenAt > new Date(item.notification.indexedAt) 124 + } 125 + } 126 + 127 + data = { 128 + ...data, 129 + pages: data.pages.map(page => { 130 + return { 131 + ...page, 132 + items: page.items.filter(item => { 133 + const isHiddenReply = 134 + item.type === 'reply' && 135 + item.subjectUri && 136 + hiddenReplyUris.has(item.subjectUri) 137 + return !isHiddenReply 138 + }), 139 + } 140 + }), 113 141 } 114 - } 115 142 116 - return data 117 - }, 143 + return data 144 + }, 145 + [selectArgs], 146 + ), 118 147 }) 119 148 120 149 // The server may end up returning an empty page, a page with too few items,
+17 -1
src/state/queries/post-thread.ts
··· 138 138 modCache: ThreadModerationCache, 139 139 currentDid: string | undefined, 140 140 justPostedUris: Set<string>, 141 + threadgateRecordHiddenReplies: Set<string>, 141 142 ): ThreadNode { 142 143 if (node.type !== 'post') { 143 144 return node ··· 183 184 return -1 // current account's reply 184 185 } else if (bIsBySelf) { 185 186 return 1 // current account's reply 187 + } 188 + 189 + const aHidden = threadgateRecordHiddenReplies.has(a.uri) 190 + const bHidden = threadgateRecordHiddenReplies.has(b.uri) 191 + if (aHidden && !aIsBySelf && !bHidden) { 192 + return 1 193 + } else if (bHidden && !bIsBySelf && !aHidden) { 194 + return -1 186 195 } 187 196 188 197 const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) ··· 222 231 return b.post.indexedAt.localeCompare(a.post.indexedAt) 223 232 }) 224 233 node.replies.forEach(reply => 225 - sortThread(reply, opts, modCache, currentDid, justPostedUris), 234 + sortThread( 235 + reply, 236 + opts, 237 + modCache, 238 + currentDid, 239 + justPostedUris, 240 + threadgateRecordHiddenReplies, 241 + ), 226 242 ) 227 243 } 228 244 return node
+24
src/state/queries/post.ts
··· 73 73 ) 74 74 } 75 75 76 + export function useGetPosts() { 77 + const queryClient = useQueryClient() 78 + const agent = useAgent() 79 + return useCallback( 80 + async ({uris}: {uris: string[]}) => { 81 + return queryClient.fetchQuery({ 82 + queryKey: RQKEY(uris.join(',') || ''), 83 + async queryFn() { 84 + const res = await agent.getPosts({ 85 + uris, 86 + }) 87 + 88 + if (res.success) { 89 + return res.data.posts 90 + } else { 91 + throw new Error('useGetPosts failed') 92 + } 93 + }, 94 + }) 95 + }, 96 + [queryClient, agent], 97 + ) 98 + } 99 + 76 100 export function usePostLikeMutationQueue( 77 101 post: Shadow<AppBskyFeedDefs.PostView>, 78 102 logContext: LogEvents['post:like']['logContext'] &
+295
src/state/queries/postgate/index.ts
··· 1 + import React from 'react' 2 + import { 3 + AppBskyEmbedRecord, 4 + AppBskyEmbedRecordWithMedia, 5 + AppBskyFeedDefs, 6 + AppBskyFeedPostgate, 7 + AtUri, 8 + BskyAgent, 9 + } from '@atproto/api' 10 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 11 + 12 + import {networkRetry, retry} from '#/lib/async/retry' 13 + import {logger} from '#/logger' 14 + import {updatePostShadow} from '#/state/cache/post-shadow' 15 + import {STALE} from '#/state/queries' 16 + import {useGetPosts} from '#/state/queries/post' 17 + import { 18 + createMaybeDetachedQuoteEmbed, 19 + createPostgateRecord, 20 + mergePostgateRecords, 21 + POSTGATE_COLLECTION, 22 + } from '#/state/queries/postgate/util' 23 + import {useAgent} from '#/state/session' 24 + 25 + export async function getPostgateRecord({ 26 + agent, 27 + postUri, 28 + }: { 29 + agent: BskyAgent 30 + postUri: string 31 + }): Promise<AppBskyFeedPostgate.Record | undefined> { 32 + const urip = new AtUri(postUri) 33 + 34 + if (!urip.host.startsWith('did:')) { 35 + const res = await agent.resolveHandle({ 36 + handle: urip.host, 37 + }) 38 + urip.host = res.data.did 39 + } 40 + 41 + try { 42 + const {data} = await retry( 43 + 2, 44 + e => { 45 + /* 46 + * If the record doesn't exist, we want to return null instead of 47 + * throwing an error. NB: This will also catch reference errors, such as 48 + * a typo in the URI. 49 + */ 50 + if (e.message.includes(`Could not locate record:`)) { 51 + return false 52 + } 53 + return true 54 + }, 55 + () => 56 + agent.api.com.atproto.repo.getRecord({ 57 + repo: urip.host, 58 + collection: POSTGATE_COLLECTION, 59 + rkey: urip.rkey, 60 + }), 61 + ) 62 + 63 + if (data.value && AppBskyFeedPostgate.isRecord(data.value)) { 64 + return data.value 65 + } else { 66 + return undefined 67 + } 68 + } catch (e: any) { 69 + /* 70 + * If the record doesn't exist, we want to return null instead of 71 + * throwing an error. NB: This will also catch reference errors, such as 72 + * a typo in the URI. 73 + */ 74 + if (e.message.includes(`Could not locate record:`)) { 75 + return undefined 76 + } else { 77 + throw e 78 + } 79 + } 80 + } 81 + 82 + export async function writePostgateRecord({ 83 + agent, 84 + postUri, 85 + postgate, 86 + }: { 87 + agent: BskyAgent 88 + postUri: string 89 + postgate: AppBskyFeedPostgate.Record 90 + }) { 91 + const postUrip = new AtUri(postUri) 92 + 93 + await networkRetry(2, () => 94 + agent.api.com.atproto.repo.putRecord({ 95 + repo: agent.session!.did, 96 + collection: POSTGATE_COLLECTION, 97 + rkey: postUrip.rkey, 98 + record: postgate, 99 + }), 100 + ) 101 + } 102 + 103 + export async function upsertPostgate( 104 + { 105 + agent, 106 + postUri, 107 + }: { 108 + agent: BskyAgent 109 + postUri: string 110 + }, 111 + callback: ( 112 + postgate: AppBskyFeedPostgate.Record | undefined, 113 + ) => Promise<AppBskyFeedPostgate.Record | undefined>, 114 + ) { 115 + const prev = await getPostgateRecord({ 116 + agent, 117 + postUri, 118 + }) 119 + const next = await callback(prev) 120 + if (!next) return 121 + await writePostgateRecord({ 122 + agent, 123 + postUri, 124 + postgate: next, 125 + }) 126 + } 127 + 128 + export const createPostgateQueryKey = (postUri: string) => [ 129 + 'postgate-record', 130 + postUri, 131 + ] 132 + export function usePostgateQuery({postUri}: {postUri: string}) { 133 + const agent = useAgent() 134 + return useQuery({ 135 + staleTime: STALE.SECONDS.THIRTY, 136 + queryKey: createPostgateQueryKey(postUri), 137 + async queryFn() { 138 + return (await getPostgateRecord({agent, postUri})) ?? null 139 + }, 140 + }) 141 + } 142 + 143 + export function useWritePostgateMutation() { 144 + const agent = useAgent() 145 + const queryClient = useQueryClient() 146 + return useMutation({ 147 + mutationFn: async ({ 148 + postUri, 149 + postgate, 150 + }: { 151 + postUri: string 152 + postgate: AppBskyFeedPostgate.Record 153 + }) => { 154 + return writePostgateRecord({ 155 + agent, 156 + postUri, 157 + postgate, 158 + }) 159 + }, 160 + onSuccess(_, {postUri}) { 161 + queryClient.invalidateQueries({ 162 + queryKey: createPostgateQueryKey(postUri), 163 + }) 164 + }, 165 + }) 166 + } 167 + 168 + export function useToggleQuoteDetachmentMutation() { 169 + const agent = useAgent() 170 + const queryClient = useQueryClient() 171 + const getPosts = useGetPosts() 172 + const prevEmbed = React.useRef<AppBskyFeedDefs.PostView['embed']>() 173 + 174 + return useMutation({ 175 + mutationFn: async ({ 176 + post, 177 + quoteUri, 178 + action, 179 + }: { 180 + post: AppBskyFeedDefs.PostView 181 + quoteUri: string 182 + action: 'detach' | 'reattach' 183 + }) => { 184 + // cache here since post shadow mutates original object 185 + prevEmbed.current = post.embed 186 + 187 + if (action === 'detach') { 188 + updatePostShadow(queryClient, post.uri, { 189 + embed: createMaybeDetachedQuoteEmbed({ 190 + post, 191 + quote: undefined, 192 + quoteUri, 193 + detached: true, 194 + }), 195 + }) 196 + } 197 + 198 + await upsertPostgate({agent, postUri: quoteUri}, async prev => { 199 + if (prev) { 200 + if (action === 'detach') { 201 + return mergePostgateRecords(prev, { 202 + detachedEmbeddingUris: [post.uri], 203 + }) 204 + } else if (action === 'reattach') { 205 + return { 206 + ...prev, 207 + detachedEmbeddingUris: 208 + prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) || 209 + [], 210 + } 211 + } 212 + } else { 213 + if (action === 'detach') { 214 + return createPostgateRecord({ 215 + post: quoteUri, 216 + detachedEmbeddingUris: [post.uri], 217 + }) 218 + } 219 + } 220 + }) 221 + }, 222 + async onSuccess(_data, {post, quoteUri, action}) { 223 + if (action === 'reattach') { 224 + try { 225 + const [quote] = await getPosts({uris: [quoteUri]}) 226 + updatePostShadow(queryClient, post.uri, { 227 + embed: createMaybeDetachedQuoteEmbed({ 228 + post, 229 + quote, 230 + quoteUri: undefined, 231 + detached: false, 232 + }), 233 + }) 234 + } catch (e: any) { 235 + // ok if this fails, it's just optimistic UI 236 + logger.error(`Postgate: failed to get quote post for re-attachment`, { 237 + safeMessage: e.message, 238 + }) 239 + } 240 + } 241 + }, 242 + onError(_, {post, action}) { 243 + if (action === 'detach' && prevEmbed.current) { 244 + // detach failed, add the embed back 245 + if ( 246 + AppBskyEmbedRecord.isView(prevEmbed.current) || 247 + AppBskyEmbedRecordWithMedia.isView(prevEmbed.current) 248 + ) { 249 + updatePostShadow(queryClient, post.uri, { 250 + embed: prevEmbed.current, 251 + }) 252 + } 253 + } 254 + }, 255 + onSettled() { 256 + prevEmbed.current = undefined 257 + }, 258 + }) 259 + } 260 + 261 + export function useToggleQuotepostEnabledMutation() { 262 + const agent = useAgent() 263 + 264 + return useMutation({ 265 + mutationFn: async ({ 266 + postUri, 267 + action, 268 + }: { 269 + postUri: string 270 + action: 'enable' | 'disable' 271 + }) => { 272 + await upsertPostgate({agent, postUri: postUri}, async prev => { 273 + if (prev) { 274 + if (action === 'disable') { 275 + return mergePostgateRecords(prev, { 276 + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], 277 + }) 278 + } else if (action === 'enable') { 279 + return { 280 + ...prev, 281 + embeddingRules: [], 282 + } 283 + } 284 + } else { 285 + if (action === 'disable') { 286 + return createPostgateRecord({ 287 + post: postUri, 288 + embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}], 289 + }) 290 + } 291 + } 292 + }) 293 + }, 294 + }) 295 + }
+196
src/state/queries/postgate/util.ts
··· 1 + import { 2 + AppBskyEmbedRecord, 3 + AppBskyEmbedRecordWithMedia, 4 + AppBskyFeedDefs, 5 + AppBskyFeedPostgate, 6 + AtUri, 7 + } from '@atproto/api' 8 + 9 + export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' 10 + 11 + export function createPostgateRecord( 12 + postgate: Partial<AppBskyFeedPostgate.Record> & { 13 + post: AppBskyFeedPostgate.Record['post'] 14 + }, 15 + ): AppBskyFeedPostgate.Record { 16 + return { 17 + $type: POSTGATE_COLLECTION, 18 + createdAt: new Date().toISOString(), 19 + post: postgate.post, 20 + detachedEmbeddingUris: postgate.detachedEmbeddingUris || [], 21 + embeddingRules: postgate.embeddingRules || [], 22 + } 23 + } 24 + 25 + export function mergePostgateRecords( 26 + prev: AppBskyFeedPostgate.Record, 27 + next: Partial<AppBskyFeedPostgate.Record>, 28 + ) { 29 + const detachedEmbeddingUris = Array.from( 30 + new Set([ 31 + ...(prev.detachedEmbeddingUris || []), 32 + ...(next.detachedEmbeddingUris || []), 33 + ]), 34 + ) 35 + const embeddingRules = [ 36 + ...(prev.embeddingRules || []), 37 + ...(next.embeddingRules || []), 38 + ].filter( 39 + (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i, 40 + ) 41 + return createPostgateRecord({ 42 + post: prev.post, 43 + detachedEmbeddingUris, 44 + embeddingRules, 45 + }) 46 + } 47 + 48 + export function createEmbedViewDetachedRecord({uri}: {uri: string}) { 49 + const record: AppBskyEmbedRecord.ViewDetached = { 50 + $type: 'app.bsky.embed.record#viewDetached', 51 + uri, 52 + detached: true, 53 + } 54 + return { 55 + $type: 'app.bsky.embed.record#view', 56 + record, 57 + } 58 + } 59 + 60 + export function createMaybeDetachedQuoteEmbed({ 61 + post, 62 + quote, 63 + quoteUri, 64 + detached, 65 + }: 66 + | { 67 + post: AppBskyFeedDefs.PostView 68 + quote: AppBskyFeedDefs.PostView 69 + quoteUri: undefined 70 + detached: false 71 + } 72 + | { 73 + post: AppBskyFeedDefs.PostView 74 + quote: undefined 75 + quoteUri: string 76 + detached: true 77 + }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined { 78 + if (AppBskyEmbedRecord.isView(post.embed)) { 79 + if (detached) { 80 + return createEmbedViewDetachedRecord({uri: quoteUri}) 81 + } else { 82 + return createEmbedRecordView({post: quote}) 83 + } 84 + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { 85 + if (detached) { 86 + return { 87 + ...post.embed, 88 + record: createEmbedViewDetachedRecord({uri: quoteUri}), 89 + } 90 + } else { 91 + return createEmbedRecordWithMediaView({post, quote}) 92 + } 93 + } 94 + } 95 + 96 + export function createEmbedViewRecordFromPost( 97 + post: AppBskyFeedDefs.PostView, 98 + ): AppBskyEmbedRecord.ViewRecord { 99 + return { 100 + $type: 'app.bsky.embed.record#viewRecord', 101 + uri: post.uri, 102 + cid: post.cid, 103 + author: post.author, 104 + value: post.record, 105 + labels: post.labels, 106 + replyCount: post.replyCount, 107 + repostCount: post.repostCount, 108 + likeCount: post.likeCount, 109 + indexedAt: post.indexedAt, 110 + } 111 + } 112 + 113 + export function createEmbedRecordView({ 114 + post, 115 + }: { 116 + post: AppBskyFeedDefs.PostView 117 + }): AppBskyEmbedRecord.View { 118 + return { 119 + $type: 'app.bsky.embed.record#view', 120 + record: createEmbedViewRecordFromPost(post), 121 + } 122 + } 123 + 124 + export function createEmbedRecordWithMediaView({ 125 + post, 126 + quote, 127 + }: { 128 + post: AppBskyFeedDefs.PostView 129 + quote: AppBskyFeedDefs.PostView 130 + }): AppBskyEmbedRecordWithMedia.View | undefined { 131 + if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return 132 + return { 133 + ...(post.embed || {}), 134 + record: { 135 + record: createEmbedViewRecordFromPost(quote), 136 + }, 137 + } 138 + } 139 + 140 + export function getMaybeDetachedQuoteEmbed({ 141 + viewerDid, 142 + post, 143 + }: { 144 + viewerDid: string 145 + post: AppBskyFeedDefs.PostView 146 + }) { 147 + if (AppBskyEmbedRecord.isView(post.embed)) { 148 + // detached 149 + if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) { 150 + const urip = new AtUri(post.embed.record.uri) 151 + return { 152 + embed: post.embed, 153 + uri: urip.toString(), 154 + isOwnedByViewer: urip.host === viewerDid, 155 + isDetached: true, 156 + } 157 + } 158 + 159 + // post 160 + if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) { 161 + const urip = new AtUri(post.embed.record.uri) 162 + return { 163 + embed: post.embed, 164 + uri: urip.toString(), 165 + isOwnedByViewer: urip.host === viewerDid, 166 + isDetached: false, 167 + } 168 + } 169 + } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) { 170 + // detached 171 + if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) { 172 + const urip = new AtUri(post.embed.record.record.uri) 173 + return { 174 + embed: post.embed, 175 + uri: urip.toString(), 176 + isOwnedByViewer: urip.host === viewerDid, 177 + isDetached: true, 178 + } 179 + } 180 + 181 + // post 182 + if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) { 183 + const urip = new AtUri(post.embed.record.record.uri) 184 + return { 185 + embed: post.embed, 186 + uri: urip.toString(), 187 + isOwnedByViewer: urip.host === viewerDid, 188 + isDetached: false, 189 + } 190 + } 191 + } 192 + } 193 + 194 + export const embeddingRules = { 195 + disableRule: {$type: 'app.bsky.feed.postgate#disableRule'}, 196 + }
-38
src/state/queries/threadgate.ts
··· 1 - import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' 2 - 3 - export type ThreadgateSetting = 4 - | {type: 'nobody'} 5 - | {type: 'mention'} 6 - | {type: 'following'} 7 - | {type: 'list'; list: unknown} 8 - 9 - export function threadgateViewToSettings( 10 - threadgate: AppBskyFeedDefs.ThreadgateView | undefined, 11 - ): ThreadgateSetting[] { 12 - const record = 13 - threadgate && 14 - AppBskyFeedThreadgate.isRecord(threadgate.record) && 15 - AppBskyFeedThreadgate.validateRecord(threadgate.record).success 16 - ? threadgate.record 17 - : null 18 - if (!record) { 19 - return [] 20 - } 21 - if (!record.allow?.length) { 22 - return [{type: 'nobody'}] 23 - } 24 - const settings: ThreadgateSetting[] = record.allow 25 - .map(allow => { 26 - let setting: ThreadgateSetting | undefined 27 - if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { 28 - setting = {type: 'mention'} 29 - } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { 30 - setting = {type: 'following'} 31 - } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { 32 - setting = {type: 'list', list: allow.list} 33 - } 34 - return setting 35 - }) 36 - .filter(n => !!n) 37 - return settings 38 - }
+358
src/state/queries/threadgate/index.ts
··· 1 + import { 2 + AppBskyFeedDefs, 3 + AppBskyFeedGetPostThread, 4 + AppBskyFeedThreadgate, 5 + AtUri, 6 + BskyAgent, 7 + } from '@atproto/api' 8 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 9 + 10 + import {networkRetry, retry} from '#/lib/async/retry' 11 + import {until} from '#/lib/async/until' 12 + import {STALE} from '#/state/queries' 13 + import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' 14 + import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' 15 + import { 16 + createThreadgateRecord, 17 + mergeThreadgateRecords, 18 + threadgateAllowUISettingToAllowRecordValue, 19 + threadgateViewToAllowUISetting, 20 + } from '#/state/queries/threadgate/util' 21 + import {useAgent} from '#/state/session' 22 + import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies' 23 + 24 + export * from '#/state/queries/threadgate/types' 25 + export * from '#/state/queries/threadgate/util' 26 + 27 + export const threadgateRecordQueryKeyRoot = 'threadgate-record' 28 + export const createThreadgateRecordQueryKey = (uri: string) => [ 29 + threadgateRecordQueryKeyRoot, 30 + uri, 31 + ] 32 + 33 + export function useThreadgateRecordQuery({ 34 + enabled, 35 + postUri, 36 + initialData, 37 + }: { 38 + enabled?: boolean 39 + postUri?: string 40 + initialData?: AppBskyFeedThreadgate.Record 41 + } = {}) { 42 + const agent = useAgent() 43 + 44 + return useQuery({ 45 + enabled: enabled ?? !!postUri, 46 + queryKey: createThreadgateRecordQueryKey(postUri || ''), 47 + placeholderData: initialData, 48 + staleTime: STALE.MINUTES.ONE, 49 + async queryFn() { 50 + return getThreadgateRecord({ 51 + agent, 52 + postUri: postUri!, 53 + }) 54 + }, 55 + }) 56 + } 57 + 58 + export const threadgateViewQueryKeyRoot = 'threadgate-view' 59 + export const createThreadgateViewQueryKey = (uri: string) => [ 60 + threadgateViewQueryKeyRoot, 61 + uri, 62 + ] 63 + export function useThreadgateViewQuery({ 64 + postUri, 65 + initialData, 66 + }: { 67 + postUri?: string 68 + initialData?: AppBskyFeedDefs.ThreadgateView 69 + } = {}) { 70 + const agent = useAgent() 71 + 72 + return useQuery({ 73 + enabled: !!postUri, 74 + queryKey: createThreadgateViewQueryKey(postUri || ''), 75 + placeholderData: initialData, 76 + staleTime: STALE.MINUTES.ONE, 77 + async queryFn() { 78 + return getThreadgateView({ 79 + agent, 80 + postUri: postUri!, 81 + }) 82 + }, 83 + }) 84 + } 85 + 86 + export async function getThreadgateView({ 87 + agent, 88 + postUri, 89 + }: { 90 + agent: BskyAgent 91 + postUri: string 92 + }) { 93 + const {data} = await agent.app.bsky.feed.getPostThread({ 94 + uri: postUri!, 95 + depth: 0, 96 + }) 97 + 98 + if (AppBskyFeedDefs.isThreadViewPost(data.thread)) { 99 + return data.thread.post.threadgate ?? null 100 + } 101 + 102 + return null 103 + } 104 + 105 + export async function getThreadgateRecord({ 106 + agent, 107 + postUri, 108 + }: { 109 + agent: BskyAgent 110 + postUri: string 111 + }): Promise<AppBskyFeedThreadgate.Record | null> { 112 + const urip = new AtUri(postUri) 113 + 114 + if (!urip.host.startsWith('did:')) { 115 + const res = await agent.resolveHandle({ 116 + handle: urip.host, 117 + }) 118 + urip.host = res.data.did 119 + } 120 + 121 + try { 122 + const {data} = await retry( 123 + 2, 124 + e => { 125 + /* 126 + * If the record doesn't exist, we want to return null instead of 127 + * throwing an error. NB: This will also catch reference errors, such as 128 + * a typo in the URI. 129 + */ 130 + if (e.message.includes(`Could not locate record:`)) { 131 + return false 132 + } 133 + return true 134 + }, 135 + () => 136 + agent.api.com.atproto.repo.getRecord({ 137 + repo: urip.host, 138 + collection: 'app.bsky.feed.threadgate', 139 + rkey: urip.rkey, 140 + }), 141 + ) 142 + 143 + if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) { 144 + return data.value 145 + } else { 146 + return null 147 + } 148 + } catch (e: any) { 149 + /* 150 + * If the record doesn't exist, we want to return null instead of 151 + * throwing an error. NB: This will also catch reference errors, such as 152 + * a typo in the URI. 153 + */ 154 + if (e.message.includes(`Could not locate record:`)) { 155 + return null 156 + } else { 157 + throw e 158 + } 159 + } 160 + } 161 + 162 + export async function writeThreadgateRecord({ 163 + agent, 164 + postUri, 165 + threadgate, 166 + }: { 167 + agent: BskyAgent 168 + postUri: string 169 + threadgate: AppBskyFeedThreadgate.Record 170 + }) { 171 + const postUrip = new AtUri(postUri) 172 + const record = createThreadgateRecord({ 173 + post: postUri, 174 + allow: threadgate.allow, // can/should be undefined! 175 + hiddenReplies: threadgate.hiddenReplies || [], 176 + }) 177 + 178 + await networkRetry(2, () => 179 + agent.api.com.atproto.repo.putRecord({ 180 + repo: agent.session!.did, 181 + collection: 'app.bsky.feed.threadgate', 182 + rkey: postUrip.rkey, 183 + record, 184 + }), 185 + ) 186 + } 187 + 188 + export async function upsertThreadgate( 189 + { 190 + agent, 191 + postUri, 192 + }: { 193 + agent: BskyAgent 194 + postUri: string 195 + }, 196 + callback: ( 197 + threadgate: AppBskyFeedThreadgate.Record | null, 198 + ) => Promise<AppBskyFeedThreadgate.Record | undefined>, 199 + ) { 200 + const prev = await getThreadgateRecord({ 201 + agent, 202 + postUri, 203 + }) 204 + const next = await callback(prev) 205 + if (!next) return 206 + await writeThreadgateRecord({ 207 + agent, 208 + postUri, 209 + threadgate: next, 210 + }) 211 + } 212 + 213 + /** 214 + * Update the allow list for a threadgate record. 215 + */ 216 + export async function updateThreadgateAllow({ 217 + agent, 218 + postUri, 219 + allow, 220 + }: { 221 + agent: BskyAgent 222 + postUri: string 223 + allow: ThreadgateAllowUISetting[] 224 + }) { 225 + return upsertThreadgate({agent, postUri}, async prev => { 226 + if (prev) { 227 + return { 228 + ...prev, 229 + allow: threadgateAllowUISettingToAllowRecordValue(allow), 230 + } 231 + } else { 232 + return createThreadgateRecord({ 233 + post: postUri, 234 + allow: threadgateAllowUISettingToAllowRecordValue(allow), 235 + }) 236 + } 237 + }) 238 + } 239 + 240 + export function useSetThreadgateAllowMutation() { 241 + const agent = useAgent() 242 + const queryClient = useQueryClient() 243 + 244 + return useMutation({ 245 + mutationFn: async ({ 246 + postUri, 247 + allow, 248 + }: { 249 + postUri: string 250 + allow: ThreadgateAllowUISetting[] 251 + }) => { 252 + return upsertThreadgate({agent, postUri}, async prev => { 253 + if (prev) { 254 + return { 255 + ...prev, 256 + allow: threadgateAllowUISettingToAllowRecordValue(allow), 257 + } 258 + } else { 259 + return createThreadgateRecord({ 260 + post: postUri, 261 + allow: threadgateAllowUISettingToAllowRecordValue(allow), 262 + }) 263 + } 264 + }) 265 + }, 266 + async onSuccess(_, {postUri, allow}) { 267 + await until( 268 + 5, // 5 tries 269 + 1e3, // 1s delay between tries 270 + (res: AppBskyFeedGetPostThread.Response) => { 271 + const thread = res.data.thread 272 + if (AppBskyFeedDefs.isThreadViewPost(thread)) { 273 + const fetchedSettings = threadgateViewToAllowUISetting( 274 + thread.post.threadgate, 275 + ) 276 + return JSON.stringify(fetchedSettings) === JSON.stringify(allow) 277 + } 278 + return false 279 + }, 280 + () => { 281 + return agent.app.bsky.feed.getPostThread({ 282 + uri: postUri, 283 + depth: 0, 284 + }) 285 + }, 286 + ) 287 + 288 + queryClient.invalidateQueries({ 289 + queryKey: [postThreadQueryKeyRoot], 290 + }) 291 + queryClient.invalidateQueries({ 292 + queryKey: [threadgateRecordQueryKeyRoot], 293 + }) 294 + queryClient.invalidateQueries({ 295 + queryKey: [threadgateViewQueryKeyRoot], 296 + }) 297 + }, 298 + }) 299 + } 300 + 301 + export function useToggleReplyVisibilityMutation() { 302 + const agent = useAgent() 303 + const queryClient = useQueryClient() 304 + const hiddenReplies = useThreadgateHiddenReplyUrisAPI() 305 + 306 + return useMutation({ 307 + mutationFn: async ({ 308 + postUri, 309 + replyUri, 310 + action, 311 + }: { 312 + postUri: string 313 + replyUri: string 314 + action: 'hide' | 'show' 315 + }) => { 316 + if (action === 'hide') { 317 + hiddenReplies.addHiddenReplyUri(replyUri) 318 + } else if (action === 'show') { 319 + hiddenReplies.removeHiddenReplyUri(replyUri) 320 + } 321 + 322 + await upsertThreadgate({agent, postUri}, async prev => { 323 + if (prev) { 324 + if (action === 'hide') { 325 + return mergeThreadgateRecords(prev, { 326 + hiddenReplies: [replyUri], 327 + }) 328 + } else if (action === 'show') { 329 + return { 330 + ...prev, 331 + hiddenReplies: 332 + prev.hiddenReplies?.filter(uri => uri !== replyUri) || [], 333 + } 334 + } 335 + } else { 336 + if (action === 'hide') { 337 + return createThreadgateRecord({ 338 + post: postUri, 339 + hiddenReplies: [replyUri], 340 + }) 341 + } 342 + } 343 + }) 344 + }, 345 + onSuccess() { 346 + queryClient.invalidateQueries({ 347 + queryKey: [threadgateRecordQueryKeyRoot], 348 + }) 349 + }, 350 + onError(_, {replyUri, action}) { 351 + if (action === 'hide') { 352 + hiddenReplies.removeHiddenReplyUri(replyUri) 353 + } else if (action === 'show') { 354 + hiddenReplies.addHiddenReplyUri(replyUri) 355 + } 356 + }, 357 + }) 358 + }
+6
src/state/queries/threadgate/types.ts
··· 1 + export type ThreadgateAllowUISetting = 2 + | {type: 'everybody'} 3 + | {type: 'nobody'} 4 + | {type: 'mention'} 5 + | {type: 'following'} 6 + | {type: 'list'; list: unknown}
+141
src/state/queries/threadgate/util.ts
··· 1 + import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api' 2 + 3 + import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' 4 + 5 + export function threadgateViewToAllowUISetting( 6 + threadgateView: AppBskyFeedDefs.ThreadgateView | undefined, 7 + ): ThreadgateAllowUISetting[] { 8 + const threadgate = 9 + threadgateView && 10 + AppBskyFeedThreadgate.isRecord(threadgateView.record) && 11 + AppBskyFeedThreadgate.validateRecord(threadgateView.record).success 12 + ? threadgateView.record 13 + : undefined 14 + return threadgateRecordToAllowUISetting(threadgate) 15 + } 16 + 17 + /** 18 + * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of 19 + * {@link ThreadgateAllowUISetting}, for use by app UI. 20 + */ 21 + export function threadgateRecordToAllowUISetting( 22 + threadgate: AppBskyFeedThreadgate.Record | undefined, 23 + ): ThreadgateAllowUISetting[] { 24 + /* 25 + * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means 26 + * anyone can reply. 27 + * 28 + * If `threadgate.allow === []` it means no one can reply, and we translate to UI code 29 + * here. This was a historical choice, and we have no lexicon representation 30 + * for 'replies disabled' other than an empty array. 31 + */ 32 + if (!threadgate || threadgate.allow === undefined) { 33 + return [{type: 'everybody'}] 34 + } 35 + if (threadgate.allow.length === 0) { 36 + return [{type: 'nobody'}] 37 + } 38 + 39 + const settings: ThreadgateAllowUISetting[] = threadgate.allow 40 + .map(allow => { 41 + let setting: ThreadgateAllowUISetting | undefined 42 + if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { 43 + setting = {type: 'mention'} 44 + } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') { 45 + setting = {type: 'following'} 46 + } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') { 47 + setting = {type: 'list', list: allow.list} 48 + } 49 + return setting 50 + }) 51 + .filter(n => !!n) 52 + return settings 53 + } 54 + 55 + /** 56 + * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on 57 + * {@link AppBskyFeedThreadgate.Record}. 58 + * 59 + * If the `allow` property on the record is undefined, we infer that to mean 60 + * that everyone can reply. If it's an empty array, we infer that to mean that 61 + * no one can reply. 62 + */ 63 + export function threadgateAllowUISettingToAllowRecordValue( 64 + threadgate: ThreadgateAllowUISetting[], 65 + ): AppBskyFeedThreadgate.Record['allow'] { 66 + if (threadgate.find(v => v.type === 'everybody')) { 67 + return undefined 68 + } 69 + 70 + let allow: ( 71 + | AppBskyFeedThreadgate.MentionRule 72 + | AppBskyFeedThreadgate.FollowingRule 73 + | AppBskyFeedThreadgate.ListRule 74 + )[] = [] 75 + 76 + if (!threadgate.find(v => v.type === 'nobody')) { 77 + for (const rule of threadgate) { 78 + if (rule.type === 'mention') { 79 + allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'}) 80 + } else if (rule.type === 'following') { 81 + allow.push({$type: 'app.bsky.feed.threadgate#followingRule'}) 82 + } else if (rule.type === 'list') { 83 + allow.push({ 84 + $type: 'app.bsky.feed.threadgate#listRule', 85 + list: rule.list, 86 + }) 87 + } 88 + } 89 + } 90 + 91 + return allow 92 + } 93 + 94 + /** 95 + * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their 96 + * `allow` and `hiddenReplies` arrays and de-deduplicating them. 97 + * 98 + * Note: `allow` can be undefined here, be sure you don't accidentally set it 99 + * to an empty array. See other comments in this file. 100 + */ 101 + export function mergeThreadgateRecords( 102 + prev: AppBskyFeedThreadgate.Record, 103 + next: Partial<AppBskyFeedThreadgate.Record>, 104 + ): AppBskyFeedThreadgate.Record { 105 + // can be undefined if everyone can reply! 106 + const allow: AppBskyFeedThreadgate.Record['allow'] | undefined = 107 + prev.allow || next.allow 108 + ? [...(prev.allow || []), ...(next.allow || [])].filter( 109 + (v, i, a) => a.findIndex(t => t.$type === v.$type) === i, 110 + ) 111 + : undefined 112 + const hiddenReplies = Array.from( 113 + new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]), 114 + ) 115 + 116 + return createThreadgateRecord({ 117 + post: prev.post, 118 + allow, // can be undefined! 119 + hiddenReplies, 120 + }) 121 + } 122 + 123 + /** 124 + * Create a new {@link AppBskyFeedThreadgate.Record} object with the given 125 + * properties. 126 + */ 127 + export function createThreadgateRecord( 128 + threadgate: Partial<AppBskyFeedThreadgate.Record>, 129 + ): AppBskyFeedThreadgate.Record { 130 + if (!threadgate.post) { 131 + throw new Error('Cannot create a threadgate record without a post URI') 132 + } 133 + 134 + return { 135 + $type: 'app.bsky.feed.threadgate', 136 + post: threadgate.post, 137 + createdAt: new Date().toISOString(), 138 + allow: threadgate.allow, // can be undefined! 139 + hiddenReplies: threadgate.hiddenReplies || [], 140 + } 141 + }
+69
src/state/threadgate-hidden-replies.tsx
··· 1 + import React from 'react' 2 + 3 + type StateContext = { 4 + uris: Set<string> 5 + recentlyUnhiddenUris: Set<string> 6 + } 7 + type ApiContext = { 8 + addHiddenReplyUri: (uri: string) => void 9 + removeHiddenReplyUri: (uri: string) => void 10 + } 11 + 12 + const StateContext = React.createContext<StateContext>({ 13 + uris: new Set(), 14 + recentlyUnhiddenUris: new Set(), 15 + }) 16 + 17 + const ApiContext = React.createContext<ApiContext>({ 18 + addHiddenReplyUri: () => {}, 19 + removeHiddenReplyUri: () => {}, 20 + }) 21 + 22 + export function Provider({children}: {children: React.ReactNode}) { 23 + const [uris, setHiddenReplyUris] = React.useState<Set<string>>(new Set()) 24 + const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState< 25 + Set<string> 26 + >(new Set()) 27 + 28 + const stateCtx = React.useMemo( 29 + () => ({ 30 + uris, 31 + recentlyUnhiddenUris, 32 + }), 33 + [uris, recentlyUnhiddenUris], 34 + ) 35 + 36 + const apiCtx = React.useMemo( 37 + () => ({ 38 + addHiddenReplyUri(uri: string) { 39 + setHiddenReplyUris(prev => new Set(prev.add(uri))) 40 + setRecentlyUnhiddenUris(prev => { 41 + prev.delete(uri) 42 + return new Set(prev) 43 + }) 44 + }, 45 + removeHiddenReplyUri(uri: string) { 46 + setHiddenReplyUris(prev => { 47 + prev.delete(uri) 48 + return new Set(prev) 49 + }) 50 + setRecentlyUnhiddenUris(prev => new Set(prev.add(uri))) 51 + }, 52 + }), 53 + [setHiddenReplyUris], 54 + ) 55 + 56 + return ( 57 + <ApiContext.Provider value={apiCtx}> 58 + <StateContext.Provider value={stateCtx}>{children}</StateContext.Provider> 59 + </ApiContext.Provider> 60 + ) 61 + } 62 + 63 + export function useThreadgateHiddenReplyUris() { 64 + return React.useContext(StateContext) 65 + } 66 + 67 + export function useThreadgateHiddenReplyUrisAPI() { 68 + return React.useContext(ApiContext) 69 + }
+54 -15
src/view/com/composer/Composer.tsx
··· 58 58 useLanguagePrefs, 59 59 useLanguagePrefsApi, 60 60 } from '#/state/preferences/languages' 61 + import {createPostgateRecord} from '#/state/queries/postgate/util' 61 62 import {useProfileQuery} from '#/state/queries/profile' 62 63 import {Gif} from '#/state/queries/tenor' 63 - import {ThreadgateSetting} from '#/state/queries/threadgate' 64 + import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' 65 + import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' 64 66 import {useUploadVideo} from '#/state/queries/video/video' 65 67 import {useAgent, useSession} from '#/state/session' 66 68 import {useComposerControls} from '#/state/shell/composer' ··· 81 83 import {ComposerOpts} from 'state/shell/composer' 82 84 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' 83 85 import {atoms as a, useTheme} from '#/alf' 84 - import {Button, ButtonText} from '#/components/Button' 86 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 87 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 85 88 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 89 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 86 90 import * as Prompt from '#/components/Prompt' 91 + import {Text as NewText} from '#/components/Typography' 87 92 import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed' 88 93 import {Text} from '../util/text/Text' 89 94 import * as Toast from '../util/Toast' ··· 182 187 }) 183 188 const [publishOnUpload, setPublishOnUpload] = useState(false) 184 189 185 - const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 190 + const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) 186 191 const [extGif, setExtGif] = useState<Gif>() 187 192 const [labels, setLabels] = useState<string[]>([]) 188 - const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([]) 193 + const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = 194 + useState<ThreadgateAllowUISetting[]>( 195 + threadgateViewToAllowUISetting(undefined), 196 + ) 197 + const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) 189 198 190 199 const gallery = useMemo( 191 200 () => new GalleryModel(initImageUris), ··· 335 344 quote, 336 345 extLink, 337 346 labels, 338 - threadgate, 347 + threadgate: threadgateAllowUISettings, 348 + postgate, 339 349 onStateChange: setProcessingState, 340 350 langs: toPostLanguages(langPrefs.postLanguage), 341 351 }) ··· 581 591 </View> 582 592 )} 583 593 {error !== '' && ( 584 - <View style={styles.errorLine}> 585 - <View style={styles.errorIcon}> 586 - <FontAwesomeIcon 587 - icon="exclamation" 588 - style={{color: colors.red4}} 589 - size={10} 590 - /> 594 + <View style={[a.px_lg, a.pb_sm]}> 595 + <View 596 + style={[ 597 + a.px_md, 598 + a.py_sm, 599 + a.rounded_sm, 600 + a.flex_row, 601 + a.gap_sm, 602 + t.atoms.bg_contrast_25, 603 + { 604 + paddingRight: 48, 605 + }, 606 + ]}> 607 + <CircleInfo fill={t.palette.negative_400} /> 608 + <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}> 609 + {error} 610 + </NewText> 611 + <Button 612 + label={_(msg`Dismiss error`)} 613 + size="tiny" 614 + color="secondary" 615 + variant="ghost" 616 + shape="round" 617 + style={[ 618 + a.absolute, 619 + { 620 + top: a.py_sm.paddingTop, 621 + right: a.px_md.paddingRight, 622 + }, 623 + ]} 624 + onPress={() => setError('')}> 625 + <ButtonIcon icon={X} /> 626 + </Button> 591 627 </View> 592 - <Text style={[s.red4, a.flex_1]}>{error}</Text> 593 628 </View> 594 629 )} 595 630 </Animated.View> ··· 680 715 681 716 {replyTo ? null : ( 682 717 <ThreadgateBtn 683 - threadgate={threadgate} 684 - onChange={setThreadgate} 718 + postgate={postgate} 719 + onChangePostgate={setPostgate} 720 + threadgateAllowUISettings={threadgateAllowUISettings} 721 + onChangeThreadgateAllowUISettings={ 722 + onChangeThreadgateAllowUISettings 723 + } 685 724 style={bottomBarAnimatedStyle} 686 725 /> 687 726 )}
+31 -20
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 1 import React from 'react' 2 2 import {Keyboard, StyleProp, ViewStyle} from 'react-native' 3 3 import Animated, {AnimatedStyle} from 'react-native-reanimated' 4 + import {AppBskyFeedPostgate} from '@atproto/api' 4 5 import {msg} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' 6 7 7 8 import {isNative} from '#/platform/detection' 8 - import {ThreadgateSetting} from '#/state/queries/threadgate' 9 + import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' 9 10 import {useAnalytics} from 'lib/analytics/analytics' 10 11 import {atoms as a, useTheme} from '#/alf' 11 12 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 13 import * as Dialog from '#/components/Dialog' 13 - import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor' 14 - import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 14 + import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' 15 15 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 16 16 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 17 17 18 18 export function ThreadgateBtn({ 19 - threadgate, 20 - onChange, 19 + postgate, 20 + onChangePostgate, 21 + threadgateAllowUISettings, 22 + onChangeThreadgateAllowUISettings, 21 23 style, 22 24 }: { 23 - threadgate: ThreadgateSetting[] 24 - onChange: (v: ThreadgateSetting[]) => void 25 + postgate: AppBskyFeedPostgate.Record 26 + onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 27 + 28 + threadgateAllowUISettings: ThreadgateAllowUISetting[] 29 + onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void 30 + 25 31 style?: StyleProp<AnimatedStyle<ViewStyle>> 26 32 }) { 27 33 const {track} = useAnalytics() ··· 38 44 control.open() 39 45 } 40 46 41 - const isEverybody = threadgate.length === 0 42 - const isNobody = !!threadgate.find(gate => gate.type === 'nobody') 43 - const label = isEverybody 44 - ? _(msg`Everybody can reply`) 45 - : isNobody 46 - ? _(msg`Nobody can reply`) 47 - : _(msg`Some people can reply`) 47 + const anyoneCanReply = 48 + threadgateAllowUISettings.length === 1 && 49 + threadgateAllowUISettings[0].type === 'everybody' 50 + const anyoneCanQuote = 51 + !postgate.embeddingRules || postgate.embeddingRules.length === 0 52 + const anyoneCanInteract = anyoneCanReply && anyoneCanQuote 53 + const label = anyoneCanInteract 54 + ? _(msg`Anybody can interact`) 55 + : _(msg`Interaction limited`) 48 56 49 57 return ( 50 58 <> ··· 59 67 accessibilityHint={_( 60 68 msg`Opens a dialog to choose who can reply to this thread`, 61 69 )}> 62 - <ButtonIcon 63 - icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group} 64 - /> 70 + <ButtonIcon icon={anyoneCanInteract ? Earth : Group} /> 65 71 <ButtonText>{label}</ButtonText> 66 72 </Button> 67 73 </Animated.View> 68 - <ThreadgateEditorDialog 74 + <PostInteractionSettingsControlledDialog 69 75 control={control} 70 - threadgate={threadgate} 71 - onChange={onChange} 76 + onSave={() => { 77 + control.close() 78 + }} 79 + postgate={postgate} 80 + onChangePostgate={onChangePostgate} 81 + threadgateAllowUISettings={threadgateAllowUISettings} 82 + onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} 72 83 /> 73 84 </> 74 85 )
+14 -4
src/view/com/composer/useExternalLinkFetch.ts
··· 1 1 import {useEffect, useState} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 2 4 3 5 import {logger} from '#/logger' 4 6 import {useFetchDid} from '#/state/queries/handle' ··· 7 9 import * as apilib from 'lib/api/index' 8 10 import {POST_IMG_MAX} from 'lib/constants' 9 11 import { 12 + EmbeddingDisabledError, 10 13 getFeedAsEmbed, 11 14 getListAsEmbed, 12 15 getPostAsQuote, ··· 28 31 29 32 export function useExternalLinkFetch({ 30 33 setQuote, 34 + setError, 31 35 }: { 32 36 setQuote: (opts: ComposerOpts['quote']) => void 37 + setError: (err: string) => void 33 38 }) { 39 + const {_} = useLingui() 34 40 const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( 35 41 undefined, 36 42 ) ··· 57 63 setExtLink(undefined) 58 64 }, 59 65 err => { 60 - logger.error('Failed to fetch post for quote embedding', { 61 - message: err.toString(), 62 - }) 66 + if (err instanceof EmbeddingDisabledError) { 67 + setError(_(msg`This post's author has disabled quote posts.`)) 68 + } else { 69 + logger.error('Failed to fetch post for quote embedding', { 70 + message: err.toString(), 71 + }) 72 + } 63 73 setExtLink(undefined) 64 74 }, 65 75 ) ··· 170 180 }) 171 181 } 172 182 return cleanup 173 - }, [extLink, setQuote, getPost, fetchDid, agent]) 183 + }, [_, extLink, setQuote, getPost, fetchDid, agent, setError]) 174 184 175 185 return {extLink, setExtLink} 176 186 }
+1 -4
src/view/com/post-thread/PostQuotes.tsx
··· 10 10 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 11 import {cleanError} from '#/lib/strings/errors' 12 12 import {logger} from '#/logger' 13 - import {isWeb} from '#/platform/detection' 14 13 import {useModerationOpts} from '#/state/preferences/moderation-opts' 15 14 import {usePostQuotesQuery} from '#/state/queries/post-quotes' 16 15 import {useResolveUriQuery} from '#/state/queries/resolve-uri' ··· 25 24 26 25 function renderItem({ 27 26 item, 28 - index, 29 27 }: { 30 28 item: { 31 29 post: AppBskyFeedDefs.PostView 32 30 moderation: ModerationDecision 33 31 record: AppBskyFeedPost.Record 34 32 } 35 - index: number 36 33 }) { 37 - return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} /> 34 + return <Post post={item.post} /> 38 35 } 39 36 40 37 function keyExtractor(item: {
+57 -8
src/view/com/post-thread/PostThread.tsx
··· 3 3 import {runOnJS} from 'react-native-reanimated' 4 4 import Animated from 'react-native-reanimated' 5 5 import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 - import {AppBskyFeedDefs} from '@atproto/api' 6 + import { 7 + AppBskyFeedDefs, 8 + AppBskyFeedPost, 9 + AppBskyFeedThreadgate, 10 + AtUri, 11 + } from '@atproto/api' 7 12 import {msg, Trans} from '@lingui/macro' 8 13 import {useLingui} from '@lingui/react' 9 14 ··· 23 28 usePostThreadQuery, 24 29 } from '#/state/queries/post-thread' 25 30 import {usePreferencesQuery} from '#/state/queries/preferences' 31 + import {useThreadgateRecordQuery} from '#/state/queries/threadgate' 26 32 import {useSession} from '#/state/session' 27 33 import {useComposerControls} from '#/state/shell' 28 34 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' ··· 113 119 ) 114 120 const rootPost = thread?.type === 'post' ? thread.post : undefined 115 121 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 122 + const replyRef = 123 + rootPostRecord && AppBskyFeedPost.isRecord(rootPostRecord) 124 + ? rootPostRecord.reply 125 + : undefined 126 + const rootPostUri = replyRef ? replyRef.root.uri : rootPost?.uri 127 + 128 + const isOP = 129 + currentAccount && 130 + rootPostUri && 131 + currentAccount?.did === new AtUri(rootPostUri).host 132 + const {data: threadgateRecord} = useThreadgateRecordQuery({ 133 + /** 134 + * If the user is the OP and the root post has a threadgate, we should load 135 + * the threadgate record. Otherwise, fallback to initialData, which is taken 136 + * from the response from `getPostThread`. 137 + */ 138 + enabled: Boolean(isOP && rootPostUri), 139 + postUri: rootPostUri, 140 + initialData: rootPost?.threadgate?.record as 141 + | AppBskyFeedThreadgate.Record 142 + | undefined, 143 + }) 116 144 117 145 const moderationOpts = useModerationOpts() 118 146 const isNoPwi = React.useMemo(() => { ··· 167 195 const skeleton = React.useMemo(() => { 168 196 const threadViewPrefs = preferences?.threadViewPrefs 169 197 if (!threadViewPrefs || !thread) return null 198 + const threadgateRecordHiddenReplies = new Set<string>( 199 + threadgateRecord?.hiddenReplies || [], 200 + ) 170 201 171 202 return createThreadSkeleton( 172 203 sortThread( ··· 175 206 threadModerationCache, 176 207 currentDid, 177 208 justPostedUris, 209 + threadgateRecordHiddenReplies, 178 210 ), 179 - !!currentDid, 211 + currentDid, 180 212 treeView, 181 213 threadModerationCache, 182 214 hiddenRepliesState !== HiddenRepliesState.Hide, 215 + threadgateRecordHiddenReplies, 183 216 ) 184 217 }, [ 185 218 thread, ··· 189 222 threadModerationCache, 190 223 hiddenRepliesState, 191 224 justPostedUris, 225 + threadgateRecord, 192 226 ]) 193 227 194 228 const error = React.useMemo(() => { ··· 425 459 <PostThreadItem 426 460 post={item.post} 427 461 record={item.record} 462 + threadgateRecord={threadgateRecord ?? undefined} 428 463 moderation={threadModerationCache.get(item)} 429 464 treeView={treeView} 430 465 depth={item.ctx.depth} ··· 545 580 546 581 function createThreadSkeleton( 547 582 node: ThreadNode, 548 - hasSession: boolean, 583 + currentDid: string | undefined, 549 584 treeView: boolean, 550 585 modCache: ThreadModerationCache, 551 586 showHiddenReplies: boolean, 587 + threadgateRecordHiddenReplies: Set<string>, 552 588 ): ThreadSkeletonParts | null { 553 589 if (!node) return null 554 590 555 591 return { 556 - parents: Array.from(flattenThreadParents(node, hasSession)), 592 + parents: Array.from(flattenThreadParents(node, !!currentDid)), 557 593 highlightedPost: node, 558 594 replies: Array.from( 559 595 flattenThreadReplies( 560 596 node, 561 - hasSession, 597 + currentDid, 562 598 treeView, 563 599 modCache, 564 600 showHiddenReplies, 601 + threadgateRecordHiddenReplies, 565 602 ), 566 603 ), 567 604 } ··· 594 631 595 632 function* flattenThreadReplies( 596 633 node: ThreadNode, 597 - hasSession: boolean, 634 + currentDid: string | undefined, 598 635 treeView: boolean, 599 636 modCache: ThreadModerationCache, 600 637 showHiddenReplies: boolean, 638 + threadgateRecordHiddenReplies: Set<string>, 601 639 ): Generator<YieldedItem, HiddenReplyType> { 602 640 if (node.type === 'post') { 603 641 // dont show pwi-opted-out posts to logged out users 604 - if (!hasSession && hasPwiOptOut(node)) { 642 + if (!currentDid && hasPwiOptOut(node)) { 605 643 return HiddenReplyType.None 606 644 } 607 645 ··· 616 654 return HiddenReplyType.Hidden 617 655 } 618 656 } 657 + 658 + if (!showHiddenReplies) { 659 + const hiddenByThreadgate = threadgateRecordHiddenReplies.has( 660 + node.post.uri, 661 + ) 662 + const authorIsViewer = node.post.author.did === currentDid 663 + if (hiddenByThreadgate && !authorIsViewer) { 664 + return HiddenReplyType.Hidden 665 + } 666 + } 619 667 } 620 668 621 669 if (!node.ctx.isHighlightedPost) { ··· 627 675 for (const reply of node.replies) { 628 676 let hiddenReply = yield* flattenThreadReplies( 629 677 reply, 630 - hasSession, 678 + currentDid, 631 679 treeView, 632 680 modCache, 633 681 showHiddenReplies, 682 + threadgateRecordHiddenReplies, 634 683 ) 635 684 if (hiddenReply > hiddenReplies) { 636 685 hiddenReplies = hiddenReply
+33 -1
src/view/com/post-thread/PostThreadItem.tsx
··· 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, 6 + AppBskyFeedThreadgate, 6 7 AtUri, 7 8 ModerationDecision, 8 9 RichText as RichTextAPI, ··· 29 30 import {useSession} from 'state/session' 30 31 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' 31 32 import {atoms as a} from '#/alf' 33 + import {AppModerationCause} from '#/components/Pills' 32 34 import {RichText} from '#/components/RichText' 33 35 import {ContentHider} from '../../../components/moderation/ContentHider' 34 36 import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' ··· 61 63 overrideBlur, 62 64 onPostReply, 63 65 hideTopBorder, 66 + threadgateRecord, 64 67 }: { 65 68 post: AppBskyFeedDefs.PostView 66 69 record: AppBskyFeedPost.Record ··· 77 80 overrideBlur: boolean 78 81 onPostReply: (postUri: string | undefined) => void 79 82 hideTopBorder?: boolean 83 + threadgateRecord?: AppBskyFeedThreadgate.Record 80 84 }) { 81 85 const postShadowed = usePostShadow(post) 82 86 const richText = useMemo( ··· 111 115 overrideBlur={overrideBlur} 112 116 onPostReply={onPostReply} 113 117 hideTopBorder={hideTopBorder} 118 + threadgateRecord={threadgateRecord} 114 119 /> 115 120 ) 116 121 } ··· 154 159 overrideBlur, 155 160 onPostReply, 156 161 hideTopBorder, 162 + threadgateRecord, 157 163 }: { 158 164 post: Shadow<AppBskyFeedDefs.PostView> 159 165 record: AppBskyFeedPost.Record ··· 171 177 overrideBlur: boolean 172 178 onPostReply: (postUri: string | undefined) => void 173 179 hideTopBorder?: boolean 180 + threadgateRecord?: AppBskyFeedThreadgate.Record 174 181 }): React.ReactNode => { 175 182 const pal = usePalette('default') 176 183 const {_} = useLingui() ··· 199 206 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 200 207 }, [post.uri, post.author]) 201 208 const repostsTitle = _(msg`Reposts of this post`) 209 + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { 210 + const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes( 211 + post.uri, 212 + ) 213 + const isControlledByViewer = 214 + threadgateRecord && 215 + new AtUri(threadgateRecord.post).host === currentAccount?.did 216 + if (!isControlledByViewer) return [] 217 + return threadgateRecord && isPostHiddenByThreadgate 218 + ? [ 219 + { 220 + type: 'reply-hidden', 221 + source: {type: 'user', did: new AtUri(threadgateRecord.post).host}, 222 + priority: 6, 223 + }, 224 + ] 225 + : [] 226 + }, [post, threadgateRecord, currentAccount?.did]) 202 227 const quotesHref = React.useMemo(() => { 203 228 const urip = new AtUri(post.uri) 204 229 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') ··· 320 345 size="lg" 321 346 includeMute 322 347 style={[a.pt_2xs, a.pb_sm]} 348 + additionalCauses={additionalPostAlerts} 323 349 /> 324 350 {richText?.text ? ( 325 351 <View ··· 420 446 onPressReply={onPressReply} 421 447 onPostReply={onPostReply} 422 448 logContext="PostThreadItem" 449 + threadgateRecord={threadgateRecord} 423 450 /> 424 451 </View> 425 452 </View> ··· 540 567 <PostAlerts 541 568 modui={moderation.ui('contentList')} 542 569 style={[a.pt_2xs, a.pb_2xs]} 570 + additionalCauses={additionalPostAlerts} 543 571 /> 544 572 {richText?.text ? ( 545 573 <View style={styles.postTextContainer}> ··· 571 599 richText={richText} 572 600 onPressReply={onPressReply} 573 601 logContext="PostThreadItem" 602 + threadgateRecord={threadgateRecord} 574 603 /> 575 604 </View> 576 605 </View> ··· 677 706 const pal = usePalette('default') 678 707 const {_} = useLingui() 679 708 const openLink = useOpenLink() 709 + const isRootPost = !('reply' in post.record) 680 710 681 711 const onTranslatePress = React.useCallback(() => { 682 712 openLink(translatorUrl) ··· 693 723 s.mb10, 694 724 ]}> 695 725 <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> 696 - <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 726 + {isRootPost && ( 727 + <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 728 + )} 697 729 {needsTranslation && ( 698 730 <> 699 731 <Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
+66 -3
src/view/com/posts/FeedItem.tsx
··· 4 4 AppBskyActorDefs, 5 5 AppBskyFeedDefs, 6 6 AppBskyFeedPost, 7 + AppBskyFeedThreadgate, 7 8 AtUri, 8 9 ModerationDecision, 9 10 RichText as RichTextAPI, ··· 21 22 import {useFeedFeedbackContext} from '#/state/feed-feedback' 22 23 import {useSession} from '#/state/session' 23 24 import {useComposerControls} from '#/state/shell/composer' 25 + import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' 24 26 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' 25 27 import {MAX_POST_LINES} from 'lib/constants' 26 28 import {usePalette} from 'lib/hooks/usePalette' ··· 33 35 import {atoms as a} from '#/alf' 34 36 import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 35 37 import {ContentHider} from '#/components/moderation/ContentHider' 38 + import {AppModerationCause} from '#/components/Pills' 36 39 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 37 40 import {RichText} from '#/components/RichText' 38 41 import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' ··· 80 83 hideTopBorder, 81 84 isParentBlocked, 82 85 isParentNotFound, 83 - }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { 86 + rootPost, 87 + }: FeedItemProps & { 88 + post: AppBskyFeedDefs.PostView 89 + rootPost: AppBskyFeedDefs.PostView 90 + }): React.ReactNode { 84 91 const postShadowed = usePostShadow(post) 85 92 const richText = useMemo( 86 93 () => ··· 112 119 hideTopBorder={hideTopBorder} 113 120 isParentBlocked={isParentBlocked} 114 121 isParentNotFound={isParentNotFound} 122 + rootPost={rootPost} 115 123 /> 116 124 ) 117 125 } ··· 133 141 hideTopBorder, 134 142 isParentBlocked, 135 143 isParentNotFound, 144 + rootPost, 136 145 }: FeedItemProps & { 137 146 richText: RichTextAPI 138 147 post: Shadow<AppBskyFeedDefs.PostView> 148 + rootPost: AppBskyFeedDefs.PostView 139 149 }): React.ReactNode => { 140 150 const queryClient = useQueryClient() 141 151 const {openComposer} = useComposerControls() ··· 216 226 const isOwner = 217 227 AppBskyFeedDefs.isReasonRepost(reason) && 218 228 reason.by.did === currentAccount?.did 229 + 230 + const threadgateRecord = AppBskyFeedThreadgate.isRecord( 231 + rootPost.threadgate?.record, 232 + ) 233 + ? rootPost.threadgate.record 234 + : undefined 219 235 220 236 return ( 221 237 <Link ··· 363 379 postEmbed={post.embed} 364 380 postAuthor={post.author} 365 381 onOpenEmbed={onOpenEmbed} 382 + post={post} 383 + threadgateRecord={threadgateRecord} 366 384 /> 367 385 <VideoDebug /> 368 386 <PostCtrls ··· 372 390 onPressReply={onPressReply} 373 391 logContext="FeedItem" 374 392 feedContext={feedContext} 393 + threadgateRecord={threadgateRecord} 375 394 /> 376 395 </View> 377 396 </View> ··· 381 400 FeedItemInner = memo(FeedItemInner) 382 401 383 402 let PostContent = ({ 403 + post, 384 404 moderation, 385 405 richText, 386 406 postEmbed, 387 407 postAuthor, 388 408 onOpenEmbed, 409 + threadgateRecord, 389 410 }: { 390 411 moderation: ModerationDecision 391 412 richText: RichTextAPI 392 413 postEmbed: AppBskyFeedDefs.PostView['embed'] 393 414 postAuthor: AppBskyFeedDefs.PostView['author'] 394 415 onOpenEmbed: () => void 416 + post: AppBskyFeedDefs.PostView 417 + threadgateRecord?: AppBskyFeedThreadgate.Record 395 418 }): React.ReactNode => { 396 419 const pal = usePalette('default') 397 420 const {_} = useLingui() 421 + const {currentAccount} = useSession() 398 422 const [limitLines, setLimitLines] = useState( 399 423 () => countLines(richText.text) >= MAX_POST_LINES, 400 424 ) 425 + const {uris: hiddenReplyUris, recentlyUnhiddenUris} = 426 + useThreadgateHiddenReplyUris() 427 + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { 428 + const isPostHiddenByHiddenReplyCache = hiddenReplyUris.has(post.uri) 429 + const isPostHiddenByThreadgate = 430 + !recentlyUnhiddenUris.has(post.uri) && 431 + !!threadgateRecord?.hiddenReplies?.includes(post.uri) 432 + const isHidden = isPostHiddenByHiddenReplyCache || isPostHiddenByThreadgate 433 + const isControlledByViewer = 434 + isPostHiddenByHiddenReplyCache || 435 + (threadgateRecord && 436 + new AtUri(threadgateRecord.post).host === currentAccount?.did) 437 + if (!isControlledByViewer) return [] 438 + const alertSource = 439 + threadgateRecord && isPostHiddenByThreadgate 440 + ? new AtUri(threadgateRecord.post).host 441 + : isPostHiddenByHiddenReplyCache 442 + ? currentAccount?.did 443 + : undefined 444 + return isHidden && alertSource 445 + ? [ 446 + { 447 + type: 'reply-hidden', 448 + source: {type: 'user', did: alertSource}, 449 + priority: 6, 450 + }, 451 + ] 452 + : [] 453 + }, [ 454 + post, 455 + hiddenReplyUris, 456 + recentlyUnhiddenUris, 457 + threadgateRecord, 458 + currentAccount?.did, 459 + ]) 401 460 402 461 const onPressShowMore = React.useCallback(() => { 403 462 setLimitLines(false) ··· 409 468 modui={moderation.ui('contentList')} 410 469 ignoreMute 411 470 childContainerStyle={styles.contentHiderChild}> 412 - <PostAlerts modui={moderation.ui('contentList')} style={[a.py_2xs]} /> 471 + <PostAlerts 472 + modui={moderation.ui('contentList')} 473 + style={[a.py_2xs]} 474 + additionalCauses={additionalPostAlerts} 475 + /> 413 476 {richText.text ? ( 414 477 <View style={styles.postTextContainer}> 415 478 <RichText ··· 460 523 if (blocked) { 461 524 label = <Trans context="description">Reply to a blocked post</Trans> 462 525 } else if (notFound) { 463 - label = <Trans context="description">Reply to an unknown post</Trans> 526 + label = <Trans context="description">Reply to a post</Trans> 464 527 } else if (profile != null) { 465 528 const isMe = profile.did === currentAccount?.did 466 529 if (isMe) {
+4
src/view/com/posts/FeedSlice.tsx
··· 37 37 hideTopBorder={hideTopBorder} 38 38 isParentBlocked={slice.items[0].isParentBlocked} 39 39 isParentNotFound={slice.items[0].isParentNotFound} 40 + rootPost={slice.items[0].post} 40 41 /> 41 42 <ViewFullThread uri={slice.items[0].uri} /> 42 43 <FeedItem ··· 55 56 isThreadChild={isThreadChildAt(slice.items, beforeLast)} 56 57 isParentBlocked={slice.items[beforeLast].isParentBlocked} 57 58 isParentNotFound={slice.items[beforeLast].isParentNotFound} 59 + rootPost={slice.items[0].post} 58 60 /> 59 61 <FeedItem 60 62 key={slice.items[last]._reactKey} ··· 70 72 isParentBlocked={slice.items[last].isParentBlocked} 71 73 isParentNotFound={slice.items[last].isParentNotFound} 72 74 isThreadLastChild 75 + rootPost={slice.items[0].post} 73 76 /> 74 77 </> 75 78 ) ··· 95 98 isParentBlocked={slice.items[i].isParentBlocked} 96 99 isParentNotFound={slice.items[i].isParentNotFound} 97 100 hideTopBorder={hideTopBorder && i === 0} 101 + rootPost={slice.items[0].post} 98 102 /> 99 103 ))} 100 104 </>
+243 -19
src/view/com/util/forms/PostDropdownBtn.tsx
··· 1 1 import React, {memo} from 'react' 2 2 import { 3 + Platform, 3 4 Pressable, 4 5 type PressableProps, 5 6 type StyleProp, ··· 9 10 import { 10 11 AppBskyFeedDefs, 11 12 AppBskyFeedPost, 13 + AppBskyFeedThreadgate, 12 14 AtUri, 13 15 RichText as RichTextAPI, 14 16 } from '@atproto/api' ··· 31 33 usePostDeleteMutation, 32 34 useThreadMuteMutationQueue, 33 35 } from '#/state/queries/post' 36 + import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' 37 + import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' 38 + import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' 34 39 import {useSession} from '#/state/session' 40 + import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' 35 41 import {getCurrentRoute} from 'lib/routes/helpers' 36 42 import {shareUrl} from 'lib/sharing' 37 43 import {toShareUrl} from 'lib/strings/url-helpers' ··· 40 46 import {useDialogControl} from '#/components/Dialog' 41 47 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 42 48 import {EmbedDialog} from '#/components/dialogs/Embed' 49 + import { 50 + PostInteractionSettingsDialog, 51 + usePrefetchPostInteractionSettings, 52 + } from '#/components/dialogs/PostInteractionSettingsDialog' 43 53 import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' 44 54 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 45 55 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' ··· 50 60 EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, 51 61 EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, 52 62 } from '#/components/icons/Emoji' 63 + import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye' 53 64 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 54 65 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 55 66 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 56 67 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 68 + import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 57 69 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 58 70 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 59 71 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' 72 + import {Loader} from '#/components/Loader' 60 73 import * as Menu from '#/components/Menu' 61 74 import * as Prompt from '#/components/Prompt' 62 75 import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' ··· 73 86 hitSlop, 74 87 size, 75 88 timestamp, 89 + threadgateRecord, 76 90 }: { 77 91 testID: string 78 92 post: Shadow<AppBskyFeedDefs.PostView> ··· 83 97 hitSlop?: PressableProps['hitSlop'] 84 98 size?: 'lg' | 'md' | 'sm' 85 99 timestamp: string 100 + threadgateRecord?: AppBskyFeedThreadgate.Record 86 101 }): React.ReactNode => { 87 102 const {hasSession, currentAccount} = useSession() 88 103 const theme = useTheme() ··· 104 119 const loggedOutWarningPromptControl = useDialogControl() 105 120 const embedPostControl = useDialogControl() 106 121 const sendViaChatControl = useDialogControl() 122 + const postInteractionSettingsDialogControl = useDialogControl() 123 + const quotePostDetachConfirmControl = useDialogControl() 124 + const hideReplyConfirmControl = useDialogControl() 125 + const {mutateAsync: toggleReplyVisibility} = 126 + useToggleReplyVisibilityMutation() 127 + const {uris: hiddenReplies, recentlyUnhiddenUris} = 128 + useThreadgateHiddenReplyUris() 129 + 107 130 const postUri = post.uri 108 131 const postCid = post.cid 109 132 const postAuthor = post.author 133 + const quoteEmbed = React.useMemo(() => { 134 + if (!currentAccount || !post.embed) return 135 + return getMaybeDetachedQuoteEmbed({ 136 + viewerDid: currentAccount.did, 137 + post, 138 + }) 139 + }, [post, currentAccount]) 110 140 111 141 const rootUri = record.reply?.root?.uri || postUri 142 + const isReply = Boolean(record.reply) 112 143 const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue( 113 144 post, 114 145 rootUri, 115 146 ) 116 147 const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) 117 148 const isAuthor = postAuthor.did === currentAccount?.did 149 + const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did 150 + const isReplyHiddenByThreadgate = 151 + hiddenReplies.has(postUri) || 152 + (!recentlyUnhiddenUris.has(postUri) && 153 + threadgateRecord?.hiddenReplies?.includes(postUri)) 154 + 155 + const {mutateAsync: toggleQuoteDetachment, isPending} = 156 + useToggleQuoteDetachmentMutation() 157 + 158 + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ 159 + postUri: post.uri, 160 + rootPostUri: rootUri, 161 + }) 118 162 119 163 const href = React.useMemo(() => { 120 164 const urip = new AtUri(postUri) ··· 242 286 [navigation, postUri], 243 287 ) 244 288 289 + const onToggleQuotePostAttachment = React.useCallback(async () => { 290 + if (!quoteEmbed) return 291 + 292 + const action = quoteEmbed.isDetached ? 'reattach' : 'detach' 293 + const isDetach = action === 'detach' 294 + 295 + try { 296 + await toggleQuoteDetachment({ 297 + post, 298 + quoteUri: quoteEmbed.uri, 299 + action: quoteEmbed.isDetached ? 'reattach' : 'detach', 300 + }) 301 + Toast.show( 302 + isDetach 303 + ? _(msg`Quote post was successfully detached`) 304 + : _(msg`Quote post was re-attached`), 305 + ) 306 + } catch (e: any) { 307 + Toast.show(_(msg`Updating quote attachment failed`)) 308 + logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) 309 + } 310 + }, [_, quoteEmbed, post, toggleQuoteDetachment]) 311 + 312 + const canHidePostForMe = !isAuthor && !isPostHidden 245 313 const canEmbed = isWeb && gtMobile && !hideInPWI 314 + const canHideReplyForEveryone = 315 + !isAuthor && isRootPostAuthor && !isPostHidden && isReply 316 + const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer 317 + 318 + const onToggleReplyVisibility = React.useCallback(async () => { 319 + // TODO no threadgate? 320 + if (!canHideReplyForEveryone) return 321 + 322 + const action = isReplyHiddenByThreadgate ? 'show' : 'hide' 323 + const isHide = action === 'hide' 324 + 325 + try { 326 + await toggleReplyVisibility({ 327 + postUri: rootUri, 328 + replyUri: postUri, 329 + action, 330 + }) 331 + Toast.show( 332 + isHide 333 + ? _(msg`Reply was successfully hidden`) 334 + : _(msg`Reply visibility updated`), 335 + ) 336 + } catch (e: any) { 337 + Toast.show(_(msg`Updating reply visibility failed`)) 338 + logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 339 + } 340 + }, [ 341 + _, 342 + isReplyHiddenByThreadgate, 343 + rootUri, 344 + postUri, 345 + canHideReplyForEveryone, 346 + toggleReplyVisibility, 347 + ]) 246 348 247 349 return ( 248 350 <EventStopper onKeyDown={false}> ··· 383 485 <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 384 486 <Menu.ItemIcon icon={Filter} position="right" /> 385 487 </Menu.Item> 386 - 387 - {!isAuthor && !isPostHidden && ( 388 - <Menu.Item 389 - testID="postDropdownHideBtn" 390 - label={_(msg`Hide post`)} 391 - onPress={hidePromptControl.open}> 392 - <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText> 393 - <Menu.ItemIcon icon={EyeSlash} position="right" /> 394 - </Menu.Item> 395 - )} 396 488 </Menu.Group> 397 489 </> 398 490 )} 399 491 492 + {hasSession && 493 + (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && ( 494 + <> 495 + <Menu.Divider /> 496 + <Menu.Group> 497 + {canHidePostForMe && ( 498 + <Menu.Item 499 + testID="postDropdownHideBtn" 500 + label={ 501 + isReply 502 + ? _(msg`Hide reply for me`) 503 + : _(msg`Hide post for me`) 504 + } 505 + onPress={hidePromptControl.open}> 506 + <Menu.ItemText> 507 + {isReply 508 + ? _(msg`Hide reply for me`) 509 + : _(msg`Hide post for me`)} 510 + </Menu.ItemText> 511 + <Menu.ItemIcon icon={EyeSlash} position="right" /> 512 + </Menu.Item> 513 + )} 514 + {canHideReplyForEveryone && ( 515 + <Menu.Item 516 + testID="postDropdownHideBtn" 517 + label={ 518 + isReplyHiddenByThreadgate 519 + ? _(msg`Show reply for everyone`) 520 + : _(msg`Hide reply for everyone`) 521 + } 522 + onPress={ 523 + isReplyHiddenByThreadgate 524 + ? onToggleReplyVisibility 525 + : () => hideReplyConfirmControl.open() 526 + }> 527 + <Menu.ItemText> 528 + {isReplyHiddenByThreadgate 529 + ? _(msg`Show reply for everyone`) 530 + : _(msg`Hide reply for everyone`)} 531 + </Menu.ItemText> 532 + <Menu.ItemIcon 533 + icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} 534 + position="right" 535 + /> 536 + </Menu.Item> 537 + )} 538 + 539 + {canDetachQuote && ( 540 + <Menu.Item 541 + disabled={isPending} 542 + testID="postDropdownHideBtn" 543 + label={ 544 + quoteEmbed.isDetached 545 + ? _(msg`Re-attach quote`) 546 + : _(msg`Detach quote`) 547 + } 548 + onPress={ 549 + quoteEmbed.isDetached 550 + ? onToggleQuotePostAttachment 551 + : () => quotePostDetachConfirmControl.open() 552 + }> 553 + <Menu.ItemText> 554 + {quoteEmbed.isDetached 555 + ? _(msg`Re-attach quote`) 556 + : _(msg`Detach quote`)} 557 + </Menu.ItemText> 558 + <Menu.ItemIcon 559 + icon={ 560 + isPending 561 + ? Loader 562 + : quoteEmbed.isDetached 563 + ? Eye 564 + : EyeSlash 565 + } 566 + position="right" 567 + /> 568 + </Menu.Item> 569 + )} 570 + </Menu.Group> 571 + </> 572 + )} 573 + 400 574 {hasSession && ( 401 575 <> 402 576 <Menu.Divider /> ··· 412 586 )} 413 587 414 588 {isAuthor && ( 415 - <Menu.Item 416 - testID="postDropdownDeleteBtn" 417 - label={_(msg`Delete post`)} 418 - onPress={deletePromptControl.open}> 419 - <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 420 - <Menu.ItemIcon icon={Trash} position="right" /> 421 - </Menu.Item> 589 + <> 590 + <Menu.Item 591 + testID="postDropdownEditPostInteractions" 592 + label={_(msg`Edit interaction settings`)} 593 + onPress={postInteractionSettingsDialogControl.open} 594 + {...(isAuthor 595 + ? Platform.select({ 596 + web: { 597 + onHoverIn: prefetchPostInteractionSettings, 598 + }, 599 + native: { 600 + onPressIn: prefetchPostInteractionSettings, 601 + }, 602 + }) 603 + : {})}> 604 + <Menu.ItemText> 605 + {_(msg`Edit interaction settings`)} 606 + </Menu.ItemText> 607 + <Menu.ItemIcon icon={Gear} position="right" /> 608 + </Menu.Item> 609 + <Menu.Item 610 + testID="postDropdownDeleteBtn" 611 + label={_(msg`Delete post`)} 612 + onPress={deletePromptControl.open}> 613 + <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 614 + <Menu.ItemIcon icon={Trash} position="right" /> 615 + </Menu.Item> 616 + </> 422 617 )} 423 618 </Menu.Group> 424 619 </> ··· 439 634 440 635 <Prompt.Basic 441 636 control={hidePromptControl} 442 - title={_(msg`Hide this post?`)} 443 - description={_(msg`This post will be hidden from feeds.`)} 637 + title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 638 + description={_( 639 + msg`This post will be hidden from feeds and threads. This cannot be undone.`, 640 + )} 444 641 onConfirm={onHidePost} 445 642 confirmButtonCta={_(msg`Hide`)} 446 643 /> ··· 478 675 <SendViaChatDialog 479 676 control={sendViaChatControl} 480 677 onSelectChat={onSelectChatToShareTo} 678 + /> 679 + 680 + <PostInteractionSettingsDialog 681 + control={postInteractionSettingsDialogControl} 682 + postUri={post.uri} 683 + rootPostUri={rootUri} 684 + initialThreadgateView={post.threadgate} 685 + /> 686 + 687 + <Prompt.Basic 688 + control={quotePostDetachConfirmControl} 689 + title={_(msg`Detach quote post?`)} 690 + description={_( 691 + msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 692 + )} 693 + onConfirm={onToggleQuotePostAttachment} 694 + confirmButtonCta={_(msg`Yes, detach`)} 695 + /> 696 + 697 + <Prompt.Basic 698 + control={hideReplyConfirmControl} 699 + title={_(msg`Hide this reply?`)} 700 + description={_( 701 + msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, 702 + )} 703 + onConfirm={onToggleReplyVisibility} 704 + confirmButtonCta={_(msg`Yes, hide`)} 481 705 /> 482 706 </EventStopper> 483 707 )
+5
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 10 10 import { 11 11 AppBskyFeedDefs, 12 12 AppBskyFeedPost, 13 + AppBskyFeedThreadgate, 13 14 AtUri, 14 15 RichText as RichTextAPI, 15 16 } from '@atproto/api' ··· 60 61 onPressReply, 61 62 onPostReply, 62 63 logContext, 64 + threadgateRecord, 63 65 }: { 64 66 big?: boolean 65 67 post: Shadow<AppBskyFeedDefs.PostView> ··· 70 72 onPressReply: () => void 71 73 onPostReply?: (postUri: string | undefined) => void 72 74 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 75 + threadgateRecord?: AppBskyFeedThreadgate.Record 73 76 }): React.ReactNode => { 74 77 const t = useTheme() 75 78 const {_} = useLingui() ··· 256 259 onRepost={onRepost} 257 260 onQuote={onQuote} 258 261 big={big} 262 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 259 263 /> 260 264 </View> 261 265 <View style={big ? a.align_center : [a.flex_1, a.align_start]}> ··· 344 348 style={{padding: 5}} 345 349 hitSlop={POST_CTRL_HITSLOP} 346 350 timestamp={post.indexedAt} 351 + threadgateRecord={threadgateRecord} 347 352 /> 348 353 </View> 349 354 {gate('debug_show_feedcontext') && feedContext && (
+25 -4
src/view/com/util/post-ctrls/RepostButton.tsx
··· 20 20 onRepost: () => void 21 21 onQuote: () => void 22 22 big?: boolean 23 + embeddingDisabled: boolean 23 24 } 24 25 25 26 let RepostButton = ({ ··· 28 29 onRepost, 29 30 onQuote, 30 31 big, 32 + embeddingDisabled, 31 33 }: Props): React.ReactNode => { 32 34 const t = useTheme() 33 35 const {_} = useLingui() ··· 111 113 </Text> 112 114 </Button> 113 115 <Button 116 + disabled={embeddingDisabled} 114 117 testID="quoteBtn" 115 118 style={[a.justify_start, a.px_md]} 116 - label={_(msg`Quote post`)} 119 + label={ 120 + embeddingDisabled 121 + ? _(msg`Quote posts disabled`) 122 + : _(msg`Quote post`) 123 + } 117 124 onPress={() => { 118 125 playHaptic() 119 126 dialogControl.close(() => { ··· 123 130 size="large" 124 131 variant="ghost" 125 132 color="primary"> 126 - <Quote size="lg" fill={t.palette.primary_500} /> 127 - <Text style={[a.font_bold, a.text_xl]}> 128 - {_(msg`Quote post`)} 133 + <Quote 134 + size="lg" 135 + fill={ 136 + embeddingDisabled 137 + ? t.atoms.text_contrast_low.color 138 + : t.palette.primary_500 139 + } 140 + /> 141 + <Text 142 + style={[ 143 + a.font_bold, 144 + a.text_xl, 145 + embeddingDisabled && t.atoms.text_contrast_low, 146 + ]}> 147 + {embeddingDisabled 148 + ? _(msg`Quote posts disabled`) 149 + : _(msg`Quote post`)} 129 150 </Text> 130 151 </Button> 131 152 </View>
+13 -2
src/view/com/util/post-ctrls/RepostButton.web.tsx
··· 20 20 onRepost: () => void 21 21 onQuote: () => void 22 22 big?: boolean 23 + embeddingDisabled: boolean 23 24 } 24 25 25 26 export const RepostButton = ({ ··· 28 29 onRepost, 29 30 onQuote, 30 31 big, 32 + embeddingDisabled, 31 33 }: Props) => { 32 34 const t = useTheme() 33 35 const {_} = useLingui() ··· 76 78 <Menu.ItemIcon icon={Repost} position="right" /> 77 79 </Menu.Item> 78 80 <Menu.Item 79 - label={_(msg`Quote post`)} 81 + disabled={embeddingDisabled} 82 + label={ 83 + embeddingDisabled 84 + ? _(msg`Quote posts disabled`) 85 + : _(msg`Quote post`) 86 + } 80 87 testID="repostDropdownQuoteBtn" 81 88 onPress={onQuote}> 82 - <Menu.ItemText>{_(msg`Quote post`)}</Menu.ItemText> 89 + <Menu.ItemText> 90 + {embeddingDisabled 91 + ? _(msg`Quote posts disabled`) 92 + : _(msg`Quote post`)} 93 + </Menu.ItemText> 83 94 <Menu.ItemIcon icon={Quote} position="right" /> 84 95 </Menu.Item> 85 96 </Menu.Outer>
+18
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 26 26 import {HITSLOP_20} from '#/lib/constants' 27 27 import {s} from '#/lib/styles' 28 28 import {useModerationOpts} from '#/state/preferences/moderation-opts' 29 + import {useSession} from '#/state/session' 29 30 import {usePalette} from 'lib/hooks/usePalette' 30 31 import {InfoCircleIcon} from 'lib/icons' 31 32 import {makeProfileLink} from 'lib/routes/links' ··· 52 53 allowNestedQuotes?: boolean 53 54 }) { 54 55 const pal = usePalette('default') 56 + const {currentAccount} = useSession() 55 57 if ( 56 58 AppBskyEmbedRecord.isViewRecord(embed.record) && 57 59 AppBskyFeedPost.isRecord(embed.record.value) && ··· 81 83 <InfoCircleIcon size={18} style={pal.text} /> 82 84 <Text type="lg" style={pal.text}> 83 85 <Trans>Deleted</Trans> 86 + </Text> 87 + </View> 88 + ) 89 + } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { 90 + const isViewerOwner = currentAccount?.did 91 + ? embed.record.uri.includes(currentAccount.did) 92 + : false 93 + return ( 94 + <View style={[styles.errorContainer, pal.borderDark]}> 95 + <InfoCircleIcon size={18} style={pal.text} /> 96 + <Text type="lg" style={pal.text}> 97 + {isViewerOwner ? ( 98 + <Trans>Removed by you</Trans> 99 + ) : ( 100 + <Trans>Removed by author</Trans> 101 + )} 84 102 </Text> 85 103 </View> 86 104 )
+1
src/view/screens/DebugMod.tsx
··· 807 807 showReplyTo={false} 808 808 reason={undefined} 809 809 feedContext={''} 810 + rootPost={post} 810 811 /> 811 812 ) 812 813 }
+8 -8
yarn.lock
··· 72 72 resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" 73 73 integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== 74 74 75 - "@atproto/api@^0.13.0": 76 - version "0.13.0" 77 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" 78 - integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== 75 + "@atproto/api@0.13.2": 76 + version "0.13.2" 77 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" 78 + integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== 79 79 dependencies: 80 80 "@atproto/common-web" "^0.3.0" 81 81 "@atproto/lexicon" "^0.4.1" ··· 85 85 multiformats "^9.9.0" 86 86 tlds "^1.234.0" 87 87 88 - "@atproto/api@^0.13.2": 89 - version "0.13.2" 90 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" 91 - integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== 88 + "@atproto/api@^0.13.0": 89 + version "0.13.0" 90 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" 91 + integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== 92 92 dependencies: 93 93 "@atproto/common-web" "^0.3.0" 94 94 "@atproto/lexicon" "^0.4.1"