Bluesky app fork with some witchin' additions 💫

Post source handling updates (#8472)

* Add debugs

* Key post-source using URI with handle

* Enhance

* EnHANCE

* ENHANCE

* ENHANCEEEECEE

* ᵉⁿʰᵃⁿᶜᵉ

* enhance

authored by

Eric Bailey and committed by
GitHub
143d5f3b 7341294d

+144 -81
+8 -11
src/App.native.tsx
··· 58 58 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 59 59 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 60 60 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 61 - import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' 62 61 import {TestCtrls} from '#/view/com/testing/TestCtrls' 63 62 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 64 63 import * as Toast from '#/view/com/util/Toast' ··· 151 150 <MutedThreadsProvider> 152 151 <ProgressGuideProvider> 153 152 <ServiceAccountManager> 154 - <UnstablePostSourceProvider> 155 - <GestureHandlerRootView 156 - style={s.h100pct}> 157 - <IntentDialogProvider> 158 - <TestCtrls /> 159 - <Shell /> 160 - <NuxDialogs /> 161 - </IntentDialogProvider> 162 - </GestureHandlerRootView> 163 - </UnstablePostSourceProvider> 153 + <GestureHandlerRootView 154 + style={s.h100pct}> 155 + <IntentDialogProvider> 156 + <TestCtrls /> 157 + <Shell /> 158 + <NuxDialogs /> 159 + </IntentDialogProvider> 160 + </GestureHandlerRootView> 164 161 </ServiceAccountManager> 165 162 </ProgressGuideProvider> 166 163 </MutedThreadsProvider>
+4 -7
src/App.web.tsx
··· 48 48 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 49 49 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 50 50 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 51 - import {Provider as UnstablePostSourceProvider} from '#/state/unstable-post-source' 52 51 import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' 53 52 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 54 53 import * as Toast from '#/view/com/util/Toast' ··· 132 131 <SafeAreaProvider> 133 132 <ProgressGuideProvider> 134 133 <ServiceConfigProvider> 135 - <UnstablePostSourceProvider> 136 - <IntentDialogProvider> 137 - <Shell /> 138 - <NuxDialogs /> 139 - </IntentDialogProvider> 140 - </UnstablePostSourceProvider> 134 + <IntentDialogProvider> 135 + <Shell /> 136 + <NuxDialogs /> 137 + </IntentDialogProvider> 141 138 </ServiceConfigProvider> 142 139 </ProgressGuideProvider> 143 140 </SafeAreaProvider>
+2
src/logger/types.ts
··· 10 10 ConversationAgent = 'conversation-agent', 11 11 DMsAgent = 'dms-agent', 12 12 ReportDialog = 'report-dialog', 13 + FeedFeedback = 'feed-feedback', 14 + PostSource = 'post-source', 13 15 14 16 /** 15 17 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+7 -1
src/state/feed-feedback.tsx
··· 12 12 13 13 import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 14 import {logEvent} from '#/lib/statsig/statsig' 15 - import {logger} from '#/logger' 15 + import {Logger} from '#/logger' 16 16 import { 17 17 type FeedDescriptor, 18 18 type FeedPostSliceItem, 19 19 } from '#/state/queries/post-feed' 20 20 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 21 21 import {useAgent} from './session' 22 + 23 + const logger = Logger.create(Logger.Context.FeedFeedback) 22 24 23 25 export type StateContext = { 24 26 enabled: boolean ··· 89 91 } 90 92 sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) 91 93 throttledFlushAggregatedStats() 94 + logger.debug('flushed') 92 95 }, [agent, throttledFlushAggregatedStats, feed]) 93 96 94 97 const sendToFeed = useMemo( ··· 141 144 if (!enabled) { 142 145 return 143 146 } 147 + logger.debug('sendInteraction', { 148 + ...interaction, 149 + }) 144 150 if (!history.current.has(interaction)) { 145 151 history.current.add(interaction) 146 152 queue.current.add(toString(interaction))
+74 -39
src/state/unstable-post-source.tsx
··· 1 - import {createContext, useCallback, useContext, useRef, useState} from 'react' 2 - import {type AppBskyFeedDefs} from '@atproto/api' 1 + import {useEffect, useId, useState} from 'react' 2 + import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 3 3 4 - import {type FeedDescriptor} from './queries/post-feed' 4 + import {Logger} from '#/logger' 5 + import {type FeedDescriptor} from '#/state/queries/post-feed' 5 6 6 7 /** 7 - * For passing the source of the post (i.e. the original post, from the feed) to the threadview, 8 - * without using query params. Deliberately unstable to avoid using query params, use for FeedFeedback 9 - * and other ephemeral non-critical systems. 8 + * Separate logger for better debugging 10 9 */ 10 + const logger = Logger.create(Logger.Context.PostSource) 11 11 12 - type Source = { 12 + export type PostSource = { 13 13 post: AppBskyFeedDefs.FeedViewPost 14 14 feed?: FeedDescriptor 15 15 } 16 16 17 - const SetUnstablePostSourceContext = createContext< 18 - (key: string, source: Source) => void 19 - >(() => {}) 20 - const ConsumeUnstablePostSourceContext = createContext< 21 - (uri: string) => Source | undefined 22 - >(() => undefined) 17 + /** 18 + * A cache of sources that will be consumed by the post thread view. This is 19 + * cleaned up any time a source is consumed. 20 + */ 21 + const transientSources = new Map<string, PostSource>() 23 22 24 - export function Provider({children}: {children: React.ReactNode}) { 25 - const sourcesRef = useRef<Map<string, Source>>(new Map()) 23 + /** 24 + * A cache of sources that have been consumed by the post thread view. This is 25 + * not cleaned up, but because we use a new ID for each post thread view that 26 + * consumes a source, this is never reused unless a user navigates back to a 27 + * post thread view that has not been dropped from memory. 28 + */ 29 + const consumedSources = new Map<string, PostSource>() 26 30 27 - const setUnstablePostSource = useCallback((key: string, source: Source) => { 28 - sourcesRef.current.set(key, source) 29 - }, []) 31 + /** 32 + * For stashing the feed that the user was browsing when they clicked on a post. 33 + * 34 + * Used for FeedFeedback and other ephemeral non-critical systems. 35 + */ 36 + export function setUnstablePostSource(key: string, source: PostSource) { 37 + assertValid( 38 + key, 39 + `setUnstablePostSource key should be a URI containing a handle, received ${key} — use buildPostSourceKey`, 40 + ) 41 + logger.debug('set', {key, source}) 42 + transientSources.set(key, source) 43 + } 30 44 31 - const consumeUnstablePostSource = useCallback((uri: string) => { 32 - const source = sourcesRef.current.get(uri) 45 + /** 46 + * This hook is unstable and should only be used for FeedFeedback and other 47 + * ephemeral non-critical systems. Views that use this hook will continue to 48 + * return a reference to the same source until those views are dropped from 49 + * memory. 50 + */ 51 + export function useUnstablePostSource(key: string) { 52 + const id = useId() 53 + const [source] = useState(() => { 54 + assertValid( 55 + key, 56 + `consumeUnstablePostSource key should be a URI containing a handle, received ${key} — use buildPostSourceKey`, 57 + ) 58 + const source = consumedSources.get(id) || transientSources.get(key) 33 59 if (source) { 34 - sourcesRef.current.delete(uri) 60 + logger.debug('consume', {id, key, source}) 61 + transientSources.delete(key) 62 + consumedSources.set(id, source) 35 63 } 36 64 return source 37 - }, []) 65 + }) 38 66 39 - return ( 40 - <SetUnstablePostSourceContext.Provider value={setUnstablePostSource}> 41 - <ConsumeUnstablePostSourceContext.Provider 42 - value={consumeUnstablePostSource}> 43 - {children} 44 - </ConsumeUnstablePostSourceContext.Provider> 45 - </SetUnstablePostSourceContext.Provider> 46 - ) 47 - } 67 + useEffect(() => { 68 + return () => { 69 + consumedSources.delete(id) 70 + logger.debug('cleanup', {id}) 71 + } 72 + }, [id]) 48 73 49 - export function useSetUnstablePostSource() { 50 - return useContext(SetUnstablePostSourceContext) 74 + return source 51 75 } 52 76 53 77 /** 54 - * DANGER - This hook is unstable and should only be used for FeedFeedback 55 - * and other ephemeral non-critical systems. Does not change when the URI changes. 78 + * Builds a post source key. This (atm) is a URI where the `host` is the post 79 + * author's handle, not DID. 56 80 */ 57 - export function useUnstablePostSource(uri: string) { 58 - const consume = useContext(ConsumeUnstablePostSourceContext) 81 + export function buildPostSourceKey(key: string, handle: string) { 82 + const urip = new AtUri(key) 83 + urip.host = handle 84 + return urip.toString() 85 + } 59 86 60 - const [source] = useState(() => consume(uri)) 61 - return source 87 + /** 88 + * Just a lil dev helper 89 + */ 90 + function assertValid(key: string, message: string) { 91 + if (__DEV__) { 92 + const urip = new AtUri(key) 93 + if (urip.host.startsWith('did:')) { 94 + throw new Error(message) 95 + } 96 + } 62 97 }
+25 -5
src/view/com/post-thread/PostThread.tsx
··· 22 22 import {sanitizeDisplayName} from '#/lib/strings/display-names' 23 23 import {cleanError} from '#/lib/strings/errors' 24 24 import {isAndroid, isNative, isWeb} from '#/platform/detection' 25 + import {useFeedFeedback} from '#/state/feed-feedback' 25 26 import {useModerationOpts} from '#/state/preferences/moderation-opts' 26 27 import { 27 28 fillThreadModerationCache, ··· 37 38 import {usePreferencesQuery} from '#/state/queries/preferences' 38 39 import {useSession} from '#/state/session' 39 40 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 41 + import {useUnstablePostSource} from '#/state/unstable-post-source' 40 42 import {List, type ListMethods} from '#/view/com/util/List' 41 43 import {atoms as a, useTheme} from '#/alf' 42 44 import {Button, ButtonIcon} from '#/components/Button' ··· 93 95 return item._reactKey 94 96 } 95 97 96 - export function PostThread({uri}: {uri: string | undefined}) { 98 + export function PostThread({uri}: {uri: string}) { 97 99 const {hasSession, currentAccount} = useSession() 98 100 const {_} = useLingui() 99 101 const t = useTheme() ··· 104 106 HiddenRepliesState.Hide, 105 107 ) 106 108 const headerRef = React.useRef<View | null>(null) 109 + const anchorPostSource = useUnstablePostSource(uri) 110 + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 107 111 108 112 const {data: preferences} = usePreferencesQuery() 109 113 const { ··· 395 399 ) 396 400 397 401 const {openComposer} = useOpenComposer() 398 - const onPressReply = React.useCallback(() => { 402 + const onReplyToAnchor = React.useCallback(() => { 399 403 if (thread?.type !== 'post') { 400 404 return 401 405 } 406 + if (anchorPostSource) { 407 + feedFeedback.sendInteraction({ 408 + item: thread.post.uri, 409 + event: 'app.bsky.feed.defs#interactionReply', 410 + feedContext: anchorPostSource.post.feedContext, 411 + reqId: anchorPostSource.post.reqId, 412 + }) 413 + } 402 414 openComposer({ 403 415 replyTo: { 404 416 uri: thread.post.uri, ··· 410 422 }, 411 423 onPost: onPostReply, 412 424 }) 413 - }, [openComposer, thread, onPostReply, threadModerationCache]) 425 + }, [ 426 + openComposer, 427 + thread, 428 + onPostReply, 429 + threadModerationCache, 430 + anchorPostSource, 431 + feedFeedback, 432 + ]) 414 433 415 434 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled 416 435 const hasParents = ··· 423 442 return ( 424 443 <View> 425 444 {!isMobile && ( 426 - <PostThreadComposePrompt onPressCompose={onPressReply} /> 445 + <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> 427 446 )} 428 447 </View> 429 448 ) ··· 511 530 } 512 531 onPostReply={onPostReply} 513 532 hideTopBorder={index === 0 && !item.ctx.isParentLoading} 533 + anchorPostSource={anchorPostSource} 514 534 /> 515 535 </View> 516 536 ) ··· 586 606 /> 587 607 </ScrollProvider> 588 608 {isMobile && canReply && hasSession && ( 589 - <MobileComposePrompt onPressReply={onPressReply} /> 609 + <MobileComposePrompt onPressReply={onReplyToAnchor} /> 590 610 )} 591 611 </> 592 612 )
+19 -15
src/view/com/post-thread/PostThreadItem.tsx
··· 40 40 import {type ThreadPost} from '#/state/queries/post-thread' 41 41 import {useSession} from '#/state/session' 42 42 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 43 - import {useUnstablePostSource} from '#/state/unstable-post-source' 43 + import {type PostSource} from '#/state/unstable-post-source' 44 44 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 45 45 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 46 46 import {Link, TextLink} from '#/view/com/util/Link' ··· 87 87 onPostReply, 88 88 hideTopBorder, 89 89 threadgateRecord, 90 + anchorPostSource, 90 91 }: { 91 92 post: AppBskyFeedDefs.PostView 92 93 record: AppBskyFeedPost.Record ··· 104 105 onPostReply: (postUri: string | undefined) => void 105 106 hideTopBorder?: boolean 106 107 threadgateRecord?: AppBskyFeedThreadgate.Record 108 + anchorPostSource?: PostSource 107 109 }) { 108 110 const postShadowed = usePostShadow(post) 109 111 const richText = useMemo( ··· 139 141 onPostReply={onPostReply} 140 142 hideTopBorder={hideTopBorder} 141 143 threadgateRecord={threadgateRecord} 144 + anchorPostSource={anchorPostSource} 142 145 /> 143 146 ) 144 147 } ··· 184 187 onPostReply, 185 188 hideTopBorder, 186 189 threadgateRecord, 190 + anchorPostSource, 187 191 }: { 188 192 post: Shadow<AppBskyFeedDefs.PostView> 189 193 record: AppBskyFeedPost.Record ··· 202 206 onPostReply: (postUri: string | undefined) => void 203 207 hideTopBorder?: boolean 204 208 threadgateRecord?: AppBskyFeedThreadgate.Record 209 + anchorPostSource?: PostSource 205 210 }): React.ReactNode => { 206 211 const {currentAccount, hasSession} = useSession() 207 - const source = useUnstablePostSource(post.uri) 208 - const feedFeedback = useFeedFeedback(source?.feed, hasSession) 212 + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 209 213 210 214 const t = useTheme() 211 215 const pal = usePalette('default') ··· 276 280 ) 277 281 278 282 const onPressReply = () => { 279 - if (source) { 283 + if (anchorPostSource && isHighlightedPost) { 280 284 feedFeedback.sendInteraction({ 281 285 item: post.uri, 282 286 event: 'app.bsky.feed.defs#interactionReply', 283 - feedContext: source.post.feedContext, 284 - reqId: source.post.reqId, 287 + feedContext: anchorPostSource.post.feedContext, 288 + reqId: anchorPostSource.post.reqId, 285 289 }) 286 290 } 287 291 openComposer({ ··· 298 302 } 299 303 300 304 const onOpenAuthor = () => { 301 - if (source) { 305 + if (anchorPostSource) { 302 306 feedFeedback.sendInteraction({ 303 307 item: post.uri, 304 308 event: 'app.bsky.feed.defs#clickthroughAuthor', 305 - feedContext: source.post.feedContext, 306 - reqId: source.post.reqId, 309 + feedContext: anchorPostSource.post.feedContext, 310 + reqId: anchorPostSource.post.reqId, 307 311 }) 308 312 } 309 313 } 310 314 311 315 const onOpenEmbed = () => { 312 - if (source) { 316 + if (anchorPostSource) { 313 317 feedFeedback.sendInteraction({ 314 318 item: post.uri, 315 319 event: 'app.bsky.feed.defs#clickthroughEmbed', 316 - feedContext: source.post.feedContext, 317 - reqId: source.post.reqId, 320 + feedContext: anchorPostSource.post.feedContext, 321 + reqId: anchorPostSource.post.reqId, 318 322 }) 319 323 } 320 324 } ··· 325 329 326 330 const {isActive: live} = useActorStatus(post.author) 327 331 328 - const reason = source?.post.reason 332 + const reason = anchorPostSource?.post.reason 329 333 const viaRepost = useMemo(() => { 330 334 if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 331 335 return { ··· 550 554 onPostReply={onPostReply} 551 555 logContext="PostThreadItem" 552 556 threadgateRecord={threadgateRecord} 553 - feedContext={source?.post?.feedContext} 554 - reqId={source?.post?.reqId} 557 + feedContext={anchorPostSource?.post?.feedContext} 558 + reqId={anchorPostSource?.post?.reqId} 555 559 viaRepost={viaRepost} 556 560 /> 557 561 </FeedFeedbackProvider>
+5 -3
src/view/com/posts/PostFeedItem.tsx
··· 36 36 import {unstableCacheProfileView} from '#/state/queries/profile' 37 37 import {useSession} from '#/state/session' 38 38 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 39 - import {useSetUnstablePostSource} from '#/state/unstable-post-source' 39 + import { 40 + buildPostSourceKey, 41 + setUnstablePostSource, 42 + } from '#/state/unstable-post-source' 40 43 import {FeedNameText} from '#/view/com/util/FeedInfoText' 41 44 import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' 42 45 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' ··· 176 179 return makeProfileLink(post.author, 'post', urip.rkey) 177 180 }, [post.uri, post.author]) 178 181 const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() 179 - const unstableSetPostSource = useSetUnstablePostSource() 180 182 181 183 const onPressReply = () => { 182 184 sendInteraction({ ··· 232 234 reqId, 233 235 }) 234 236 unstableCacheProfileView(queryClient, post.author) 235 - unstableSetPostSource(post.uri, { 237 + setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 236 238 feed: feedDescriptor, 237 239 post: { 238 240 post,