Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Send FeedFeedback interactions in thread view (#8414)

authored by

Samuel Newman and committed by
GitHub
cf63c2ca 665a0430

+363 -122
+2 -2
package.json
··· 69 69 "icons:optimize": "svgo -f ./assets/icons" 70 70 }, 71 71 "dependencies": { 72 - "@atproto/api": "^0.15.8", 72 + "@atproto/api": "^0.15.9", 73 73 "@bitdrift/react-native": "^0.6.8", 74 74 "@braintree/sanitize-url": "^6.0.2", 75 75 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", ··· 219 219 "zod": "^3.20.2" 220 220 }, 221 221 "devDependencies": { 222 - "@atproto/dev-env": "^0.3.132", 222 + "@atproto/dev-env": "^0.3.133", 223 223 "@babel/core": "^7.26.0", 224 224 "@babel/preset-env": "^7.26.0", 225 225 "@babel/runtime": "^7.26.0",
+11 -8
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' 61 62 import {TestCtrls} from '#/view/com/testing/TestCtrls' 62 63 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 63 64 import * as Toast from '#/view/com/util/Toast' ··· 150 151 <MutedThreadsProvider> 151 152 <ProgressGuideProvider> 152 153 <ServiceAccountManager> 153 - <GestureHandlerRootView 154 - style={s.h100pct}> 155 - <IntentDialogProvider> 156 - <TestCtrls /> 157 - <Shell /> 158 - <NuxDialogs /> 159 - </IntentDialogProvider> 160 - </GestureHandlerRootView> 154 + <UnstablePostSourceProvider> 155 + <GestureHandlerRootView 156 + style={s.h100pct}> 157 + <IntentDialogProvider> 158 + <TestCtrls /> 159 + <Shell /> 160 + <NuxDialogs /> 161 + </IntentDialogProvider> 162 + </GestureHandlerRootView> 163 + </UnstablePostSourceProvider> 161 164 </ServiceAccountManager> 162 165 </ProgressGuideProvider> 163 166 </MutedThreadsProvider>
+7 -4
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' 51 52 import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' 52 53 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 53 54 import * as Toast from '#/view/com/util/Toast' ··· 131 132 <SafeAreaProvider> 132 133 <ProgressGuideProvider> 133 134 <ServiceConfigProvider> 134 - <IntentDialogProvider> 135 - <Shell /> 136 - <NuxDialogs /> 137 - </IntentDialogProvider> 135 + <UnstablePostSourceProvider> 136 + <IntentDialogProvider> 137 + <Shell /> 138 + <NuxDialogs /> 139 + </IntentDialogProvider> 140 + </UnstablePostSourceProvider> 138 141 </ServiceConfigProvider> 139 142 </ProgressGuideProvider> 140 143 </SafeAreaProvider>
+8 -1
src/components/PostControls/index.tsx
··· 50 50 logContext, 51 51 threadgateRecord, 52 52 onShowLess, 53 + viaRepost, 53 54 }: { 54 55 big?: boolean 55 56 post: Shadow<AppBskyFeedDefs.PostView> ··· 63 64 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 64 65 threadgateRecord?: AppBskyFeedThreadgate.Record 65 66 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 67 + viaRepost?: {uri: string; cid: string} 66 68 }): React.ReactNode => { 67 69 const {_, i18n} = useLingui() 68 70 const {gtMobile} = useBreakpoints() 69 71 const {openComposer} = useOpenComposer() 70 - const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) 72 + const [queueLike, queueUnlike] = usePostLikeMutationQueue( 73 + post, 74 + viaRepost, 75 + logContext, 76 + ) 71 77 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 72 78 post, 79 + viaRepost, 73 80 logContext, 74 81 ) 75 82 const requireAuth = useRequireAuth()
+6 -1
src/screens/VideoFeed/index.tsx
··· 1023 1023 const {_} = useLingui() 1024 1024 const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null) 1025 1025 const playHaptic = useHaptics() 1026 - const [queueLike] = usePostLikeMutationQueue(post, 'ImmersiveVideo') 1026 + // TODO: implement viaRepost -sfn 1027 + const [queueLike] = usePostLikeMutationQueue( 1028 + post, 1029 + undefined, 1030 + 'ImmersiveVideo', 1031 + ) 1027 1032 const {sendInteraction} = useFeedFeedbackContext() 1028 1033 const {isPlaying} = useEvent(player, 'playingChange', { 1029 1034 isPlaying: player.playing,
+32 -19
src/state/feed-feedback.tsx
··· 1 - import React from 'react' 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useMemo, 7 + useRef, 8 + } from 'react' 2 9 import {AppState, type AppStateStatus} from 'react-native' 3 10 import {type AppBskyFeedDefs} from '@atproto/api' 4 11 import throttle from 'lodash.throttle' ··· 13 20 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 14 21 import {useAgent} from './session' 15 22 16 - type StateContext = { 23 + export type StateContext = { 17 24 enabled: boolean 18 25 onItemSeen: (item: any) => void 19 26 sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void 27 + feedDescriptor: FeedDescriptor | undefined 20 28 } 21 29 22 - const stateContext = React.createContext<StateContext>({ 30 + const stateContext = createContext<StateContext>({ 23 31 enabled: false, 24 32 onItemSeen: (_item: any) => {}, 25 33 sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {}, 34 + feedDescriptor: undefined, 26 35 }) 27 36 28 - export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { 37 + export function useFeedFeedback( 38 + feed: FeedDescriptor | undefined, 39 + hasSession: boolean, 40 + ) { 29 41 const agent = useAgent() 30 42 const enabled = isDiscoverFeed(feed) && hasSession 31 43 32 - const queue = React.useRef<Set<string>>(new Set()) 33 - const history = React.useRef< 44 + const queue = useRef<Set<string>>(new Set()) 45 + const history = useRef< 34 46 // Use a WeakSet so that we don't need to clear it. 35 47 // This assumes that referential identity of slice items maps 1:1 to feed (re)fetches. 36 48 WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 37 49 >(new WeakSet()) 38 50 39 - const aggregatedStats = React.useRef<AggregatedStats | null>(null) 40 - const throttledFlushAggregatedStats = React.useMemo( 51 + const aggregatedStats = useRef<AggregatedStats | null>(null) 52 + const throttledFlushAggregatedStats = useMemo( 41 53 () => 42 54 throttle(() => flushToStatsig(aggregatedStats.current), 45e3, { 43 55 leading: true, // The outer call is already throttled somewhat. ··· 46 58 [], 47 59 ) 48 60 49 - const sendToFeedNoDelay = React.useCallback(() => { 61 + const sendToFeedNoDelay = useCallback(() => { 50 62 const interactions = Array.from(queue.current).map(toInteraction) 51 63 queue.current.clear() 52 64 53 65 let proxyDid = 'did:web:discover.bsky.app' 54 - if (STAGING_FEEDS.includes(feed)) { 66 + if (STAGING_FEEDS.includes(feed ?? '')) { 55 67 proxyDid = 'did:web:algo.pop2.bsky.app' 56 68 } 57 69 ··· 79 91 throttledFlushAggregatedStats() 80 92 }, [agent, throttledFlushAggregatedStats, feed]) 81 93 82 - const sendToFeed = React.useMemo( 94 + const sendToFeed = useMemo( 83 95 () => 84 96 throttle(sendToFeedNoDelay, 10e3, { 85 97 leading: false, ··· 88 100 [sendToFeedNoDelay], 89 101 ) 90 102 91 - React.useEffect(() => { 103 + useEffect(() => { 92 104 if (!enabled) { 93 105 return 94 106 } ··· 100 112 return () => sub.remove() 101 113 }, [enabled, sendToFeed]) 102 114 103 - const onItemSeen = React.useCallback( 115 + const onItemSeen = useCallback( 104 116 (feedItem: any) => { 105 117 if (!enabled) { 106 118 return ··· 124 136 [enabled, sendToFeed], 125 137 ) 126 138 127 - const sendInteraction = React.useCallback( 139 + const sendInteraction = useCallback( 128 140 (interaction: AppBskyFeedDefs.Interaction) => { 129 141 if (!enabled) { 130 142 return ··· 138 150 [enabled, sendToFeed], 139 151 ) 140 152 141 - return React.useMemo(() => { 153 + return useMemo(() => { 142 154 return { 143 155 enabled, 144 156 // pass this method to the <List> onItemSeen ··· 146 158 // call on various events 147 159 // queues the event to be sent with the throttled sendToFeed call 148 160 sendInteraction, 161 + feedDescriptor: feed, 149 162 } 150 - }, [enabled, onItemSeen, sendInteraction]) 163 + }, [enabled, onItemSeen, sendInteraction, feed]) 151 164 } 152 165 153 166 export const FeedFeedbackProvider = stateContext.Provider 154 167 155 168 export function useFeedFeedbackContext() { 156 - return React.useContext(stateContext) 169 + return useContext(stateContext) 157 170 } 158 171 159 172 // TODO ··· 161 174 // take advantage of the feed feedback API. Until that's in 162 175 // place, we're hardcoding it to the discover feed. 163 176 // -prf 164 - function isDiscoverFeed(feed: FeedDescriptor) { 165 - return FEEDBACK_FEEDS.includes(feed) 177 + function isDiscoverFeed(feed?: FeedDescriptor) { 178 + return !!feed && FEEDBACK_FEEDS.includes(feed) 166 179 } 167 180 168 181 function toString(interaction: AppBskyFeedDefs.Interaction): string {
+2
src/state/queries/notifications/types.ts
··· 46 46 | 'feedgen-like' 47 47 | 'verified' 48 48 | 'unverified' 49 + | 'like-via-repost' 50 + | 'repost-via-repost' 49 51 | 'unknown' 50 52 51 53 type FeedNotificationBase = {
+9 -2
src/state/queries/notifications/util.ts
··· 244 244 notif.reason === 'follow' || 245 245 notif.reason === 'starterpack-joined' || 246 246 notif.reason === 'verified' || 247 - notif.reason === 'unverified' 247 + notif.reason === 'unverified' || 248 + notif.reason === 'like-via-repost' || 249 + notif.reason === 'repost-via-repost' 248 250 ) { 249 251 return notif.reason as NotificationType 250 252 } ··· 257 259 ): string | undefined { 258 260 if (type === 'reply' || type === 'quote' || type === 'mention') { 259 261 return notif.uri 260 - } else if (type === 'post-like' || type === 'repost') { 262 + } else if ( 263 + type === 'post-like' || 264 + type === 'repost' || 265 + type === 'like-via-repost' || 266 + type === 'repost-via-repost' 267 + ) { 261 268 if ( 262 269 bsky.dangerousIsType<AppBskyFeedRepost.Record>( 263 270 notif.record,
+13 -9
src/state/queries/post.ts
··· 1 1 import {useCallback} from 'react' 2 - import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api' 2 + import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api' 3 3 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 4 4 5 5 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 6 - import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' 6 + import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 7 7 import {updatePostShadow} from '#/state/cache/post-shadow' 8 - import {Shadow} from '#/state/cache/types' 8 + import {type Shadow} from '#/state/cache/types' 9 9 import {useAgent, useSession} from '#/state/session' 10 10 import * as userActionHistory from '#/state/userActionHistory' 11 11 import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' ··· 98 98 99 99 export function usePostLikeMutationQueue( 100 100 post: Shadow<AppBskyFeedDefs.PostView>, 101 + viaRepost: {uri: string; cid: string} | undefined, 101 102 logContext: LogEvents['post:like']['logContext'] & 102 103 LogEvents['post:unlike']['logContext'], 103 104 ) { ··· 115 116 const {uri: likeUri} = await likeMutation.mutateAsync({ 116 117 uri: postUri, 117 118 cid: postCid, 119 + via: viaRepost, 118 120 }) 119 121 userActionHistory.like([postUri]) 120 122 return likeUri ··· 167 169 return useMutation< 168 170 {uri: string}, // responds with the uri of the like 169 171 Error, 170 - {uri: string; cid: string} // the post's uri and cid 172 + {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present 171 173 >({ 172 - mutationFn: ({uri, cid}) => { 174 + mutationFn: ({uri, cid, via}) => { 173 175 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined 174 176 if (currentAccount) { 175 177 ownProfile = findProfileQueryData(queryClient, currentAccount.did) ··· 190 192 ? toClout(post.likeCount + post.repostCount + post.replyCount) 191 193 : undefined, 192 194 }) 193 - return agent.like(uri, cid) 195 + return agent.like(uri, cid, via) 194 196 }, 195 197 }) 196 198 } ··· 209 211 210 212 export function usePostRepostMutationQueue( 211 213 post: Shadow<AppBskyFeedDefs.PostView>, 214 + viaRepost: {uri: string; cid: string} | undefined, 212 215 logContext: LogEvents['post:repost']['logContext'] & 213 216 LogEvents['post:unrepost']['logContext'], 214 217 ) { ··· 226 229 const {uri: repostUri} = await repostMutation.mutateAsync({ 227 230 uri: postUri, 228 231 cid: postCid, 232 + via: viaRepost, 229 233 }) 230 234 return repostUri 231 235 } else { ··· 272 276 return useMutation< 273 277 {uri: string}, // responds with the uri of the repost 274 278 Error, 275 - {uri: string; cid: string} // the post's uri and cid 279 + {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present 276 280 >({ 277 - mutationFn: post => { 281 + mutationFn: ({uri, cid, via}) => { 278 282 logEvent('post:repost', {logContext}) 279 - return agent.repost(post.uri, post.cid) 283 + return agent.repost(uri, cid, via) 280 284 }, 281 285 }) 282 286 }
+73
src/state/unstable-post-source.tsx
··· 1 + import {createContext, useCallback, useContext, useState} from 'react' 2 + import {type AppBskyFeedDefs} from '@atproto/api' 3 + 4 + import {type FeedDescriptor} from './queries/post-feed' 5 + 6 + /** 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. 10 + */ 11 + 12 + type Source = { 13 + post: AppBskyFeedDefs.FeedViewPost 14 + feed?: FeedDescriptor 15 + } 16 + 17 + const SetUnstablePostSourceContext = createContext< 18 + (key: string, source: Source) => void 19 + >(() => {}) 20 + const ConsumeUnstablePostSourceContext = createContext< 21 + (uri: string) => Source | undefined 22 + >(() => undefined) 23 + 24 + export function Provider({children}: {children: React.ReactNode}) { 25 + const [sources, setSources] = useState<Map<string, Source>>(() => new Map()) 26 + 27 + const setUnstablePostSource = useCallback((key: string, source: Source) => { 28 + setSources(prev => { 29 + const newMap = new Map(prev) 30 + newMap.set(key, source) 31 + return newMap 32 + }) 33 + }, []) 34 + 35 + const consumeUnstablePostSource = useCallback( 36 + (uri: string) => { 37 + const source = sources.get(uri) 38 + if (source) { 39 + setSources(prev => { 40 + const newMap = new Map(prev) 41 + newMap.delete(uri) 42 + return newMap 43 + }) 44 + } 45 + return source 46 + }, 47 + [sources], 48 + ) 49 + 50 + return ( 51 + <SetUnstablePostSourceContext.Provider value={setUnstablePostSource}> 52 + <ConsumeUnstablePostSourceContext.Provider 53 + value={consumeUnstablePostSource}> 54 + {children} 55 + </ConsumeUnstablePostSourceContext.Provider> 56 + </SetUnstablePostSourceContext.Provider> 57 + ) 58 + } 59 + 60 + export function useSetUnstablePostSource() { 61 + return useContext(SetUnstablePostSourceContext) 62 + } 63 + 64 + /** 65 + * DANGER - This hook is unstable and should only be used for FeedFeedback 66 + * and other ephemeral non-critical systems. Does not change when the URI changes. 67 + */ 68 + export function useUnstablePostSource(uri: string) { 69 + const consume = useContext(ConsumeUnstablePostSourceContext) 70 + 71 + const [source] = useState(() => consume(uri)) 72 + return source 73 + }
+2 -2
src/view/com/notifications/NotificationFeed.tsx
··· 1 1 import React from 'react' 2 2 import { 3 3 ActivityIndicator, 4 - ListRenderItemInfo, 4 + type ListRenderItemInfo, 5 5 StyleSheet, 6 6 View, 7 7 } from 'react-native' ··· 16 16 import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' 17 17 import {EmptyState} from '#/view/com/util/EmptyState' 18 18 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 19 - import {List, ListRef} from '#/view/com/util/List' 19 + import {List, type ListRef} from '#/view/com/util/List' 20 20 import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 21 21 import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' 22 22 import {NotificationFeedItem} from './NotificationFeedItem'
+53 -1
src/view/com/notifications/NotificationFeedItem.tsx
··· 446 446 </Trans> 447 447 ) 448 448 icon = <VerifiedCheck size="xl" fill={t.palette.contrast_500} /> 449 + } else if (item.type === 'like-via-repost') { 450 + a11yLabel = hasMultipleAuthors 451 + ? _( 452 + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 453 + one: `${formattedAuthorsCount} other`, 454 + other: `${formattedAuthorsCount} others`, 455 + })} liked your repost`, 456 + ) 457 + : _(msg`${firstAuthorName} liked your repost`) 458 + notificationContent = hasMultipleAuthors ? ( 459 + <Trans> 460 + {firstAuthorLink} and{' '} 461 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 462 + <Plural 463 + value={additionalAuthorsCount} 464 + one={`${formattedAuthorsCount} other`} 465 + other={`${formattedAuthorsCount} others`} 466 + /> 467 + </Text>{' '} 468 + liked your repost 469 + </Trans> 470 + ) : ( 471 + <Trans>{firstAuthorLink} liked your repost</Trans> 472 + ) 473 + } else if (item.type === 'repost-via-repost') { 474 + a11yLabel = hasMultipleAuthors 475 + ? _( 476 + msg`${firstAuthorName} and ${plural(additionalAuthorsCount, { 477 + one: `${formattedAuthorsCount} other`, 478 + other: `${formattedAuthorsCount} others`, 479 + })} reposted your repost`, 480 + ) 481 + : _(msg`${firstAuthorName} reposted your repost`) 482 + notificationContent = hasMultipleAuthors ? ( 483 + <Trans> 484 + {firstAuthorLink} and{' '} 485 + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> 486 + <Plural 487 + value={additionalAuthorsCount} 488 + one={`${formattedAuthorsCount} other`} 489 + other={`${formattedAuthorsCount} others`} 490 + /> 491 + </Text>{' '} 492 + reposted your repost 493 + </Trans> 494 + ) : ( 495 + <Trans>{firstAuthorLink} reposted your repost</Trans> 496 + ) 497 + icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} /> 449 498 } else { 450 499 return null 451 500 } ··· 553 602 </TimeElapsed> 554 603 </Text> 555 604 </ExpandListPressable> 556 - {item.type === 'post-like' || item.type === 'repost' ? ( 605 + {item.type === 'post-like' || 606 + item.type === 'repost' || 607 + item.type === 'like-via-repost' || 608 + item.type === 'repost-via-repost' ? ( 557 609 <View style={[a.pt_2xs]}> 558 610 <AdditionalPostText post={item.subject} /> 559 611 </View>
+79 -28
src/view/com/post-thread/PostThreadItem.tsx
··· 1 - import React, {memo, useMemo} from 'react' 1 + import {memo, useCallback, useMemo, useState} from 'react' 2 2 import { 3 3 type GestureResponderEvent, 4 4 StyleSheet, ··· 6 6 View, 7 7 } from 'react-native' 8 8 import { 9 - type AppBskyFeedDefs, 9 + AppBskyFeedDefs, 10 10 AppBskyFeedPost, 11 11 type AppBskyFeedThreadgate, 12 12 AtUri, ··· 35 35 usePostShadow, 36 36 } from '#/state/cache/post-shadow' 37 37 import {useProfileShadow} from '#/state/cache/profile-shadow' 38 + import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 38 39 import {useLanguagePrefs} from '#/state/preferences' 39 40 import {type ThreadPost} from '#/state/queries/post-thread' 40 41 import {useSession} from '#/state/session' 41 42 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 43 + import {useUnstablePostSource} from '#/state/unstable-post-source' 42 44 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 43 45 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 44 46 import {Link, TextLink} from '#/view/com/util/Link' ··· 201 203 hideTopBorder?: boolean 202 204 threadgateRecord?: AppBskyFeedThreadgate.Record 203 205 }): React.ReactNode => { 206 + const {currentAccount, hasSession} = useSession() 207 + const source = useUnstablePostSource(post.uri) 208 + const feedFeedback = useFeedFeedback(source?.feed, hasSession) 209 + 204 210 const t = useTheme() 205 211 const pal = usePalette('default') 206 212 const {_, i18n} = useLingui() 207 213 const langPrefs = useLanguagePrefs() 208 214 const {openComposer} = useOpenComposer() 209 - const [limitLines, setLimitLines] = React.useState( 215 + const [limitLines, setLimitLines] = useState( 210 216 () => countLines(richText?.text) >= MAX_POST_LINES, 211 217 ) 212 - const {currentAccount} = useSession() 213 218 const shadowedPostAuthor = useProfileShadow(post.author) 214 219 const rootUri = record.reply?.root?.uri || post.uri 215 - const postHref = React.useMemo(() => { 220 + const postHref = useMemo(() => { 216 221 const urip = new AtUri(post.uri) 217 222 return makeProfileLink(post.author, 'post', urip.rkey) 218 223 }, [post.uri, post.author]) ··· 220 225 const authorHref = makeProfileLink(post.author) 221 226 const authorTitle = post.author.handle 222 227 const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 223 - const likesHref = React.useMemo(() => { 228 + const likesHref = useMemo(() => { 224 229 const urip = new AtUri(post.uri) 225 230 return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 226 231 }, [post.uri, post.author]) 227 232 const likesTitle = _(msg`Likes on this post`) 228 - const repostsHref = React.useMemo(() => { 233 + const repostsHref = useMemo(() => { 229 234 const urip = new AtUri(post.uri) 230 235 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 231 236 }, [post.uri, post.author]) ··· 233 238 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 234 239 threadgateRecord, 235 240 }) 236 - const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { 241 + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 237 242 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 238 243 const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did 239 244 return isControlledByViewer && isPostHiddenByThreadgate ··· 246 251 ] 247 252 : [] 248 253 }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri]) 249 - const quotesHref = React.useMemo(() => { 254 + const quotesHref = useMemo(() => { 250 255 const urip = new AtUri(post.uri) 251 256 return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 252 257 }, [post.uri, post.author]) ··· 270 275 [post, langPrefs.primaryLanguage], 271 276 ) 272 277 273 - const onPressReply = React.useCallback(() => { 278 + const onPressReply = () => { 279 + if (source) { 280 + feedFeedback.sendInteraction({ 281 + item: post.uri, 282 + event: 'app.bsky.feed.defs#interactionReply', 283 + feedContext: source.post.feedContext, 284 + reqId: source.post.reqId, 285 + }) 286 + } 274 287 openComposer({ 275 288 replyTo: { 276 289 uri: post.uri, ··· 282 295 }, 283 296 onPost: onPostReply, 284 297 }) 285 - }, [openComposer, post, record, onPostReply, moderation]) 298 + } 299 + 300 + const onOpenAuthor = () => { 301 + if (source) { 302 + feedFeedback.sendInteraction({ 303 + item: post.uri, 304 + event: 'app.bsky.feed.defs#clickthroughAuthor', 305 + feedContext: source.post.feedContext, 306 + reqId: source.post.reqId, 307 + }) 308 + } 309 + } 310 + 311 + const onOpenEmbed = () => { 312 + if (source) { 313 + feedFeedback.sendInteraction({ 314 + item: post.uri, 315 + event: 'app.bsky.feed.defs#clickthroughEmbed', 316 + feedContext: source.post.feedContext, 317 + reqId: source.post.reqId, 318 + }) 319 + } 320 + } 286 321 287 - const onPressShowMore = React.useCallback(() => { 322 + const onPressShowMore = useCallback(() => { 288 323 setLimitLines(false) 289 324 }, [setLimitLines]) 290 325 291 326 const {isActive: live} = useActorStatus(post.author) 292 327 328 + const reason = source?.post.reason 329 + const viaRepost = useMemo(() => { 330 + if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 331 + return { 332 + uri: reason.uri, 333 + cid: reason.cid, 334 + } 335 + } 336 + }, [reason]) 337 + 293 338 if (!record) { 294 339 return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} /> 295 340 } ··· 309 354 <View 310 355 style={[ 311 356 styles.replyLine, 312 - { 313 - flexGrow: 1, 314 - backgroundColor: pal.colors.replyLine, 315 - }, 357 + a.flex_grow, 358 + {backgroundColor: pal.colors.replyLine}, 316 359 ]} 317 360 /> 318 361 </View> ··· 334 377 moderation={moderation.ui('avatar')} 335 378 type={post.author.associated?.labeler ? 'labeler' : 'user'} 336 379 live={live} 380 + onBeforePress={onOpenAuthor} 337 381 /> 338 382 <View style={[a.flex_1]}> 339 383 <View style={[a.flex_row, a.align_center]}> 340 384 <Link 341 385 style={[a.flex_shrink]} 342 386 href={authorHref} 343 - title={authorTitle}> 387 + title={authorTitle} 388 + onBeforePress={onOpenAuthor}> 344 389 <Text 345 390 emoji 346 391 style={[ ··· 413 458 embed={post.embed} 414 459 moderation={moderation} 415 460 viewContext={PostEmbedViewContext.ThreadHighlighted} 461 + onOpen={onOpenEmbed} 416 462 /> 417 463 </View> 418 464 )} ··· 494 540 marginLeft: -5, 495 541 }, 496 542 ]}> 497 - <PostControls 498 - big 499 - post={post} 500 - record={record} 501 - richText={richText} 502 - onPressReply={onPressReply} 503 - onPostReply={onPostReply} 504 - logContext="PostThreadItem" 505 - threadgateRecord={threadgateRecord} 506 - /> 543 + <FeedFeedbackProvider value={feedFeedback}> 544 + <PostControls 545 + big 546 + post={post} 547 + record={record} 548 + richText={richText} 549 + onPressReply={onPressReply} 550 + onPostReply={onPostReply} 551 + logContext="PostThreadItem" 552 + threadgateRecord={threadgateRecord} 553 + feedContext={source?.post?.feedContext} 554 + reqId={source?.post?.reqId} 555 + viaRepost={viaRepost} 556 + /> 557 + </FeedFeedbackProvider> 507 558 </View> 508 559 </View> 509 560 </View> ··· 779 830 const isRootPost = !('reply' in post.record) 780 831 const langPrefs = useLanguagePrefs() 781 832 782 - const onTranslatePress = React.useCallback( 833 + const onTranslatePress = useCallback( 783 834 (e: GestureResponderEvent) => { 784 835 e.preventDefault() 785 836 openLink(translatorUrl, true)
+24 -3
src/view/com/posts/PostFeedItem.tsx
··· 33 33 usePostShadow, 34 34 } from '#/state/cache/post-shadow' 35 35 import {useFeedFeedbackContext} from '#/state/feed-feedback' 36 - import {precacheProfile} from '#/state/queries/profile' 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 40 import {FeedNameText} from '#/view/com/util/FeedInfoText' 40 41 import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' 41 42 import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' ··· 174 175 const urip = new AtUri(post.uri) 175 176 return makeProfileLink(post.author, 'post', urip.rkey) 176 177 }, [post.uri, post.author]) 177 - const {sendInteraction} = useFeedFeedbackContext() 178 + const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() 179 + const unstableSetPostSource = useSetUnstablePostSource() 178 180 179 181 const onPressReply = () => { 180 182 sendInteraction({ ··· 229 231 feedContext, 230 232 reqId, 231 233 }) 232 - precacheProfile(queryClient, post.author) 234 + unstableCacheProfileView(queryClient, post.author) 235 + unstableSetPostSource(post.uri, { 236 + feed: feedDescriptor, 237 + post: { 238 + post, 239 + reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, 240 + feedContext, 241 + reqId, 242 + }, 243 + }) 233 244 } 234 245 235 246 const outerStyles = [ ··· 262 273 : undefined 263 274 264 275 const {isActive: live} = useActorStatus(post.author) 276 + 277 + const viaRepost = useMemo(() => { 278 + if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 279 + return { 280 + uri: reason.uri, 281 + cid: reason.cid, 282 + } 283 + } 284 + }, [reason]) 265 285 266 286 return ( 267 287 <Link ··· 450 470 reqId={reqId} 451 471 threadgateRecord={threadgateRecord} 452 472 onShowLess={onShowLess} 473 + viaRepost={viaRepost} 453 474 /> 454 475 </View> 455 476
+42 -42
yarn.lock
··· 63 63 "@atproto/xrpc" "^0.7.0" 64 64 "@atproto/xrpc-server" "^0.7.18" 65 65 66 - "@atproto/api@^0.15.8": 67 - version "0.15.8" 68 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.8.tgz#f284a9c225191ebd35b46f5695932ab649c04a61" 69 - integrity sha512-PsCgmV4zPjN8VuJMruxqauhn88PuS0b8t2Xsjl4617+bCPpY513jVlxgNH/XExxO7TSVvJM7EzdLY4o3fqh/xQ== 66 + "@atproto/api@^0.15.9": 67 + version "0.15.9" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.9.tgz#f8c40afd6e414ab107d63d6f08d9e264bf9a149a" 69 + integrity sha512-CyAILiIcbN+V5CFAI6MDb247epm25RGkP7HSan5LUaOHiyg1NCAmflWCN/bbMdJX9kLqjAPAG3eN4BUUbYe//Q== 70 70 dependencies: 71 71 "@atproto/common-web" "^0.4.2" 72 72 "@atproto/lexicon" "^0.4.11" ··· 94 94 multiformats "^9.9.0" 95 95 uint8arrays "3.0.0" 96 96 97 - "@atproto/bsky@^0.0.150": 98 - version "0.0.150" 99 - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.150.tgz#6626095875d805d0d3f38fa4e184b9f7d274c80f" 100 - integrity sha512-dn1jzP1EId842+g78Q6EMdOmgEZxa9bSq20HMdd5/R8uu559mPs8zigFuddqCoT1fRaJXFC8ZP7Jk5asvBQhrA== 97 + "@atproto/bsky@^0.0.151": 98 + version "0.0.151" 99 + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.151.tgz#a0e5b59e163a3b74379fb547601be4fc66b7a133" 100 + integrity sha512-42pvUsyGw0nR6Sxlda824maY4gBxUni1cXPG+7uGe6Ixm6XAaPhfTgT1rAg++1rDXH9tT1EXAVnMxg38S6osLg== 101 101 dependencies: 102 102 "@atproto-labs/fetch-node" "0.1.9" 103 103 "@atproto-labs/xrpc-utils" "0.0.14" 104 - "@atproto/api" "^0.15.8" 104 + "@atproto/api" "^0.15.9" 105 105 "@atproto/common" "^0.4.11" 106 106 "@atproto/crypto" "^0.4.4" 107 107 "@atproto/did" "^0.1.5" ··· 218 218 "@noble/hashes" "^1.6.1" 219 219 uint8arrays "3.0.0" 220 220 221 - "@atproto/dev-env@^0.3.132": 222 - version "0.3.132" 223 - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.132.tgz#78d55ef08a368a752c55b1ee7b7c08a41f27b5ac" 224 - integrity sha512-RFd/9kgvmbP859N6NLu/FxCzLsj01iq22P9jNpL+dQNXbWXHYwGMUa6edf/ZrljNi3dFBNxabdDZJ2q+8uvBJQ== 221 + "@atproto/dev-env@^0.3.133": 222 + version "0.3.133" 223 + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.133.tgz#4ca58c9c4c99f001f26ce50629214f81d6acd3ab" 224 + integrity sha512-GtKDa+q0Fx2tJZL44cDAINMCxNmt1aKkGVpW/6PTnuSSjdA7ErBUEL3opbwgaAcPRGZfscB0mQmGfWR0BUmvUw== 225 225 dependencies: 226 - "@atproto/api" "^0.15.8" 227 - "@atproto/bsky" "^0.0.150" 226 + "@atproto/api" "^0.15.9" 227 + "@atproto/bsky" "^0.0.151" 228 228 "@atproto/bsync" "^0.0.19" 229 229 "@atproto/common-web" "^0.4.2" 230 230 "@atproto/crypto" "^0.4.4" 231 231 "@atproto/identity" "^0.4.8" 232 232 "@atproto/lexicon" "^0.4.11" 233 - "@atproto/ozone" "^0.1.111" 234 - "@atproto/pds" "^0.4.138" 233 + "@atproto/ozone" "^0.1.112" 234 + "@atproto/pds" "^0.4.139" 235 235 "@atproto/sync" "^0.1.23" 236 236 "@atproto/syntax" "^0.4.0" 237 237 "@atproto/xrpc-server" "^0.7.18" ··· 294 294 "@atproto/jwk" "0.1.5" 295 295 "@atproto/oauth-types" "0.2.7" 296 296 297 - "@atproto/oauth-provider-frontend@0.1.4": 298 - version "0.1.4" 299 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.4.tgz#240a2e58c29d32fa7d4ea9d142c00c23d2469452" 300 - integrity sha512-TLKL5lTmSieHx7+3RVIx7rIxRPP1SNCwzzdTvYB46yd1XrGHdPU//M6CP5OZ1BvcxF6H4JXIkOSWvFseol+gOw== 297 + "@atproto/oauth-provider-frontend@0.1.5": 298 + version "0.1.5" 299 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.5.tgz#66fd8760fade2ac94111ad5389f33f4d8ce5bba2" 300 + integrity sha512-FdDBuwy827+etjIcRwZU7dtxa8Ltso3ufVLMEi8A2V91v21XDysZjLANC6cvmNNSUcS4E/J6ZAwTrQDo7O5axw== 301 301 optionalDependencies: 302 302 "@atproto/oauth-provider-api" "0.1.2" 303 303 304 - "@atproto/oauth-provider-ui@0.1.5": 305 - version "0.1.5" 306 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.5.tgz#b080c5e814975821689c5976c27ac1081211106f" 307 - integrity sha512-pW0Vx3kvIWH1Mu3SOImNHP9JbmhSj2e3ChDvtfXCWI1oC03fiaMlJfdxrx9Plq5Z+DajnCzPzrf1Lvbopjf94Q== 304 + "@atproto/oauth-provider-ui@0.1.6": 305 + version "0.1.6" 306 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.6.tgz#4bae995ff57671ac3915f58fdb2cf6a76a0fe42d" 307 + integrity sha512-pJzV9ouNj1/TDUCl3CWEZrHoUese4lcKx5F59t2OiLFm2K7T7QrszKUIMyU5QdiQHv551B0ZJOkJ8+4b/fVGPA== 308 308 optionalDependencies: 309 309 "@atproto/oauth-provider-api" "0.1.2" 310 310 311 - "@atproto/oauth-provider@^0.7.7": 312 - version "0.7.7" 313 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.7.tgz#dbbdeb405ab1d239fd926340f83fb41e13455011" 314 - integrity sha512-ElphzmOjw1hr42HN4dD6sMAQFtpTkaJ8bBDAsbL9YBVJDEGhmHsF3Ye8mDUO4nhEdg7PUTWiCzXyqnaorAjiTA== 311 + "@atproto/oauth-provider@^0.7.8": 312 + version "0.7.8" 313 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.7.8.tgz#287b15eb6b0bc0bb4b2da2339150253db006c6e0" 314 + integrity sha512-+dEU9dTyfWKeZ/Nu7ocR6fO73RcG0vwDjT45vgcnM9L7jtuPk9zfpmiR4ODYBk9QUu2DURo9yBhtXNJI3Yz8aQ== 315 315 dependencies: 316 316 "@atproto-labs/fetch" "0.2.3" 317 317 "@atproto-labs/fetch-node" "0.1.9" ··· 322 322 "@atproto/jwk" "0.1.5" 323 323 "@atproto/jwk-jose" "0.1.6" 324 324 "@atproto/oauth-provider-api" "0.1.2" 325 - "@atproto/oauth-provider-frontend" "0.1.4" 326 - "@atproto/oauth-provider-ui" "0.1.5" 325 + "@atproto/oauth-provider-frontend" "0.1.5" 326 + "@atproto/oauth-provider-ui" "0.1.6" 327 327 "@atproto/oauth-types" "0.2.7" 328 328 "@atproto/syntax" "0.4.0" 329 329 "@hapi/accept" "^6.0.3" ··· 346 346 "@atproto/jwk" "0.1.5" 347 347 zod "^3.23.8" 348 348 349 - "@atproto/ozone@^0.1.111": 350 - version "0.1.111" 351 - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.111.tgz#7ef4a02f1af045ab44254fb9d44ab0e50fd94ba9" 352 - integrity sha512-NY+Cn/3dY4tPFkMUoJR1KMZN/v9ZIxjx6EQBMwn/nqTiHk0E3rtGEbyL2jLQ7x+FxpPTjDgpnn3K725+8XUaAg== 349 + "@atproto/ozone@^0.1.112": 350 + version "0.1.112" 351 + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.112.tgz#6b6b5ac052dd4e6dfec3c88f83c9b53f4902fcbe" 352 + integrity sha512-Euut64N/4UyRXyV6m1ATE9K6o6EpCf46ozD4GG8HJ9AC5zEgBYMSkH4l6SLrhKrYYIGXkvglk1WYuuDQKYb3LA== 353 353 dependencies: 354 - "@atproto/api" "^0.15.8" 354 + "@atproto/api" "^0.15.9" 355 355 "@atproto/common" "^0.4.11" 356 356 "@atproto/crypto" "^0.4.4" 357 357 "@atproto/identity" "^0.4.8" ··· 376 376 undici "^6.14.1" 377 377 ws "^8.12.0" 378 378 379 - "@atproto/pds@^0.4.138": 380 - version "0.4.138" 381 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.138.tgz#437d785c83f710bf37bef8baf687b0a46ce9dc68" 382 - integrity sha512-WLzDhmguTgs2wQNKoGxCbpKNegDnRiemSslenMbPrB7kSiXYj+XZobLyoIXHv1EnAd2pbThwNEL8z8EfkM0mDg== 379 + "@atproto/pds@^0.4.139": 380 + version "0.4.139" 381 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.139.tgz#70ae5afd7d90eab214c652d57a5e6478af454fbe" 382 + integrity sha512-VD1VTSAnbAme4D4Xk/Wdl05qs8YbCe39/i960EyXzw2fYNvL9jMpKm3z0lwhrYN9q7phFhr2ubU2QjfRFDbDAQ== 383 383 dependencies: 384 384 "@atproto-labs/fetch-node" "0.1.9" 385 385 "@atproto-labs/xrpc-utils" "0.0.14" 386 - "@atproto/api" "^0.15.8" 386 + "@atproto/api" "^0.15.9" 387 387 "@atproto/aws" "^0.2.21" 388 388 "@atproto/common" "^0.4.11" 389 389 "@atproto/crypto" "^0.4.4" 390 390 "@atproto/identity" "^0.4.8" 391 391 "@atproto/lexicon" "^0.4.11" 392 - "@atproto/oauth-provider" "^0.7.7" 392 + "@atproto/oauth-provider" "^0.7.8" 393 393 "@atproto/repo" "^0.8.1" 394 394 "@atproto/syntax" "^0.4.0" 395 395 "@atproto/xrpc" "^0.7.0"