Bluesky app fork with some witchin' additions ๐Ÿ’ซ
0
fork

Configure Feed

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

[๐Ÿด] Block states, read only (#4022)

* Refactor ChatListItem for mod state

* Refactor Conversation Header for mod state

* Invalidate query for list when blocking/unblocking

* Remove unused prop, restore border

* Add mutations, hook up profile shadow to list query, use shadow-aware query for convo (#4024)

authored by

Eric Bailey and committed by
GitHub
6efe90a5 d390db0f

+246 -69
+83 -11
src/components/dms/ConvoMenu.tsx
··· 1 1 import React, {useCallback} from 'react' 2 2 import {Keyboard, Pressable, View} from 'react-native' 3 - import {AppBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 3 + import { 4 + AppBskyActorDefs, 5 + ChatBskyConvoDefs, 6 + ModerationDecision, 7 + } from '@atproto/api' 4 8 import {msg, Trans} from '@lingui/macro' 5 9 import {useLingui} from '@lingui/react' 6 10 import {useNavigation} from '@react-navigation/native' 7 11 8 12 import {NavigationProp} from '#/lib/routes/types' 13 + import {listUriToHref} from '#/lib/strings/url-helpers' 14 + import {Shadow} from '#/state/cache/types' 9 15 import { 10 16 useConvoQuery, 11 17 useMarkAsReadMutation, 12 18 } from '#/state/queries/messages/conversation' 13 19 import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 14 20 import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 21 + import {useProfileBlockMutationQueue} from '#/state/queries/profile' 15 22 import * as Toast from '#/view/com/util/Toast' 16 23 import {atoms as a, useTheme} from '#/alf' 24 + import * as Dialog from '#/components/Dialog' 17 25 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' 18 26 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 19 27 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' ··· 22 30 import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 23 31 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 24 32 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 33 + import {InlineLinkText} from '#/components/Link' 25 34 import * as Menu from '#/components/Menu' 26 35 import * as Prompt from '#/components/Prompt' 36 + import {Text} from '#/components/Typography' 27 37 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble' 28 38 29 39 let ConvoMenu = ({ ··· 34 44 showMarkAsRead, 35 45 hideTrigger, 36 46 triggerOpacity, 47 + moderation, 37 48 }: { 38 49 convo: ChatBskyConvoDefs.ConvoView 39 - profile: AppBskyActorDefs.ProfileViewBasic 40 - onUpdateConvo?: (convo: ChatBskyConvoDefs.ConvoView) => void 50 + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 41 51 control?: Menu.MenuControlProps 42 52 currentScreen: 'list' | 'conversation' 43 53 showMarkAsRead?: boolean 44 54 hideTrigger?: boolean 45 55 triggerOpacity?: number 56 + moderation: ModerationDecision 46 57 }): React.ReactNode => { 47 58 const navigation = useNavigation<NavigationProp>() 48 59 const {_} = useLingui() 49 60 const t = useTheme() 50 61 const leaveConvoControl = Prompt.usePromptControl() 51 62 const reportControl = Prompt.usePromptControl() 63 + const blockedByListControl = Prompt.usePromptControl() 52 64 const {mutate: markAsRead} = useMarkAsReadMutation() 65 + const modui = moderation.ui('profileView') 66 + const {listBlocks, userBlock} = React.useMemo(() => { 67 + const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 68 + const listBlocks = blocks.filter(alert => alert.source.type === 'list') 69 + const userBlock = blocks.find(alert => alert.source.type === 'user') 70 + return { 71 + listBlocks, 72 + userBlock, 73 + } 74 + }, [modui]) 75 + const isBlocking = !!userBlock || !!listBlocks.length 53 76 54 77 const {data: convo} = useConvoQuery(initialConvo) 55 78 ··· 69 92 Toast.show(_(msg`Could not mute chat`)) 70 93 }, 71 94 }) 95 + 96 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 97 + 98 + const toggleBlock = React.useCallback(() => { 99 + if (listBlocks.length) { 100 + blockedByListControl.open() 101 + return 102 + } 103 + 104 + if (userBlock) { 105 + queueUnblock() 106 + } else { 107 + queueBlock() 108 + } 109 + }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) 72 110 73 111 const {mutate: leaveConvo} = useLeaveConvo(convo?.id, { 74 112 onSuccess: () => { ··· 146 184 </Menu.Item> 147 185 </Menu.Group> 148 186 <Menu.Divider /> 149 - {/* TODO(samuel): implement this */} 150 187 <Menu.Group> 151 188 <Menu.Item 152 - label={_(msg`Block account`)} 153 - onPress={() => {}} 154 - disabled> 189 + label={ 190 + isBlocking ? _(msg`Unblock account`) : _(msg`Block account`) 191 + } 192 + onPress={toggleBlock}> 155 193 <Menu.ItemText> 156 - <Trans>Block account</Trans> 194 + {isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)} 157 195 </Menu.ItemText> 158 - <Menu.ItemIcon 159 - icon={profile.viewer?.blocking ? PersonCheck : PersonX} 160 - /> 196 + <Menu.ItemIcon icon={isBlocking ? PersonX : PersonCheck} /> 161 197 </Menu.Item> 162 198 <Menu.Item 163 199 label={_(msg`Report conversation`)} ··· 202 238 confirmButtonCta={_(msg`I understand`)} 203 239 onConfirm={noop} 204 240 /> 241 + 242 + <Prompt.Outer control={blockedByListControl} testID="blockedByListDialog"> 243 + <Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText> 244 + 245 + <View style={[a.gap_sm, a.pb_lg]}> 246 + <Text 247 + selectable 248 + style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 249 + {_( 250 + msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`, 251 + )}{' '} 252 + </Text> 253 + 254 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}> 255 + {_(msg`Lists blocking this user:`)}{' '} 256 + {listBlocks.map((block, i) => 257 + block.source.type === 'list' ? ( 258 + <React.Fragment key={block.source.list.uri}> 259 + {i === 0 ? null : ', '} 260 + <InlineLinkText 261 + to={listUriToHref(block.source.list.uri)} 262 + style={[a.text_md, a.leading_snug]}> 263 + {block.source.list.name} 264 + </InlineLinkText> 265 + </React.Fragment> 266 + ) : null, 267 + )} 268 + </Text> 269 + </View> 270 + 271 + <Prompt.Actions> 272 + <Prompt.Cancel cta={_(msg`I understand`)} /> 273 + </Prompt.Actions> 274 + 275 + <Dialog.Close /> 276 + </Prompt.Outer> 205 277 </> 206 278 ) 207 279 }
+72 -32
src/screens/Messages/Conversation/index.tsx
··· 3 3 import {KeyboardProvider} from 'react-native-keyboard-controller' 4 4 import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 5 5 import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 - import {AppBskyActorDefs} from '@atproto/api' 6 + import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 7 7 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 8 8 import {msg} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' ··· 12 12 13 13 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 14 14 import {useGate} from '#/lib/statsig/statsig' 15 + import {useProfileShadow} from '#/state/cache/profile-shadow' 15 16 import {useCurrentConvoId} from '#/state/messages/current-convo-id' 17 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 + import {useProfileQuery} from '#/state/queries/profile' 16 19 import {BACK_HITSLOP} from 'lib/constants' 20 + import {sanitizeDisplayName} from 'lib/strings/display-names' 17 21 import {isIOS, isWeb} from 'platform/detection' 18 22 import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo' 19 23 import {ConvoStatus} from 'state/messages/convo/types' ··· 27 31 import {Loader} from '#/components/Loader' 28 32 import {Text} from '#/components/Typography' 29 33 import {ClipClopGate} from '../gate' 34 + 30 35 type Props = NativeStackScreenProps< 31 36 CommonNavigatorParams, 32 37 'MessagesConversation' ··· 137 142 } 138 143 139 144 let Header = ({ 140 - profile, 145 + profile: initialProfile, 141 146 }: { 142 147 profile?: AppBskyActorDefs.ProfileViewBasic 143 148 }): React.ReactNode => { ··· 145 150 const {_} = useLingui() 146 151 const {gtTablet} = useBreakpoints() 147 152 const navigation = useNavigation<NavigationProp>() 148 - const convoState = useConvo() 149 - 150 - const isDeletedAccount = profile?.handle === 'missing.invalid' 151 - const displayName = isDeletedAccount 152 - ? 'Deleted Account' 153 - : profile?.displayName 153 + const moderationOpts = useModerationOpts() 154 + const {data: profile} = useProfileQuery({did: initialProfile?.did}) 154 155 155 156 const onPressBack = useCallback(() => { 156 157 if (isWeb) { ··· 195 196 ) : ( 196 197 <View style={{width: 30}} /> 197 198 )} 198 - <View style={[a.align_center, a.gap_sm, a.flex_1]}> 199 - {profile ? ( 200 - <View style={[a.align_center]}> 201 - <PreviewableUserAvatar size={32} profile={profile} /> 202 - <Text 203 - style={[a.text_lg, a.font_bold, a.pt_sm, a.pb_2xs]} 204 - numberOfLines={1}> 205 - {displayName} 206 - </Text> 207 - {!isDeletedAccount && ( 208 - <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}> 209 - @{profile.handle} 210 - </Text> 211 - )} 212 - </View> 213 - ) : ( 214 - <> 199 + 200 + {profile && moderationOpts ? ( 201 + <HeaderReady profile={profile} moderationOpts={moderationOpts} /> 202 + ) : ( 203 + <> 204 + <View style={[a.align_center, a.gap_sm, a.flex_1]}> 215 205 <View 216 206 style={[ 217 207 {width: 32, height: 32}, ··· 234 224 t.atoms.bg_contrast_25, 235 225 ]} 236 226 /> 237 - </> 238 - )} 227 + </View> 228 + 229 + <View style={{width: 30}} /> 230 + </> 231 + )} 232 + </View> 233 + ) 234 + } 235 + Header = React.memo(Header) 236 + 237 + function HeaderReady({ 238 + profile: profileUnshadowed, 239 + moderationOpts, 240 + }: { 241 + profile: AppBskyActorDefs.ProfileViewBasic 242 + moderationOpts: ModerationOpts 243 + }) { 244 + const t = useTheme() 245 + const convoState = useConvo() 246 + const profile = useProfileShadow(profileUnshadowed) 247 + const moderation = React.useMemo( 248 + () => moderateProfile(profile, moderationOpts), 249 + [profile, moderationOpts], 250 + ) 251 + 252 + const isDeletedAccount = profile?.handle === 'missing.invalid' 253 + const displayName = isDeletedAccount 254 + ? 'Deleted Account' 255 + : sanitizeDisplayName( 256 + profile.displayName || profile.handle, 257 + moderation.ui('displayName'), 258 + ) 259 + 260 + return ( 261 + <> 262 + <View style={[a.align_center, a.gap_sm, a.flex_1]}> 263 + <View style={[a.align_center]}> 264 + <PreviewableUserAvatar 265 + size={32} 266 + profile={profile} 267 + moderation={moderation.ui('avatar')} 268 + /> 269 + <Text 270 + style={[a.text_lg, a.font_bold, a.pt_sm, a.pb_2xs]} 271 + numberOfLines={1}> 272 + {displayName} 273 + </Text> 274 + {!isDeletedAccount && ( 275 + <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}> 276 + @{profile.handle} 277 + </Text> 278 + )} 279 + </View> 239 280 </View> 240 - {isConvoActive(convoState) && profile ? ( 281 + 282 + {isConvoActive(convoState) && ( 241 283 <ConvoMenu 242 284 convo={convoState.convo} 243 285 profile={profile} 244 286 currentScreen="conversation" 287 + moderation={moderation} 245 288 /> 246 - ) : ( 247 - <View style={{width: 30}} /> 248 289 )} 249 - </View> 290 + </> 250 291 ) 251 292 } 252 - Header = React.memo(Header)
+53 -17
src/screens/Messages/List/ChatListItem.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {ChatBskyConvoDefs} from '@atproto/api' 3 + import { 4 + AppBskyActorDefs, 5 + ChatBskyConvoDefs, 6 + moderateProfile, 7 + ModerationOpts, 8 + } from '@atproto/api' 4 9 import {msg} from '@lingui/macro' 5 10 import {useLingui} from '@lingui/react' 6 11 import {useNavigation} from '@react-navigation/native' 7 12 8 13 import {NavigationProp} from '#/lib/routes/types' 9 14 import {isNative} from '#/platform/detection' 15 + import {useProfileShadow} from '#/state/cache/profile-shadow' 16 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 10 17 import {useSession} from '#/state/session' 18 + import {sanitizeDisplayName} from 'lib/strings/display-names' 11 19 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 12 20 import {UserAvatar} from '#/view/com/util/UserAvatar' 13 21 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' ··· 17 25 import {useMenuControl} from '#/components/Menu' 18 26 import {Text} from '#/components/Typography' 19 27 20 - export function ChatListItem({ 28 + export function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { 29 + const {currentAccount} = useSession() 30 + const otherUser = convo.members.find( 31 + member => member.did !== currentAccount?.did, 32 + ) 33 + const moderationOpts = useModerationOpts() 34 + 35 + if (!otherUser || !moderationOpts) { 36 + return null 37 + } 38 + 39 + return ( 40 + <ChatListItemReady 41 + convo={convo} 42 + profile={otherUser} 43 + moderationOpts={moderationOpts} 44 + /> 45 + ) 46 + } 47 + 48 + function ChatListItemReady({ 21 49 convo, 22 - index, 50 + profile: profileUnshadowed, 51 + moderationOpts, 23 52 }: { 24 53 convo: ChatBskyConvoDefs.ConvoView 25 - index: number 54 + profile: AppBskyActorDefs.ProfileViewBasic 55 + moderationOpts: ModerationOpts 26 56 }) { 27 57 const t = useTheme() 28 58 const {_} = useLingui() 29 59 const {currentAccount} = useSession() 30 60 const menuControl = useMenuControl() 31 61 const {gtMobile} = useBreakpoints() 32 - const otherUser = convo.members.find( 33 - member => member.did !== currentAccount?.did, 62 + const profile = useProfileShadow(profileUnshadowed) 63 + const moderation = React.useMemo( 64 + () => moderateProfile(profile, moderationOpts), 65 + [profile, moderationOpts], 34 66 ) 35 - const isDeletedAccount = otherUser?.handle === 'missing.invalid' 67 + 68 + const isDeletedAccount = profile.handle === 'missing.invalid' 36 69 const displayName = isDeletedAccount 37 70 ? 'Deleted Account' 38 - : otherUser?.displayName || otherUser?.handle 71 + : sanitizeDisplayName( 72 + profile.displayName || profile.handle, 73 + moderation.ui('displayName'), 74 + ) 39 75 40 76 let lastMessage = _(msg`No messages yet`) 41 77 let lastMessageSentAt: string | null = null ··· 73 109 }) 74 110 }, [convo.id, navigation]) 75 111 76 - if (!otherUser) { 77 - return null 78 - } 79 - 80 112 return ( 81 113 <View 82 114 // @ts-expect-error web only ··· 85 117 onFocus={onFocus} 86 118 onBlur={onMouseLeave}> 87 119 <Button 88 - label={otherUser.displayName || otherUser.handle} 120 + label={profile.displayName || profile.handle} 89 121 onPress={onPress} 90 122 style={a.flex_1} 91 123 onLongPress={isNative ? menuControl.open : undefined}> ··· 98 130 a.py_md, 99 131 a.gap_md, 100 132 (hovered || pressed) && t.atoms.bg_contrast_25, 101 - index === 0 && [a.border_t, a.pt_lg], 102 133 t.atoms.border_contrast_low, 103 134 ]}> 104 - <UserAvatar avatar={otherUser?.avatar} size={52} /> 135 + <UserAvatar 136 + avatar={profile.avatar} 137 + size={52} 138 + moderation={moderation.ui('avatar')} 139 + /> 105 140 <View style={[a.flex_1, a.flex_row, a.align_center]}> 106 141 <View style={[a.flex_1]}> 107 142 <View ··· 154 189 <Text 155 190 numberOfLines={1} 156 191 style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> 157 - @{otherUser.handle} 192 + @{profile.handle} 158 193 </Text> 159 194 )} 160 195 <Text ··· 196 231 )} 197 232 <ConvoMenu 198 233 convo={convo} 199 - profile={otherUser} 234 + profile={profile} 200 235 control={menuControl} 201 236 currentScreen="list" 202 237 showMarkAsRead={convo.unreadCount > 0} ··· 204 239 triggerOpacity={ 205 240 !gtMobile || showActions || menuControl.isOpen ? 1 : 0 206 241 } 242 + moderation={moderation} 207 243 /> 208 244 </View> 209 245 </View>
+4 -8
src/screens/Messages/List/index.tsx
··· 29 29 30 30 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> 31 31 32 - function renderItem({ 33 - item, 34 - index, 35 - }: { 36 - item: ChatBskyConvoDefs.ConvoView 37 - index: number 38 - }) { 39 - return <ChatListItem convo={item} index={index} /> 32 + function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { 33 + return <ChatListItem convo={item} /> 40 34 } 41 35 42 36 function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { ··· 232 226 a.gap_lg, 233 227 a.px_lg, 234 228 a.py_sm, 229 + a.border_b, 230 + t.atoms.border_contrast_low, 235 231 ]}> 236 232 <Text style={[a.text_2xl, a.font_bold]}> 237 233 <Trans>Messages</Trans>
+2
src/state/cache/profile-shadow.ts
··· 6 6 import {batchedUpdates} from '#/lib/batchedUpdates' 7 7 import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '../queries/actor-search' 8 8 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' 9 + import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '../queries/messages/list-converations' 9 10 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' 10 11 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts' 11 12 import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by' ··· 105 106 yield* findAllProfilesInProfileFollowsQueryData(queryClient, did) 106 107 yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) 107 108 yield* findAllProfilesInActorSearchQueryData(queryClient, did) 109 + yield* findAllProfilesInListConvosQueryData(queryClient, did) 108 110 }
+32 -1
src/state/queries/messages/list-converations.ts
··· 1 1 import {useCallback, useMemo} from 'react' 2 2 import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api' 3 - import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query' 3 + import { 4 + InfiniteData, 5 + QueryClient, 6 + useInfiniteQuery, 7 + useQueryClient, 8 + } from '@tanstack/react-query' 4 9 5 10 import {useCurrentConvoId} from '#/state/messages/current-convo-id' 6 11 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' ··· 140 145 })), 141 146 } 142 147 } 148 + 149 + export function* findAllProfilesInQueryData( 150 + queryClient: QueryClient, 151 + did: string, 152 + ) { 153 + const queryDatas = queryClient.getQueriesData< 154 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 155 + >({ 156 + queryKey: RQKEY, 157 + }) 158 + for (const [_queryKey, queryData] of queryDatas) { 159 + if (!queryData?.pages) { 160 + continue 161 + } 162 + 163 + for (const page of queryData.pages) { 164 + for (const convo of page.convos) { 165 + for (const member of convo.members) { 166 + if (member.did === did) { 167 + yield member 168 + } 169 + } 170 + } 171 + } 172 + } 173 + }