An ATproto social media client -- with an independent Appview.

Enable show less / more buttons for third party feeds (#8672)

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

authored by

kindgracekind
hailey
Hailey
and committed by
GitHub
88e6dff4 98d96bd2

+150 -34
+16 -9
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 266 feedContext: postFeedContext, 267 reqId: postReqId, 268 }) 269 - Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'}))) 270 } 271 272 const onPressShowLess = () => { ··· 282 feedContext: postFeedContext, 283 }) 284 } else { 285 - Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'}))) 286 } 287 } 288 ··· 486 )} 487 488 {isDiscoverDebugUser && ( 489 - <Menu.Item 490 - testID="postDropdownReportMisclassificationBtn" 491 - label={_(msg`Assign topic for algo`)} 492 - onPress={onReportMisclassification}> 493 - <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 494 - <Menu.ItemIcon icon={AtomIcon} position="right" /> 495 - </Menu.Item> 496 )} 497 498 {hasSession && (
··· 266 feedContext: postFeedContext, 267 reqId: postReqId, 268 }) 269 + Toast.show( 270 + _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 271 + ) 272 } 273 274 const onPressShowLess = () => { ··· 284 feedContext: postFeedContext, 285 }) 286 } else { 287 + Toast.show( 288 + _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 289 + ) 290 } 291 } 292 ··· 490 )} 491 492 {isDiscoverDebugUser && ( 493 + <> 494 + <Menu.Divider /> 495 + <Menu.Item 496 + testID="postDropdownReportMisclassificationBtn" 497 + label={_(msg`Assign topic for algo`)} 498 + onPress={onReportMisclassification}> 499 + <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 500 + <Menu.ItemIcon icon={AtomIcon} position="right" /> 501 + </Menu.Item> 502 + </> 503 )} 504 505 {hasSession && (
-2
src/lib/constants.ts
··· 90 `feedgen|${STAGING_DEFAULT_FEED('thevids')}`, 91 ] 92 93 - export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] 94 - 95 export const POST_IMG_MAX = { 96 width: 2000, 97 height: 2000,
··· 90 `feedgen|${STAGING_DEFAULT_FEED('thevids')}`, 91 ] 92 93 export const POST_IMG_MAX = { 94 width: 2000, 95 height: 2000,
+1 -1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 180 const {openComposer} = useOpenComposer() 181 const {currentAccount, hasSession} = useSession() 182 const {gtTablet} = useBreakpoints() 183 - const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) 184 185 const post = postShadow 186 const record = item.value.post.record
··· 180 const {openComposer} = useOpenComposer() 181 const {currentAccount, hasSession} = useSession() 182 const {gtTablet} = useBreakpoints() 183 + const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) 184 185 const post = postShadow 186 const record = item.value.post.record
+4 -1
src/screens/PostThread/index.tsx
··· 49 const initialNumToRender = useInitialNumToRender() 50 const {height: windowHeight} = useWindowDimensions() 51 const anchorPostSource = useUnstablePostSource(uri) 52 - const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 53 54 /* 55 * One query to rule them all
··· 49 const initialNumToRender = useInitialNumToRender() 50 const {height: windowHeight} = useWindowDimensions() 51 const anchorPostSource = useUnstablePostSource(uri) 52 + const feedFeedback = useFeedFeedback( 53 + anchorPostSource?.feedSourceInfo, 54 + hasSession, 55 + ) 56 57 /* 58 * One query to rule them all
+1 -1
src/screens/Profile/ProfileFeed/index.tsx
··· 169 const [hasNew, setHasNew] = React.useState(false) 170 const [isScrolledDown, setIsScrolledDown] = React.useState(false) 171 const queryClient = useQueryClient() 172 - const feedFeedback = useFeedFeedback(feed, hasSession) 173 const scrollElRef = useAnimatedRef() as ListRef 174 175 const onScrollToTop = useCallback(() => {
··· 169 const [hasNew, setHasNew] = React.useState(false) 170 const [isScrolledDown, setIsScrolledDown] = React.useState(false) 171 const queryClient = useQueryClient() 172 + const feedFeedback = useFeedFeedback(feedInfo, hasSession) 173 const scrollElRef = useAnimatedRef() as ListRef 174 175 const onScrollToTop = useCallback(() => {
+4 -1
src/screens/VideoFeed/index.tsx
··· 70 useFeedFeedbackContext, 71 } from '#/state/feed-feedback' 72 import {useFeedFeedback} from '#/state/feed-feedback' 73 import {usePostLikeMutationQueue} from '#/state/queries/post' 74 import { 75 type AuthorFilter, ··· 199 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 200 } 201 }, [params]) 202 - const feedFeedback = useFeedFeedback(feedDesc, hasSession) 203 const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} = 204 usePostFeedQuery( 205 feedDesc,
··· 70 useFeedFeedbackContext, 71 } from '#/state/feed-feedback' 72 import {useFeedFeedback} from '#/state/feed-feedback' 73 + import {useFeedInfo} from '#/state/queries/feed' 74 import {usePostLikeMutationQueue} from '#/state/queries/post' 75 import { 76 type AuthorFilter, ··· 200 throw new Error(`Invalid video feed params ${JSON.stringify(params)}`) 201 } 202 }, [params]) 203 + const feedUri = params.type === 'feedgen' ? params.uri : undefined 204 + const {data: feedInfo} = useFeedInfo(feedUri) 205 + const feedFeedback = useFeedFeedback(feedInfo, hasSession) 206 const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} = 207 usePostFeedQuery( 208 feedDesc,
+86 -12
src/state/feed-feedback.tsx
··· 10 import {type AppBskyFeedDefs} from '@atproto/api' 11 import throttle from 'lodash.throttle' 12 13 - import {FEEDBACK_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 import {isNetworkError} from '#/lib/hooks/useCleanError' 15 import {logEvent} from '#/lib/statsig/statsig' 16 import {Logger} from '#/logger' 17 import { 18 type FeedDescriptor, 19 type FeedPostSliceItem, ··· 21 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 22 import {useAgent} from './session' 23 24 const logger = Logger.create(Logger.Context.FeedFeedback) 25 26 export type StateContext = { ··· 28 onItemSeen: (item: any) => void 29 sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void 30 feedDescriptor: FeedDescriptor | undefined 31 } 32 33 const stateContext = createContext<StateContext>({ ··· 35 onItemSeen: (_item: any) => {}, 36 sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {}, 37 feedDescriptor: undefined, 38 }) 39 stateContext.displayName = 'FeedFeedbackContext' 40 41 export function useFeedFeedback( 42 - feed: FeedDescriptor | undefined, 43 hasSession: boolean, 44 ) { 45 const agent = useAgent() 46 - const enabled = isDiscoverFeed(feed) && hasSession 47 48 const queue = useRef<Set<string>>(new Set()) 49 const history = useRef< ··· 66 const interactions = Array.from(queue.current).map(toInteraction) 67 queue.current.clear() 68 69 - let proxyDid = 'did:web:discover.bsky.app' 70 - if (STAGING_FEEDS.includes(feed ?? '')) { 71 - proxyDid = 'did:web:algo.pop2.bsky.app' 72 } 73 74 // Send to the feed 75 agent.app.bsky.feed 76 .sendInteractions( 77 - {interactions}, 78 { 79 encoding: 'application/json', 80 headers: { 81 - // TODO when we start sending to other feeds, we need to grab their DID -prf 82 'atproto-proxy': `${proxyDid}#bsky_fg`, 83 }, 84 }, ··· 93 if (aggregatedStats.current === null) { 94 aggregatedStats.current = createAggregatedStats() 95 } 96 - sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) 97 throttledFlushAggregatedStats() 98 logger.debug('flushed') 99 - }, [agent, throttledFlushAggregatedStats, feed]) 100 101 const sendToFeed = useMemo( 102 () => ··· 168 // call on various events 169 // queues the event to be sent with the throttled sendToFeed call 170 sendInteraction, 171 - feedDescriptor: feed, 172 } 173 }, [enabled, onItemSeen, sendInteraction, feed]) 174 } ··· 184 // take advantage of the feed feedback API. Until that's in 185 // place, we're hardcoding it to the discover feed. 186 // -prf 187 - function isDiscoverFeed(feed?: FeedDescriptor) { 188 return !!feed && FEEDBACK_FEEDS.includes(feed) 189 } 190 191 function toString(interaction: AppBskyFeedDefs.Interaction): string {
··· 10 import {type AppBskyFeedDefs} from '@atproto/api' 11 import throttle from 'lodash.throttle' 12 13 + import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants' 14 import {isNetworkError} from '#/lib/hooks/useCleanError' 15 import {logEvent} from '#/lib/statsig/statsig' 16 import {Logger} from '#/logger' 17 + import { 18 + type FeedSourceFeedInfo, 19 + type FeedSourceInfo, 20 + isFeedSourceFeedInfo, 21 + } from '#/state/queries/feed' 22 import { 23 type FeedDescriptor, 24 type FeedPostSliceItem, ··· 26 import {getItemsForFeedback} from '#/view/com/posts/PostFeed' 27 import {useAgent} from './session' 28 29 + export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS] 30 + 31 + export const PASSIVE_FEEDBACK_INTERACTIONS = [ 32 + 'app.bsky.feed.defs#clickthroughItem', 33 + 'app.bsky.feed.defs#clickthroughAuthor', 34 + 'app.bsky.feed.defs#clickthroughReposter', 35 + 'app.bsky.feed.defs#clickthroughEmbed', 36 + 'app.bsky.feed.defs#interactionSeen', 37 + ] as const 38 + 39 + export type PassiveFeedbackInteraction = 40 + (typeof PASSIVE_FEEDBACK_INTERACTIONS)[number] 41 + 42 + export const DIRECT_FEEDBACK_INTERACTIONS = [ 43 + 'app.bsky.feed.defs#requestLess', 44 + 'app.bsky.feed.defs#requestMore', 45 + ] as const 46 + 47 + export type DirectFeedbackInteraction = 48 + (typeof DIRECT_FEEDBACK_INTERACTIONS)[number] 49 + 50 + export const ALL_FEEDBACK_INTERACTIONS = [ 51 + ...PASSIVE_FEEDBACK_INTERACTIONS, 52 + ...DIRECT_FEEDBACK_INTERACTIONS, 53 + ] as const 54 + 55 + export type FeedbackInteraction = (typeof ALL_FEEDBACK_INTERACTIONS)[number] 56 + 57 + export function isFeedbackInteraction( 58 + interactionEvent: string, 59 + ): interactionEvent is FeedbackInteraction { 60 + return ALL_FEEDBACK_INTERACTIONS.includes( 61 + interactionEvent as FeedbackInteraction, 62 + ) 63 + } 64 + 65 const logger = Logger.create(Logger.Context.FeedFeedback) 66 67 export type StateContext = { ··· 69 onItemSeen: (item: any) => void 70 sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void 71 feedDescriptor: FeedDescriptor | undefined 72 + feedSourceInfo: FeedSourceInfo | undefined 73 } 74 75 const stateContext = createContext<StateContext>({ ··· 77 onItemSeen: (_item: any) => {}, 78 sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {}, 79 feedDescriptor: undefined, 80 + feedSourceInfo: undefined, 81 }) 82 stateContext.displayName = 'FeedFeedbackContext' 83 84 export function useFeedFeedback( 85 + feedSourceInfo: FeedSourceInfo | undefined, 86 hasSession: boolean, 87 ) { 88 const agent = useAgent() 89 + 90 + const feed = 91 + !!feedSourceInfo && isFeedSourceFeedInfo(feedSourceInfo) 92 + ? feedSourceInfo 93 + : undefined 94 + 95 + const isDiscover = isDiscoverFeed(feed?.feedDescriptor) 96 + const acceptsInteractions = Boolean(isDiscover || feed?.acceptsInteractions) 97 + const proxyDid = feed?.view?.did 98 + const enabled = 99 + Boolean(feed) && Boolean(proxyDid) && acceptsInteractions && hasSession 100 + const enabledInteractions = getEnabledInteractions(enabled, feed, isDiscover) 101 102 const queue = useRef<Set<string>>(new Set()) 103 const history = useRef< ··· 120 const interactions = Array.from(queue.current).map(toInteraction) 121 queue.current.clear() 122 123 + const interactionsToSend = interactions.filter( 124 + interaction => 125 + interaction.event && 126 + isFeedbackInteraction(interaction.event) && 127 + enabledInteractions.includes(interaction.event), 128 + ) 129 + 130 + if (interactionsToSend.length === 0) { 131 + return 132 } 133 134 // Send to the feed 135 agent.app.bsky.feed 136 .sendInteractions( 137 + {interactions: interactionsToSend}, 138 { 139 encoding: 'application/json', 140 headers: { 141 'atproto-proxy': `${proxyDid}#bsky_fg`, 142 }, 143 }, ··· 152 if (aggregatedStats.current === null) { 153 aggregatedStats.current = createAggregatedStats() 154 } 155 + sendOrAggregateInteractionsForStats( 156 + aggregatedStats.current, 157 + interactionsToSend, 158 + ) 159 throttledFlushAggregatedStats() 160 logger.debug('flushed') 161 + }, [agent, throttledFlushAggregatedStats, proxyDid, enabledInteractions]) 162 163 const sendToFeed = useMemo( 164 () => ··· 230 // call on various events 231 // queues the event to be sent with the throttled sendToFeed call 232 sendInteraction, 233 + feedDescriptor: feed?.feedDescriptor, 234 + feedSourceInfo: typeof feed === 'object' ? feed : undefined, 235 } 236 }, [enabled, onItemSeen, sendInteraction, feed]) 237 } ··· 247 // take advantage of the feed feedback API. Until that's in 248 // place, we're hardcoding it to the discover feed. 249 // -prf 250 + export function isDiscoverFeed(feed?: FeedDescriptor) { 251 return !!feed && FEEDBACK_FEEDS.includes(feed) 252 + } 253 + 254 + function getEnabledInteractions( 255 + enabled: boolean, 256 + feed: FeedSourceFeedInfo | undefined, 257 + isDiscover: boolean, 258 + ): readonly FeedbackInteraction[] { 259 + if (!enabled || !feed) { 260 + return [] 261 + } 262 + return isDiscover ? ALL_FEEDBACK_INTERACTIONS : DIRECT_FEEDBACK_INTERACTIONS 263 } 264 265 function toString(interaction: AppBskyFeedDefs.Interaction): string {
+31
src/state/queries/feed.ts
··· 48 creatorDid: string 49 creatorHandle: string 50 likeCount: number | undefined 51 likeUri: string | undefined 52 contentMode: AppBskyFeedDefs.GeneratorView['contentMode'] 53 } ··· 72 } 73 74 export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo 75 76 const feedSourceInfoQueryKeyRoot = 'getFeedSourceInfo' 77 export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [ ··· 115 creatorDid: view.creator.did, 116 creatorHandle: view.creator.handle, 117 likeCount: view.likeCount, 118 likeUri: view.viewer?.like, 119 contentMode: view.contentMode, 120 } ··· 615 count: result.length, 616 feeds: result, 617 } 618 }, 619 }) 620 }
··· 48 creatorDid: string 49 creatorHandle: string 50 likeCount: number | undefined 51 + acceptsInteractions?: boolean 52 likeUri: string | undefined 53 contentMode: AppBskyFeedDefs.GeneratorView['contentMode'] 54 } ··· 73 } 74 75 export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo 76 + 77 + export function isFeedSourceFeedInfo( 78 + feed: FeedSourceInfo, 79 + ): feed is FeedSourceFeedInfo { 80 + return feed.type === 'feed' 81 + } 82 83 const feedSourceInfoQueryKeyRoot = 'getFeedSourceInfo' 84 export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [ ··· 122 creatorDid: view.creator.did, 123 creatorHandle: view.creator.handle, 124 likeCount: view.likeCount, 125 + acceptsInteractions: view.acceptsInteractions, 126 likeUri: view.viewer?.like, 127 contentMode: view.contentMode, 128 } ··· 623 count: result.length, 624 feeds: result, 625 } 626 + }, 627 + }) 628 + } 629 + 630 + const feedInfoQueryKeyRoot = 'feedInfo' 631 + 632 + export function useFeedInfo(feedUri: string | undefined) { 633 + const agent = useAgent() 634 + 635 + return useQuery({ 636 + staleTime: STALE.INFINITY, 637 + queryKey: [feedInfoQueryKeyRoot, feedUri], 638 + queryFn: async () => { 639 + if (!feedUri) { 640 + return undefined 641 + } 642 + 643 + const res = await agent.app.bsky.feed.getFeedGenerator({ 644 + feed: feedUri, 645 + }) 646 + 647 + const feedSourceInfo = hydrateFeedGenerator(res.data.view) 648 + return feedSourceInfo 649 }, 650 }) 651 }
+2 -2
src/state/unstable-post-source.tsx
··· 2 import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 3 4 import {Logger} from '#/logger' 5 - import {type FeedDescriptor} from '#/state/queries/post-feed' 6 7 /** 8 * Separate logger for better debugging ··· 11 12 export type PostSource = { 13 post: AppBskyFeedDefs.FeedViewPost 14 - feed?: FeedDescriptor 15 } 16 17 /**
··· 2 import {type AppBskyFeedDefs, AtUri} from '@atproto/api' 3 4 import {Logger} from '#/logger' 5 + import {type FeedSourceInfo} from '#/state/queries/feed' 6 7 /** 8 * Separate logger for better debugging ··· 11 12 export type PostSource = { 13 post: AppBskyFeedDefs.FeedViewPost 14 + feedSourceInfo?: FeedSourceInfo 15 } 16 17 /**
+3 -3
src/view/com/feeds/FeedPage.tsx
··· 17 import {listenSoftReset} from '#/state/events' 18 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 19 import {useSetHomeBadge} from '#/state/home-badge' 20 - import {type SavedFeedSourceInfo} from '#/state/queries/feed' 21 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 22 import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' 23 import {truncateAndInvalidate} from '#/state/queries/util' ··· 51 renderEmptyState: () => JSX.Element 52 renderEndOfFeed?: () => JSX.Element 53 savedFeedConfig?: AppBskyActorDefs.SavedFeed 54 - feedInfo: SavedFeedSourceInfo 55 }) { 56 const {hasSession} = useSession() 57 const {_} = useLingui() ··· 61 const [isScrolledDown, setIsScrolledDown] = useState(false) 62 const setMinimalShellMode = useSetMinimalShellMode() 63 const headerOffset = useHeaderOffset() 64 - const feedFeedback = useFeedFeedback(feed, hasSession) 65 const scrollElRef = useRef<ListMethods>(null) 66 const [hasNew, setHasNew] = useState(false) 67 const setHomeBadge = useSetHomeBadge()
··· 17 import {listenSoftReset} from '#/state/events' 18 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 19 import {useSetHomeBadge} from '#/state/home-badge' 20 + import {type FeedSourceInfo} from '#/state/queries/feed' 21 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 22 import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' 23 import {truncateAndInvalidate} from '#/state/queries/util' ··· 51 renderEmptyState: () => JSX.Element 52 renderEndOfFeed?: () => JSX.Element 53 savedFeedConfig?: AppBskyActorDefs.SavedFeed 54 + feedInfo: FeedSourceInfo 55 }) { 56 const {hasSession} = useSession() 57 const {_} = useLingui() ··· 61 const [isScrolledDown, setIsScrolledDown] = useState(false) 62 const setMinimalShellMode = useSetMinimalShellMode() 63 const headerOffset = useHeaderOffset() 64 + const feedFeedback = useFeedFeedback(feedInfo, hasSession) 65 const scrollElRef = useRef<ListMethods>(null) 66 const [hasNew, setHasNew] = useState(false) 67 const setHomeBadge = useSetHomeBadge()
+2 -2
src/view/com/posts/PostFeedItem.tsx
··· 176 const urip = new AtUri(post.uri) 177 return makeProfileLink(post.author, 'post', urip.rkey) 178 }, [post.uri, post.author]) 179 - const {sendInteraction, feedDescriptor} = useFeedFeedbackContext() 180 181 const onPressReply = () => { 182 sendInteraction({ ··· 234 }) 235 unstableCacheProfileView(queryClient, post.author) 236 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 237 - feed: feedDescriptor, 238 post: { 239 post, 240 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,
··· 176 const urip = new AtUri(post.uri) 177 return makeProfileLink(post.author, 'post', urip.rkey) 178 }, [post.uri, post.author]) 179 + const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext() 180 181 const onPressReply = () => { 182 sendInteraction({ ··· 234 }) 235 unstableCacheProfileView(queryClient, post.author) 236 setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { 237 + feedSourceInfo, 238 post: { 239 post, 240 reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined,