Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Fix convo header loading state (#7603)

* get initial convo state from cache

* undo useConvoQuery changes

* fix shadowing situation with new hook

authored by

Samuel Newman and committed by
GitHub
32b28d66 fa8607b8

+174 -84
+5 -8
src/components/dms/ConvoMenu.tsx
··· 73 73 const isBlocking = userBlock || !!listBlocks.length 74 74 const isDeletedAccount = profile.handle === 'missing.invalid' 75 75 76 + const convoId = initialConvo.id 76 77 const {data: convo} = useConvoQuery(initialConvo) 77 78 78 79 const onNavigateToProfile = useCallback(() => { 79 80 navigation.navigate('Profile', {name: profile.did}) 80 81 }, [navigation, profile.did]) 81 82 82 - const {mutate: muteConvo} = useMuteConvo(convo?.id, { 83 + const {mutate: muteConvo} = useMuteConvo(convoId, { 83 84 onSuccess: data => { 84 85 if (data.convo.muted) { 85 86 Toast.show(_(msg`Chat muted`)) ··· 152 153 {showMarkAsRead && ( 153 154 <Menu.Item 154 155 label={_(msg`Mark as read`)} 155 - onPress={() => 156 - markAsRead({ 157 - convoId: convo?.id, 158 - }) 159 - }> 156 + onPress={() => markAsRead({convoId})}> 160 157 <Menu.ItemText> 161 158 <Trans>Mark as read</Trans> 162 159 </Menu.ItemText> ··· 222 219 223 220 <LeaveConvoPrompt 224 221 control={leaveConvoControl} 225 - convoId={convo.id} 222 + convoId={convoId} 226 223 currentScreen={currentScreen} 227 224 /> 228 225 {latestReportableMessage ? ( ··· 230 227 currentScreen={currentScreen} 231 228 params={{ 232 229 type: 'convoMessage', 233 - convoId: convo.id, 230 + convoId: convoId, 234 231 message: latestReportableMessage, 235 232 }} 236 233 control={reportControl}
+14 -7
src/components/dms/MessagesListBlockedFooter.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs, ModerationCause} from '@atproto/api' 3 + import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 ··· 19 19 recipient: initialRecipient, 20 20 convoId, 21 21 hasMessages, 22 - blockInfo, 22 + moderation, 23 23 }: { 24 24 recipient: AppBskyActorDefs.ProfileViewBasic 25 25 convoId: string 26 26 hasMessages: boolean 27 - blockInfo: { 28 - listBlocks: ModerationCause[] 29 - userBlock: ModerationCause | undefined 30 - } 27 + moderation: ModerationDecision 31 28 }) { 32 29 const t = useTheme() 33 30 const {gtMobile} = useBreakpoints() ··· 39 36 const reportControl = useDialogControl() 40 37 const blockedByListControl = useDialogControl() 41 38 42 - const {listBlocks, userBlock} = blockInfo 39 + const {listBlocks, userBlock} = React.useMemo(() => { 40 + const modui = moderation.ui('profileView') 41 + const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 42 + const listBlocks = blocks.filter(alert => alert.source.type === 'list') 43 + const userBlock = blocks.find(alert => alert.source.type === 'user') 44 + return { 45 + listBlocks, 46 + userBlock, 47 + } 48 + }, [moderation]) 49 + 43 50 const isBlocking = !!userBlock || !!listBlocks.length 44 51 45 52 const onUnblockPress = React.useCallback(() => {
+16 -10
src/components/dms/MessagesListHeader.tsx
··· 15 15 import {NavigationProp} from '#/lib/routes/types' 16 16 import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 17 import {isWeb} from '#/platform/detection' 18 - import {useProfileShadow} from '#/state/cache/profile-shadow' 18 + import {Shadow} from '#/state/cache/profile-shadow' 19 19 import {isConvoActive, useConvo} from '#/state/messages/convo' 20 20 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 21 21 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' ··· 30 30 export let MessagesListHeader = ({ 31 31 profile, 32 32 moderation, 33 - blockInfo, 34 33 }: { 35 - profile?: AppBskyActorDefs.ProfileViewBasic 34 + profile?: Shadow<AppBskyActorDefs.ProfileViewBasic> 36 35 moderation?: ModerationDecision 37 - blockInfo?: { 38 - listBlocks: ModerationCause[] 39 - userBlock?: ModerationCause 40 - } 41 36 }): React.ReactNode => { 42 37 const t = useTheme() 43 38 const {_} = useLingui() 44 39 const {gtTablet} = useBreakpoints() 45 40 const navigation = useNavigation<NavigationProp>() 41 + 42 + const blockInfo = React.useMemo(() => { 43 + if (!moderation) return 44 + const modui = moderation.ui('profileView') 45 + const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 46 + const listBlocks = blocks.filter(alert => alert.source.type === 'list') 47 + const userBlock = blocks.find(alert => alert.source.type === 'user') 48 + return { 49 + listBlocks, 50 + userBlock, 51 + } 52 + }, [moderation]) 46 53 47 54 const onPressBack = useCallback(() => { 48 55 if (isWeb) { ··· 127 134 MessagesListHeader = React.memo(MessagesListHeader) 128 135 129 136 function HeaderReady({ 130 - profile: profileUnshadowed, 137 + profile, 131 138 moderation, 132 139 blockInfo, 133 140 }: { 134 - profile: AppBskyActorDefs.ProfileViewBasic 141 + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 135 142 moderation: ModerationDecision 136 143 blockInfo: { 137 144 listBlocks: ModerationCause[] ··· 141 148 const {_} = useLingui() 142 149 const t = useTheme() 143 150 const convoState = useConvo() 144 - const profile = useProfileShadow(profileUnshadowed) 145 151 146 152 const isDeletedAccount = profile?.handle === 'missing.invalid' 147 153 const displayName = isDeletedAccount
-1
src/screens/Messages/ChatList.tsx
··· 236 236 onEndReachedThreshold={isNative ? 1.5 : 0} 237 237 initialNumToRender={initialNumToRender} 238 238 windowSize={11} 239 - // @ts-ignore our .web version only -sfn 240 239 desktopFixedHeight 241 240 sideBorders={false} 242 241 />
+27 -32
src/screens/Messages/Conversation.tsx
··· 1 1 import React, {useCallback} from 'react' 2 2 import {View} from 'react-native' 3 - import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 3 + import { 4 + AppBskyActorDefs, 5 + moderateProfile, 6 + ModerationDecision, 7 + } from '@atproto/api' 4 8 import {msg} from '@lingui/macro' 5 9 import {useLingui} from '@lingui/react' 6 10 import {useFocusEffect, useNavigation} from '@react-navigation/native' ··· 10 14 import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 11 15 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 12 16 import {isWeb} from '#/platform/detection' 13 - import {useProfileShadow} from '#/state/cache/profile-shadow' 17 + import {Shadow, useMaybeProfileShadow} from '#/state/cache/profile-shadow' 14 18 import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' 15 19 import {ConvoStatus} from '#/state/messages/convo/types' 16 20 import {useCurrentConvoId} from '#/state/messages/current-convo-id' ··· 72 76 const {_} = useLingui() 73 77 74 78 const moderationOpts = useModerationOpts() 75 - const {data: recipient} = useProfileQuery({ 79 + const {data: recipientUnshadowed} = useProfileQuery({ 76 80 did: convoState.recipients?.[0].did, 77 81 }) 82 + const recipient = useMaybeProfileShadow(recipientUnshadowed) 83 + 84 + const moderation = React.useMemo(() => { 85 + if (!recipient || !moderationOpts) return null 86 + return moderateProfile(recipient, moderationOpts) 87 + }, [recipient, moderationOpts]) 78 88 79 89 // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user, 80 90 // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be ··· 110 120 111 121 return ( 112 122 <Layout.Center style={[a.flex_1]}> 113 - {!readyToShow && <MessagesListHeader />} 123 + {!readyToShow && 124 + (moderation ? ( 125 + <MessagesListHeader moderation={moderation} profile={recipient} /> 126 + ) : ( 127 + <MessagesListHeader /> 128 + ))} 114 129 <View style={[a.flex_1]}> 115 - {moderationOpts && recipient ? ( 130 + {moderation && recipient ? ( 116 131 <InnerReady 117 - moderationOpts={moderationOpts} 132 + moderation={moderation} 118 133 recipient={recipient} 119 134 hasScrolled={hasScrolled} 120 135 setHasScrolled={setHasScrolled} ··· 144 159 } 145 160 146 161 function InnerReady({ 147 - moderationOpts, 148 - recipient: recipientUnshadowed, 162 + moderation, 163 + recipient, 149 164 hasScrolled, 150 165 setHasScrolled, 151 166 }: { 152 - moderationOpts: ModerationOpts 153 - recipient: AppBskyActorDefs.ProfileViewBasic 167 + moderation: ModerationDecision 168 + recipient: Shadow<AppBskyActorDefs.ProfileViewBasic> 154 169 hasScrolled: boolean 155 170 setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> 156 171 }) { 157 172 const {_} = useLingui() 158 173 const convoState = useConvo() 159 174 const navigation = useNavigation<NavigationProp>() 160 - const recipient = useProfileShadow(recipientUnshadowed) 161 175 const verifyEmailControl = useDialogControl() 162 176 const {needsEmailVerification} = useEmail() 163 177 164 - const moderation = React.useMemo(() => { 165 - return moderateProfile(recipient, moderationOpts) 166 - }, [recipient, moderationOpts]) 167 - 168 - const blockInfo = React.useMemo(() => { 169 - const modui = moderation.ui('profileView') 170 - const blocks = modui.alerts.filter(alert => alert.type === 'blocking') 171 - const listBlocks = blocks.filter(alert => alert.source.type === 'list') 172 - const userBlock = blocks.find(alert => alert.source.type === 'user') 173 - return { 174 - listBlocks, 175 - userBlock, 176 - } 177 - }, [moderation]) 178 - 179 178 React.useEffect(() => { 180 179 if (needsEmailVerification) { 181 180 verifyEmailControl.open() ··· 184 183 185 184 return ( 186 185 <> 187 - <MessagesListHeader 188 - profile={recipient} 189 - moderation={moderation} 190 - blockInfo={blockInfo} 191 - /> 186 + <MessagesListHeader profile={recipient} moderation={moderation} /> 192 187 {isConvoActive(convoState) && ( 193 188 <MessagesList 194 189 hasScrolled={hasScrolled} ··· 199 194 recipient={recipient} 200 195 convoId={convoState.convo.id} 201 196 hasMessages={convoState.items.length > 0} 202 - blockInfo={blockInfo} 197 + moderation={moderation} 203 198 /> 204 199 } 205 200 />
+10 -2
src/screens/Messages/components/ChatListItem.tsx
··· 9 9 } from '@atproto/api' 10 10 import {msg} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' 12 + import {useQueryClient} from '@tanstack/react-query' 12 13 13 14 import {GestureActionView} from '#/lib/custom-animations/GestureActionView' 14 15 import {useHaptics} from '#/lib/haptics' ··· 23 24 import {isNative} from '#/platform/detection' 24 25 import {useProfileShadow} from '#/state/cache/profile-shadow' 25 26 import {useModerationOpts} from '#/state/preferences/moderation-opts' 26 - import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' 27 + import { 28 + precacheConvoQuery, 29 + useMarkAsReadMutation, 30 + } from '#/state/queries/messages/conversation' 31 + import {precacheProfile} from '#/state/queries/profile' 27 32 import {useSession} from '#/state/session' 28 33 import {TimeElapsed} from '#/view/com/util/TimeElapsed' 29 34 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' ··· 89 94 [profile, moderationOpts], 90 95 ) 91 96 const playHaptic = useHaptics() 97 + const queryClient = useQueryClient() 92 98 const isUnread = convo.unreadCount > 0 93 99 94 100 const blockInfo = useMemo(() => { ··· 198 204 199 205 const onPress = useCallback( 200 206 (e: GestureResponderEvent) => { 207 + precacheProfile(queryClient, profile) 208 + precacheConvoQuery(queryClient, convo) 201 209 decrementBadgeCount(convo.unreadCount) 202 210 if (isDeletedAccount) { 203 211 e.preventDefault() ··· 207 215 logEvent('chat:open', {logContext: 'ChatsList'}) 208 216 } 209 217 }, 210 - [convo.unreadCount, isDeletedAccount, menuControl], 218 + [isDeletedAccount, menuControl, queryClient, profile, convo], 211 219 ) 212 220 213 221 const onLongPress = useCallback(() => {
+38
src/state/cache/profile-shadow.ts
··· 63 63 }, [profile, shadow]) 64 64 } 65 65 66 + /** 67 + * Same as useProfileShadow, but allows for the profile to be undefined. 68 + * This is useful for when the profile is not guaranteed to be loaded yet. 69 + */ 70 + export function useMaybeProfileShadow< 71 + TProfileView extends AppBskyActorDefs.ProfileView, 72 + >(profile?: TProfileView): Shadow<TProfileView> | undefined { 73 + const [shadow, setShadow] = useState(() => 74 + profile ? shadows.get(profile) : undefined, 75 + ) 76 + const [prevPost, setPrevPost] = useState(profile) 77 + if (profile !== prevPost) { 78 + setPrevPost(profile) 79 + setShadow(profile ? shadows.get(profile) : undefined) 80 + } 81 + 82 + useEffect(() => { 83 + if (!profile) return 84 + function onUpdate() { 85 + if (!profile) return 86 + setShadow(shadows.get(profile)) 87 + } 88 + emitter.addListener(profile.did, onUpdate) 89 + return () => { 90 + emitter.removeListener(profile.did, onUpdate) 91 + } 92 + }, [profile]) 93 + 94 + return useMemo(() => { 95 + if (!profile) return undefined 96 + if (shadow) { 97 + return mergeShadow(profile, shadow) 98 + } else { 99 + return castAsShadow(profile) 100 + } 101 + }, [profile, shadow]) 102 + } 103 + 66 104 export function updateProfileShadow( 67 105 queryClient: QueryClient, 68 106 did: string,
+25 -7
src/state/messages/convo/agent.ts
··· 81 81 convoId: string 82 82 convo: ChatBskyConvoDefs.ConvoView | undefined 83 83 sender: AppBskyActorDefs.ProfileViewBasic | undefined 84 - recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined = undefined 84 + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined 85 85 snapshot: ConvoState | undefined 86 86 87 87 constructor(params: ConvoParams) { ··· 90 90 this.agent = params.agent 91 91 this.events = params.events 92 92 this.senderUserDid = params.agent.session?.did! 93 + 94 + if (params.placeholderData) { 95 + this.setupPlaceholderData(params.placeholderData) 96 + } 93 97 94 98 this.subscribe = this.subscribe.bind(this) 95 99 this.getSnapshot = this.getSnapshot.bind(this) ··· 131 135 return { 132 136 status: ConvoStatus.Initializing, 133 137 items: [], 134 - convo: undefined, 138 + convo: this.convo, 135 139 error: undefined, 136 - sender: undefined, 137 - recipients: undefined, 140 + sender: this.sender, 141 + recipients: this.recipients, 138 142 isFetchingHistory: this.isFetchingHistory, 139 143 deleteMessage: undefined, 140 144 sendMessage: undefined, ··· 176 180 return { 177 181 status: ConvoStatus.Uninitialized, 178 182 items: [], 179 - convo: undefined, 183 + convo: this.convo, 180 184 error: undefined, 181 - sender: undefined, 182 - recipients: undefined, 185 + sender: this.sender, 186 + recipients: this.recipients, 183 187 isFetchingHistory: false, 184 188 deleteMessage: undefined, 185 189 sendMessage: undefined, ··· 422 426 this.fetchMessageHistoryError = undefined 423 427 this.commit() 424 428 } 429 + } 430 + 431 + /** 432 + * Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo, 433 + * but this allows us to render the convo header immediately. 434 + */ 435 + private setupPlaceholderData( 436 + data: NonNullable<ConvoParams['placeholderData']>, 437 + ) { 438 + this.convo = data.convo 439 + this.sender = data.convo.members.find(m => m.did === this.senderUserDid) 440 + this.recipients = data.convo.members.filter( 441 + m => m.did !== this.senderUserDid, 442 + ) 425 443 } 426 444 427 445 private async setup() {
+16 -9
src/state/messages/convo/index.tsx
··· 1 1 import React, {useContext, useState, useSyncExternalStore} from 'react' 2 + import {ChatBskyConvoDefs} from '@atproto/api' 2 3 import {useFocusEffect} from '@react-navigation/native' 3 4 import {useQueryClient} from '@tanstack/react-query' 4 5 ··· 14 15 } from '#/state/messages/convo/types' 15 16 import {isConvoActive} from '#/state/messages/convo/util' 16 17 import {useMessagesEventBus} from '#/state/messages/events' 17 - import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' 18 + import { 19 + RQKEY as getConvoKey, 20 + useMarkAsReadMutation, 21 + } from '#/state/queries/messages/conversation' 18 22 import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations' 19 23 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' 20 24 import {useAgent} from '#/state/session' ··· 60 64 const queryClient = useQueryClient() 61 65 const agent = useAgent() 62 66 const events = useMessagesEventBus() 63 - const [convo] = useState( 64 - () => 65 - new Convo({ 66 - convoId, 67 - agent, 68 - events, 69 - }), 70 - ) 67 + const [convo] = useState(() => { 68 + const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 69 + getConvoKey(convoId), 70 + ) 71 + return new Convo({ 72 + convoId, 73 + agent, 74 + events, 75 + placeholderData: placeholder ? {convo: placeholder} : undefined, 76 + }) 77 + }) 71 78 const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) 72 79 const {mutate: markAsRead} = useMarkAsReadMutation() 73 80
+9 -6
src/state/messages/convo/types.ts
··· 11 11 convoId: string 12 12 agent: BskyAgent 13 13 events: MessagesEventBus 14 + placeholderData?: { 15 + convo: ChatBskyConvoDefs.ConvoView 16 + } 14 17 } 15 18 16 19 export enum ConvoStatus { ··· 142 145 export type ConvoStateUninitialized = { 143 146 status: ConvoStatus.Uninitialized 144 147 items: [] 145 - convo: undefined 148 + convo: ChatBskyConvoDefs.ConvoView | undefined 146 149 error: undefined 147 - sender: undefined 148 - recipients: undefined 150 + sender: AppBskyActorDefs.ProfileViewBasic | undefined 151 + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined 149 152 isFetchingHistory: false 150 153 deleteMessage: undefined 151 154 sendMessage: undefined ··· 154 157 export type ConvoStateInitializing = { 155 158 status: ConvoStatus.Initializing 156 159 items: [] 157 - convo: undefined 160 + convo: ChatBskyConvoDefs.ConvoView | undefined 158 161 error: undefined 159 - sender: undefined 160 - recipients: undefined 162 + sender: AppBskyActorDefs.ProfileViewBasic | undefined 163 + recipients: AppBskyActorDefs.ProfileViewBasic[] | undefined 161 164 isFetchingHistory: boolean 162 165 deleteMessage: undefined 163 166 sendMessage: undefined
+14 -2
src/state/queries/messages/conversation.ts
··· 1 1 import {ChatBskyConvoDefs} from '@atproto/api' 2 - import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 2 + import { 3 + QueryClient, 4 + useMutation, 5 + useQuery, 6 + useQueryClient, 7 + } from '@tanstack/react-query' 3 8 4 9 import {STALE} from '#/state/queries' 5 10 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' ··· 20 25 return useQuery({ 21 26 queryKey: RQKEY(convo.id), 22 27 queryFn: async () => { 23 - const {data} = await agent.api.chat.bsky.convo.getConvo( 28 + const {data} = await agent.chat.bsky.convo.getConvo( 24 29 {convoId: convo.id}, 25 30 {headers: DM_SERVICE_HEADERS}, 26 31 ) ··· 29 34 initialData: convo, 30 35 staleTime: STALE.INFINITY, 31 36 }) 37 + } 38 + 39 + export function precacheConvoQuery( 40 + queryClient: QueryClient, 41 + convo: ChatBskyConvoDefs.ConvoView, 42 + ) { 43 + queryClient.setQueryData(RQKEY(convo.id), convo) 32 44 } 33 45 34 46 export function useMarkAsReadMutation() {