Bluesky app fork with some witchin' additions 💫

Implement FeedFeedback API (#3498)

* Implement onViewableItemsChanged on List.web.tsx

* Introduce onItemSeen to List API

* Add FeedFeedback tracker

* Add clickthrough interaction tracking

* Add engagement interaction tracking

* Reduce duplicate sends, introduce a flushAndReset to be triggered on refreshes, and modify the api design a bit

* Wire up SDK types and feedContext

* Avoid needless function allocations

* Fix schema usage

* Add show more / show less buttons

* Fix minor rendering issue on mobile menu

* Wire up sendInteractions()

* Fix logic error

* Fix: it's item not uri

* Update 'seen' to mean 3 seconds on-screen with some significant portion visible

* Fix non-reactive debounce

* Move methods out

* Use a WeakSet for deduping

* Reset timeout

* 3 -> 2 seconds

* Oopsie

* Throttle instead

* Fix divider

* Remove explicit flush calls

* Rm unused

---------

Co-authored-by: dan <dan.abramov@gmail.com>

authored by

Paul Frazee
dan
and committed by
GitHub
4fad18b2 e264dfbb

+515 -63
+1
assets/icons/emojiSmile_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>
+2
package.json
··· 100 100 "@tiptap/react": "^2.0.0-beta.220", 101 101 "@tiptap/suggestion": "^2.0.0-beta.220", 102 102 "@types/invariant": "^2.2.37", 103 + "@types/lodash.throttle": "^4.1.9", 103 104 "@types/node": "^18.16.2", 104 105 "@zxing/text-encoding": "^0.9.0", 105 106 "array.prototype.findlast": "^1.2.3", ··· 151 152 "lodash.samplesize": "^4.2.0", 152 153 "lodash.set": "^4.3.2", 153 154 "lodash.shuffle": "^4.2.0", 155 + "lodash.throttle": "^4.1.1", 154 156 "mobx": "^6.6.1", 155 157 "mobx-react-lite": "^3.4.0", 156 158 "mobx-utils": "^6.0.6",
+4
src/components/icons/Emoji.tsx
··· 4 4 path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z', 5 5 }) 6 6 7 + export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z', 9 + }) 10 + 7 11 export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 12 path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z', 9 13 })
+151
src/state/feed-feedback.tsx
··· 1 + import React from 'react' 2 + import {AppState, AppStateStatus} from 'react-native' 3 + import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' 4 + import throttle from 'lodash.throttle' 5 + 6 + import {PROD_DEFAULT_FEED} from '#/lib/constants' 7 + import {logger} from '#/logger' 8 + import { 9 + FeedDescriptor, 10 + FeedPostSliceItem, 11 + isFeedPostSlice, 12 + } from '#/state/queries/post-feed' 13 + import {useAgent} from './session' 14 + 15 + type StateContext = { 16 + enabled: boolean 17 + onItemSeen: (item: any) => void 18 + sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void 19 + } 20 + 21 + const stateContext = React.createContext<StateContext>({ 22 + enabled: false, 23 + onItemSeen: (_item: any) => {}, 24 + sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {}, 25 + }) 26 + 27 + export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { 28 + const {getAgent} = useAgent() 29 + const enabled = isDiscoverFeed(feed) && hasSession 30 + const queue = React.useRef<Set<string>>(new Set()) 31 + const history = React.useRef< 32 + // Use a WeakSet so that we don't need to clear it. 33 + // This assumes that referential identity of slice items maps 1:1 to feed (re)fetches. 34 + WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction> 35 + >(new WeakSet()) 36 + 37 + const sendToFeedNoDelay = React.useCallback(() => { 38 + const proxyAgent = getAgent().withProxy( 39 + // @ts-ignore TODO need to update withProxy() to support this key -prf 40 + 'bsky_fg', 41 + // TODO when we start sending to other feeds, we need to grab their DID -prf 42 + 'did:web:discover.bsky.app', 43 + ) as BskyAgent 44 + 45 + const interactions = Array.from(queue.current).map(toInteraction) 46 + queue.current.clear() 47 + 48 + proxyAgent.app.bsky.feed 49 + .sendInteractions({interactions}) 50 + .catch((e: any) => { 51 + logger.warn('Failed to send feed interactions', {error: e}) 52 + }) 53 + }, [getAgent]) 54 + 55 + const sendToFeed = React.useMemo( 56 + () => 57 + throttle(sendToFeedNoDelay, 15e3, { 58 + leading: false, 59 + trailing: true, 60 + }), 61 + [sendToFeedNoDelay], 62 + ) 63 + 64 + React.useEffect(() => { 65 + if (!enabled) { 66 + return 67 + } 68 + const sub = AppState.addEventListener('change', (state: AppStateStatus) => { 69 + if (state === 'background') { 70 + sendToFeed.flush() 71 + } 72 + }) 73 + return () => sub.remove() 74 + }, [enabled, sendToFeed]) 75 + 76 + const onItemSeen = React.useCallback( 77 + (slice: any) => { 78 + if (!enabled) { 79 + return 80 + } 81 + if (!isFeedPostSlice(slice)) { 82 + return 83 + } 84 + for (const postItem of slice.items) { 85 + if (!history.current.has(postItem)) { 86 + history.current.add(postItem) 87 + queue.current.add( 88 + toString({ 89 + item: postItem.uri, 90 + event: 'app.bsky.feed.defs#interactionSeen', 91 + feedContext: postItem.feedContext, 92 + }), 93 + ) 94 + sendToFeed() 95 + } 96 + } 97 + }, 98 + [enabled, sendToFeed], 99 + ) 100 + 101 + const sendInteraction = React.useCallback( 102 + (interaction: AppBskyFeedDefs.Interaction) => { 103 + if (!enabled) { 104 + return 105 + } 106 + if (!history.current.has(interaction)) { 107 + history.current.add(interaction) 108 + queue.current.add(toString(interaction)) 109 + sendToFeed() 110 + } 111 + }, 112 + [enabled, sendToFeed], 113 + ) 114 + 115 + return React.useMemo(() => { 116 + return { 117 + enabled, 118 + // pass this method to the <List> onItemSeen 119 + onItemSeen, 120 + // call on various events 121 + // queues the event to be sent with the throttled sendToFeed call 122 + sendInteraction, 123 + } 124 + }, [enabled, onItemSeen, sendInteraction]) 125 + } 126 + 127 + export const FeedFeedbackProvider = stateContext.Provider 128 + 129 + export function useFeedFeedbackContext() { 130 + return React.useContext(stateContext) 131 + } 132 + 133 + // TODO 134 + // We will introduce a permissions framework for 3p feeds to 135 + // take advantage of the feed feedback API. Until that's in 136 + // place, we're hardcoding it to the discover feed. 137 + // -prf 138 + function isDiscoverFeed(feed: FeedDescriptor) { 139 + return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}` 140 + } 141 + 142 + function toString(interaction: AppBskyFeedDefs.Interaction): string { 143 + return `${interaction.item}|${interaction.event}|${ 144 + interaction.feedContext || '' 145 + }` 146 + } 147 + 148 + function toInteraction(str: string): AppBskyFeedDefs.Interaction { 149 + const [item, event, feedContext] = str.split('|') 150 + return {item, event, feedContext} 151 + }
+10
src/state/queries/post-feed.ts
··· 70 70 post: AppBskyFeedDefs.PostView 71 71 record: AppBskyFeedPost.Record 72 72 reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource 73 + feedContext: string | undefined 73 74 moderation: ModerationDecision 74 75 } 75 76 76 77 export interface FeedPostSlice { 78 + _isFeedPostSlice: boolean 77 79 _reactKey: string 78 80 rootUri: string 79 81 isThread: boolean ··· 276 278 277 279 return { 278 280 _reactKey: slice._reactKey, 281 + _isFeedPostSlice: true, 279 282 rootUri: slice.rootItem.post.uri, 280 283 isThread: 281 284 slice.items.length > 1 && ··· 300 303 i === 0 && slice.source 301 304 ? slice.source 302 305 : item.reason, 306 + feedContext: item.feedContext, 303 307 moderation: moderations[i], 304 308 } 305 309 } ··· 507 511 }) 508 512 }, timeout) 509 513 } 514 + 515 + export function isFeedPostSlice(v: any): v is FeedPostSlice { 516 + return ( 517 + v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice 518 + ) 519 + }
+18 -14
src/view/com/feeds/FeedPage.tsx
··· 9 9 import {logEvent, useGate} from '#/lib/statsig/statsig' 10 10 import {isNative} from '#/platform/detection' 11 11 import {listenSoftReset} from '#/state/events' 12 + import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 12 13 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 13 14 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 14 15 import {truncateAndInvalidate} from '#/state/queries/util' ··· 51 52 const setMinimalShellMode = useSetMinimalShellMode() 52 53 const {screen, track} = useAnalytics() 53 54 const headerOffset = useHeaderOffset() 55 + const feedFeedback = useFeedFeedback(feed, hasSession) 54 56 const scrollElRef = React.useRef<ListMethods>(null) 55 57 const [hasNew, setHasNew] = React.useState(false) 56 58 const gate = useGate() ··· 113 115 return ( 114 116 <View testID={testID} style={s.h100pct}> 115 117 <MainScrollProvider> 116 - <Feed 117 - testID={testID ? `${testID}-feed` : undefined} 118 - enabled={isPageFocused} 119 - feed={feed} 120 - feedParams={feedParams} 121 - pollInterval={POLL_FREQ} 122 - disablePoll={hasNew} 123 - scrollElRef={scrollElRef} 124 - onScrolledDownChange={setIsScrolledDown} 125 - onHasNew={setHasNew} 126 - renderEmptyState={renderEmptyState} 127 - renderEndOfFeed={renderEndOfFeed} 128 - headerOffset={headerOffset} 129 - /> 118 + <FeedFeedbackProvider value={feedFeedback}> 119 + <Feed 120 + testID={testID ? `${testID}-feed` : undefined} 121 + enabled={isPageFocused} 122 + feed={feed} 123 + feedParams={feedParams} 124 + pollInterval={POLL_FREQ} 125 + disablePoll={hasNew} 126 + scrollElRef={scrollElRef} 127 + onScrolledDownChange={setIsScrolledDown} 128 + onHasNew={setHasNew} 129 + renderEmptyState={renderEmptyState} 130 + renderEndOfFeed={renderEndOfFeed} 131 + headerOffset={headerOffset} 132 + /> 133 + </FeedFeedbackProvider> 130 134 </MainScrollProvider> 131 135 {(isScrolledDown || adjustedHasNew) && ( 132 136 <LoadLatestBtn
+3
src/view/com/posts/Feed.tsx
··· 17 17 import {logger} from '#/logger' 18 18 import {isWeb} from '#/platform/detection' 19 19 import {listenPostCreated} from '#/state/events' 20 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 20 21 import {STALE} from '#/state/queries' 21 22 import { 22 23 FeedDescriptor, ··· 88 89 const queryClient = useQueryClient() 89 90 const {currentAccount} = useSession() 90 91 const initialNumToRender = useInitialNumToRender() 92 + const feedFeedback = useFeedFeedbackContext() 91 93 const [isPTRing, setIsPTRing] = React.useState(false) 92 94 const checkForNewRef = React.useRef<(() => void) | null>(null) 93 95 const lastFetchRef = React.useRef<number>(Date.now()) ··· 353 355 } 354 356 initialNumToRender={initialNumToRender} 355 357 windowSize={11} 358 + onItemSeen={feedFeedback.onItemSeen} 356 359 /> 357 360 </View> 358 361 )
+57 -4
src/view/com/posts/FeedItem.tsx
··· 16 16 import {useQueryClient} from '@tanstack/react-query' 17 17 18 18 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 19 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 19 20 import {useComposerControls} from '#/state/shell/composer' 20 21 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' 21 22 import {MAX_POST_LINES} from 'lib/constants' ··· 45 46 post, 46 47 record, 47 48 reason, 49 + feedContext, 48 50 moderation, 49 51 isThreadChild, 50 52 isThreadLastChild, ··· 53 55 post: AppBskyFeedDefs.PostView 54 56 record: AppBskyFeedPost.Record 55 57 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined 58 + feedContext: string | undefined 56 59 moderation: ModerationDecision 57 60 isThreadChild?: boolean 58 61 isThreadLastChild?: boolean ··· 78 81 post={postShadowed} 79 82 record={record} 80 83 reason={reason} 84 + feedContext={feedContext} 81 85 richText={richText} 82 86 moderation={moderation} 83 87 isThreadChild={isThreadChild} ··· 93 97 post, 94 98 record, 95 99 reason, 100 + feedContext, 96 101 richText, 97 102 moderation, 98 103 isThreadChild, ··· 102 107 post: Shadow<AppBskyFeedDefs.PostView> 103 108 record: AppBskyFeedPost.Record 104 109 reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined 110 + feedContext: string | undefined 105 111 richText: RichTextAPI 106 112 moderation: ModerationDecision 107 113 isThreadChild?: boolean ··· 116 122 const urip = new AtUri(post.uri) 117 123 return makeProfileLink(post.author, 'post', urip.rkey) 118 124 }, [post.uri, post.author]) 125 + const {sendInteraction} = useFeedFeedbackContext() 119 126 120 127 const replyAuthorDid = useMemo(() => { 121 128 if (!record?.reply) { ··· 126 133 }, [record?.reply]) 127 134 128 135 const onPressReply = React.useCallback(() => { 136 + sendInteraction({ 137 + item: post.uri, 138 + event: 'app.bsky.feed.defs#interactionReply', 139 + feedContext, 140 + }) 129 141 openComposer({ 130 142 replyTo: { 131 143 uri: post.uri, ··· 136 148 moderation, 137 149 }, 138 150 }) 139 - }, [post, record, openComposer, moderation]) 151 + }, [post, record, openComposer, moderation, sendInteraction, feedContext]) 152 + 153 + const onOpenAuthor = React.useCallback(() => { 154 + sendInteraction({ 155 + item: post.uri, 156 + event: 'app.bsky.feed.defs#clickthroughAuthor', 157 + feedContext, 158 + }) 159 + }, [sendInteraction, post, feedContext]) 160 + 161 + const onOpenReposter = React.useCallback(() => { 162 + sendInteraction({ 163 + item: post.uri, 164 + event: 'app.bsky.feed.defs#clickthroughReposter', 165 + feedContext, 166 + }) 167 + }, [sendInteraction, post, feedContext]) 168 + 169 + const onOpenEmbed = React.useCallback(() => { 170 + sendInteraction({ 171 + item: post.uri, 172 + event: 'app.bsky.feed.defs#clickthroughEmbed', 173 + feedContext, 174 + }) 175 + }, [sendInteraction, post, feedContext]) 140 176 141 177 const onBeforePress = React.useCallback(() => { 178 + sendInteraction({ 179 + item: post.uri, 180 + event: 'app.bsky.feed.defs#clickthroughItem', 181 + feedContext, 182 + }) 142 183 precacheProfile(queryClient, post.author) 143 - }, [queryClient, post.author]) 184 + }, [queryClient, post, sendInteraction, feedContext]) 144 185 145 186 const outerStyles = [ 146 187 styles.outer, ··· 207 248 msg`Reposted by ${sanitizeDisplayName( 208 249 reason.by.displayName || reason.by.handle, 209 250 )}`, 210 - )}> 251 + )} 252 + onBeforePress={onOpenReposter}> 211 253 <FontAwesomeIcon 212 254 icon="retweet" 213 255 style={{ ··· 235 277 moderation.ui('displayName'), 236 278 )} 237 279 href={makeProfileLink(reason.by)} 280 + onBeforePress={onOpenReposter} 238 281 /> 239 282 </ProfileHoverCard> 240 283 </Trans> ··· 251 294 profile={post.author} 252 295 moderation={moderation.ui('avatar')} 253 296 type={post.author.associated?.labeler ? 'labeler' : 'user'} 297 + onBeforePress={onOpenAuthor} 254 298 /> 255 299 {isThreadParent && ( 256 300 <View ··· 272 316 authorHasWarning={!!post.author.labels?.length} 273 317 timestamp={post.indexedAt} 274 318 postHref={href} 319 + onOpenAuthor={onOpenAuthor} 275 320 /> 276 321 {!isThreadChild && replyAuthorDid !== '' && ( 277 322 <View style={[s.flexRow, s.mb2, s.alignCenter]}> ··· 308 353 richText={richText} 309 354 postEmbed={post.embed} 310 355 postAuthor={post.author} 356 + onOpenEmbed={onOpenEmbed} 311 357 /> 312 358 <PostCtrls 313 359 post={post} ··· 315 361 richText={richText} 316 362 onPressReply={onPressReply} 317 363 logContext="FeedItem" 364 + feedContext={feedContext} 318 365 /> 319 366 </View> 320 367 </View> ··· 328 375 richText, 329 376 postEmbed, 330 377 postAuthor, 378 + onOpenEmbed, 331 379 }: { 332 380 moderation: ModerationDecision 333 381 richText: RichTextAPI 334 382 postEmbed: AppBskyFeedDefs.PostView['embed'] 335 383 postAuthor: AppBskyFeedDefs.PostView['author'] 384 + onOpenEmbed: () => void 336 385 }): React.ReactNode => { 337 386 const pal = usePalette('default') 338 387 const {_} = useLingui() ··· 373 422 ) : undefined} 374 423 {postEmbed ? ( 375 424 <View style={[a.pb_sm]}> 376 - <PostEmbeds embed={postEmbed} moderation={moderation} /> 425 + <PostEmbeds 426 + embed={postEmbed} 427 + moderation={moderation} 428 + onOpen={onOpenEmbed} 429 + /> 377 430 </View> 378 431 ) : null} 379 432 </ContentHider>
+10 -5
src/view/com/posts/FeedSlice.tsx
··· 1 1 import React, {memo} from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 - import {FeedPostSlice} from '#/state/queries/post-feed' 3 + import Svg, {Circle, Line} from 'react-native-svg' 4 4 import {AtUri} from '@atproto/api' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {FeedPostSlice} from '#/state/queries/post-feed' 8 + import {usePalette} from 'lib/hooks/usePalette' 9 + import {makeProfileLink} from 'lib/routes/links' 5 10 import {Link} from '../util/Link' 6 11 import {Text} from '../util/text/Text' 7 - import Svg, {Circle, Line} from 'react-native-svg' 8 12 import {FeedItem} from './FeedItem' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {makeProfileLink} from 'lib/routes/links' 11 - import {Trans} from '@lingui/macro' 12 13 13 14 let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => { 14 15 if (slice.isThread && slice.items.length > 3) { ··· 20 21 post={slice.items[0].post} 21 22 record={slice.items[0].record} 22 23 reason={slice.items[0].reason} 24 + feedContext={slice.items[0].feedContext} 23 25 moderation={slice.items[0].moderation} 24 26 isThreadParent={isThreadParentAt(slice.items, 0)} 25 27 isThreadChild={isThreadChildAt(slice.items, 0)} ··· 29 31 post={slice.items[1].post} 30 32 record={slice.items[1].record} 31 33 reason={slice.items[1].reason} 34 + feedContext={slice.items[1].feedContext} 32 35 moderation={slice.items[1].moderation} 33 36 isThreadParent={isThreadParentAt(slice.items, 1)} 34 37 isThreadChild={isThreadChildAt(slice.items, 1)} ··· 39 42 post={slice.items[last].post} 40 43 record={slice.items[last].record} 41 44 reason={slice.items[last].reason} 45 + feedContext={slice.items[last].feedContext} 42 46 moderation={slice.items[last].moderation} 43 47 isThreadParent={isThreadParentAt(slice.items, last)} 44 48 isThreadChild={isThreadChildAt(slice.items, last)} ··· 56 60 post={slice.items[i].post} 57 61 record={slice.items[i].record} 58 62 reason={slice.items[i].reason} 63 + feedContext={slice.items[i].feedContext} 59 64 moderation={slice.items[i].moderation} 60 65 isThreadParent={isThreadParentAt(slice.items, i)} 61 66 isThreadChild={isThreadChildAt(slice.items, i)}
+1 -1
src/view/com/util/Link.tsx
··· 220 220 ) 221 221 }, 222 222 [ 223 + onBeforePress, 223 224 onPress, 224 225 closeModal, 225 226 openModal, ··· 229 230 disableMismatchWarning, 230 231 navigationAction, 231 232 openLink, 232 - onBeforePress, 233 233 ], 234 234 ) 235 235 const hrefAttrs = useMemo(() => {
+24 -1
src/view/com/util/List.tsx
··· 1 1 import React, {memo} from 'react' 2 - import {FlatListProps, RefreshControl} from 'react-native' 2 + import {FlatListProps, RefreshControl, ViewToken} from 'react-native' 3 3 import {runOnJS, useSharedValue} from 'react-native-reanimated' 4 4 5 5 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' ··· 23 23 headerOffset?: number 24 24 refreshing?: boolean 25 25 onRefresh?: () => void 26 + onItemSeen?: (item: ItemT) => void 26 27 containWeb?: boolean 27 28 } 28 29 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null> ··· 34 35 onScrolledDownChange, 35 36 refreshing, 36 37 onRefresh, 38 + onItemSeen, 37 39 headerOffset, 38 40 style, 39 41 ...props ··· 73 75 }, 74 76 }) 75 77 78 + const [onViewableItemsChanged, viewabilityConfig] = React.useMemo(() => { 79 + if (!onItemSeen) { 80 + return [undefined, undefined] 81 + } 82 + return [ 83 + (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => { 84 + for (const item of info.changed) { 85 + if (item.isViewable) { 86 + onItemSeen(item.item) 87 + } 88 + } 89 + }, 90 + { 91 + itemVisiblePercentThreshold: 40, 92 + minimumViewTime: 2e3, 93 + }, 94 + ] 95 + }, [onItemSeen]) 96 + 76 97 let refreshControl 77 98 if (refreshing !== undefined || onRefresh !== undefined) { 78 99 refreshControl = ( ··· 102 123 refreshControl={refreshControl} 103 124 onScroll={scrollHandler} 104 125 scrollEventThrottle={1} 126 + onViewableItemsChanged={onViewableItemsChanged} 127 + viewabilityConfig={viewabilityConfig} 105 128 style={style} 106 129 ref={ref} 107 130 />
+67 -10
src/view/com/util/List.web.tsx
··· 20 20 headerOffset?: number 21 21 refreshing?: boolean 22 22 onRefresh?: () => void 23 + onItemSeen?: (item: ItemT) => void 23 24 desktopFixedHeight: any // TODO: Better types. 24 25 containWeb?: boolean 25 26 } 26 27 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. 28 + 29 + const ON_ITEM_SEEN_WAIT_DURATION = 2e3 // post must be "seen" 2 seconds before capturing 30 + const ON_ITEM_SEEN_INTERSECTION_OPTS = { 31 + rootMargin: '-200px 0px -200px 0px', 32 + } // post must be 200px visible to be "seen" 27 33 28 34 function ListImpl<ItemT>( 29 35 { ··· 43 49 onRefresh: _unsupportedOnRefresh, 44 50 onScrolledDownChange, 45 51 onContentSizeChange, 52 + onItemSeen, 46 53 renderItem, 47 54 extraData, 48 55 style, ··· 319 326 /> 320 327 )} 321 328 {header} 322 - {(data as Array<ItemT>).map((item, index) => ( 323 - <Row<ItemT> 324 - key={keyExtractor!(item, index)} 325 - item={item} 326 - index={index} 327 - renderItem={renderItem} 328 - extraData={extraData} 329 - /> 330 - ))} 329 + {(data as Array<ItemT>).map((item, index) => { 330 + const key = keyExtractor!(item, index) 331 + return ( 332 + <Row<ItemT> 333 + key={key} 334 + item={item} 335 + index={index} 336 + renderItem={renderItem} 337 + extraData={extraData} 338 + onItemSeen={onItemSeen} 339 + /> 340 + ) 341 + })} 331 342 {onEndReached && ( 332 343 <Visibility 333 344 root={containWeb ? nativeRef : null} ··· 372 383 index, 373 384 renderItem, 374 385 extraData: _unused, 386 + onItemSeen, 375 387 }: { 376 388 item: ItemT 377 389 index: number ··· 380 392 | undefined 381 393 | ((data: {index: number; item: any; separators: any}) => React.ReactNode) 382 394 extraData: any 395 + onItemSeen: ((item: any) => void) | undefined 383 396 }): React.ReactNode { 397 + const rowRef = React.useRef(null) 398 + const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined) 399 + 400 + const handleIntersection = useNonReactiveCallback( 401 + (entries: IntersectionObserverEntry[]) => { 402 + batchedUpdates(() => { 403 + if (!onItemSeen) { 404 + return 405 + } 406 + entries.forEach(entry => { 407 + if (entry.isIntersecting) { 408 + if (!intersectionTimeout.current) { 409 + intersectionTimeout.current = setTimeout(() => { 410 + intersectionTimeout.current = undefined 411 + onItemSeen!(item) 412 + }, ON_ITEM_SEEN_WAIT_DURATION) 413 + } 414 + } else { 415 + if (intersectionTimeout.current) { 416 + clearTimeout(intersectionTimeout.current) 417 + intersectionTimeout.current = undefined 418 + } 419 + } 420 + }) 421 + }) 422 + }, 423 + ) 424 + 425 + React.useEffect(() => { 426 + if (!onItemSeen) { 427 + return 428 + } 429 + const observer = new IntersectionObserver( 430 + handleIntersection, 431 + ON_ITEM_SEEN_INTERSECTION_OPTS, 432 + ) 433 + const row: Element | null = rowRef.current! 434 + observer.observe(row) 435 + return () => { 436 + observer.unobserve(row) 437 + } 438 + }, [handleIntersection, onItemSeen]) 439 + 384 440 if (!renderItem) { 385 441 return null 386 442 } 443 + 387 444 return ( 388 - <View style={styles.row}> 445 + <View style={styles.row} ref={rowRef}> 389 446 {renderItem({item, index, separators: null as any})} 390 447 </View> 391 448 )
+10 -4
src/view/com/util/PostMeta.tsx
··· 28 28 avatarSize?: number 29 29 displayNameType?: TypographyVariant 30 30 displayNameStyle?: StyleProp<TextStyle> 31 + onOpenAuthor?: () => void 31 32 style?: StyleProp<ViewStyle> 32 33 } 33 34 ··· 43 44 : undefined 44 45 45 46 const queryClient = useQueryClient() 46 - const onBeforePress = useCallback(() => { 47 + const onOpenAuthor = opts.onOpenAuthor 48 + const onBeforePressAuthor = useCallback(() => { 49 + precacheProfile(queryClient, opts.author) 50 + onOpenAuthor?.() 51 + }, [queryClient, opts.author, onOpenAuthor]) 52 + const onBeforePressPost = useCallback(() => { 47 53 precacheProfile(queryClient, opts.author) 48 54 }, [queryClient, opts.author]) 49 55 ··· 77 83 </> 78 84 } 79 85 href={profileLink} 80 - onBeforePress={onBeforePress} 86 + onBeforePress={onBeforePressAuthor} 81 87 onPointerEnter={onPointerEnter} 82 88 /> 83 89 <TextLinkOnWebOnly ··· 86 92 style={[pal.textLight, {flexShrink: 4}]} 87 93 text={'\xa0' + sanitizeHandle(handle, '@')} 88 94 href={profileLink} 89 - onBeforePress={onBeforePress} 95 + onBeforePress={onBeforePressAuthor} 90 96 onPointerEnter={onPointerEnter} 91 97 anchorNoUnderline 92 98 /> ··· 112 118 title={niceDate(opts.timestamp)} 113 119 accessibilityHint="" 114 120 href={opts.postHref} 115 - onBeforePress={onBeforePress} 121 + onBeforePress={onBeforePressPost} 116 122 /> 117 123 )} 118 124 </TimeElapsed>
+4 -1
src/view/com/util/UserAvatar.tsx
··· 50 50 51 51 interface PreviewableUserAvatarProps extends BaseUserAvatarProps { 52 52 moderation?: ModerationUI 53 + onBeforePress?: () => void 53 54 profile: AppBskyActorDefs.ProfileViewBasic 54 55 } 55 56 ··· 382 383 let PreviewableUserAvatar = ({ 383 384 moderation, 384 385 profile, 386 + onBeforePress, 385 387 ...rest 386 388 }: PreviewableUserAvatarProps): React.ReactNode => { 387 389 const {_} = useLingui() 388 390 const queryClient = useQueryClient() 389 391 390 392 const onPress = React.useCallback(() => { 393 + onBeforePress?.() 391 394 precacheProfile(queryClient, profile) 392 - }, [profile, queryClient]) 395 + }, [profile, queryClient, onBeforePress]) 393 396 394 397 return ( 395 398 <ProfileHoverCard did={profile.did}>
+49 -2
src/view/com/util/forms/PostDropdownBtn.tsx
··· 18 18 import {getTranslatorLink} from '#/locale/helpers' 19 19 import {logger} from '#/logger' 20 20 import {isWeb} from '#/platform/detection' 21 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 21 22 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' 22 23 import {useLanguagePrefs} from '#/state/preferences' 23 24 import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' ··· 36 37 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 37 38 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 38 39 import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' 40 + import { 41 + EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, 42 + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, 43 + } from '#/components/icons/Emoji' 39 44 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 40 45 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 41 46 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' ··· 53 58 postAuthor, 54 59 postCid, 55 60 postUri, 61 + postFeedContext, 56 62 record, 57 63 richText, 58 64 style, ··· 63 69 postAuthor: AppBskyActorDefs.ProfileViewBasic 64 70 postCid: string 65 71 postUri: string 72 + postFeedContext: string | undefined 66 73 record: AppBskyFeedPost.Record 67 74 richText: RichTextAPI 68 75 style?: StyleProp<ViewStyle> ··· 81 88 const postDeleteMutation = usePostDeleteMutation() 82 89 const hiddenPosts = useHiddenPosts() 83 90 const {hidePost} = useHiddenPostsApi() 91 + const feedFeedback = useFeedFeedbackContext() 84 92 const openLink = useOpenLink() 85 93 const navigation = useNavigation() 86 94 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() ··· 182 190 const url = toShareUrl(href) 183 191 shareUrl(url) 184 192 }, [href]) 193 + 194 + const onPressShowMore = React.useCallback(() => { 195 + feedFeedback.sendInteraction({ 196 + event: 'app.bsky.feed.defs#requestMore', 197 + item: postUri, 198 + feedContext: postFeedContext, 199 + }) 200 + Toast.show('Feedback sent!') 201 + }, [feedFeedback, postUri, postFeedContext]) 202 + 203 + const onPressShowLess = React.useCallback(() => { 204 + feedFeedback.sendInteraction({ 205 + event: 'app.bsky.feed.defs#requestLess', 206 + item: postUri, 207 + feedContext: postFeedContext, 208 + }) 209 + Toast.show('Feedback sent!') 210 + }, [feedFeedback, postUri, postFeedContext]) 185 211 186 212 const canEmbed = isWeb && gtMobile && !hideInPWI 187 213 ··· 262 288 )} 263 289 </Menu.Group> 264 290 265 - {hasSession && ( 291 + {hasSession && feedFeedback.enabled && ( 266 292 <> 267 293 <Menu.Divider /> 294 + <Menu.Group> 295 + <Menu.Item 296 + testID="postDropdownShowMoreBtn" 297 + label={_(msg`Show more like this`)} 298 + onPress={onPressShowMore}> 299 + <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 300 + <Menu.ItemIcon icon={EmojiSmile} position="right" /> 301 + </Menu.Item> 268 302 303 + <Menu.Item 304 + testID="postDropdownShowLessBtn" 305 + label={_(msg`Show less like this`)} 306 + onPress={onPressShowLess}> 307 + <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 308 + <Menu.ItemIcon icon={EmojiSad} position="right" /> 309 + </Menu.Item> 310 + </Menu.Group> 311 + </> 312 + )} 313 + 314 + {hasSession && ( 315 + <> 316 + <Menu.Divider /> 269 317 <Menu.Group> 270 318 <Menu.Item 271 319 testID="postDropdownMuteThreadBtn" ··· 308 356 {hasSession && ( 309 357 <> 310 358 <Menu.Divider /> 311 - 312 359 <Menu.Group> 313 360 {!isAuthor && ( 314 361 <Menu.Item
+47 -3
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 23 23 import {s} from '#/lib/styles' 24 24 import {useTheme} from '#/lib/ThemeContext' 25 25 import {Shadow} from '#/state/cache/types' 26 + import {useFeedFeedbackContext} from '#/state/feed-feedback' 26 27 import {useModalControls} from '#/state/modals' 27 28 import { 28 29 usePostLikeMutationQueue, ··· 43 44 post, 44 45 record, 45 46 richText, 47 + feedContext, 46 48 style, 47 49 onPressReply, 48 50 logContext, ··· 51 53 post: Shadow<AppBskyFeedDefs.PostView> 52 54 record: AppBskyFeedPost.Record 53 55 richText: RichTextAPI 56 + feedContext?: string | undefined 54 57 style?: StyleProp<ViewStyle> 55 58 onPressReply: () => void 56 59 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' ··· 66 69 ) 67 70 const requireAuth = useRequireAuth() 68 71 const loggedOutWarningPromptControl = useDialogControl() 72 + const {sendInteraction} = useFeedFeedbackContext() 69 73 const playHaptic = useHaptics() 70 74 71 75 const shouldShowLoggedOutWarning = React.useMemo(() => { ··· 85 89 try { 86 90 if (!post.viewer?.like) { 87 91 playHaptic() 92 + sendInteraction({ 93 + item: post.uri, 94 + event: 'app.bsky.feed.defs#interactionLike', 95 + feedContext, 96 + }) 88 97 await queueLike() 89 98 } else { 90 99 await queueUnlike() ··· 94 103 throw e 95 104 } 96 105 } 97 - }, [playHaptic, post.viewer?.like, queueLike, queueUnlike]) 106 + }, [ 107 + playHaptic, 108 + post.uri, 109 + post.viewer?.like, 110 + queueLike, 111 + queueUnlike, 112 + sendInteraction, 113 + feedContext, 114 + ]) 98 115 99 116 const onRepost = useCallback(async () => { 100 117 closeModal() 101 118 try { 102 119 if (!post.viewer?.repost) { 103 120 playHaptic() 121 + sendInteraction({ 122 + item: post.uri, 123 + event: 'app.bsky.feed.defs#interactionRepost', 124 + feedContext, 125 + }) 104 126 await queueRepost() 105 127 } else { 106 128 await queueUnrepost() ··· 110 132 throw e 111 133 } 112 134 } 113 - }, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost]) 135 + }, [ 136 + closeModal, 137 + post.uri, 138 + post.viewer?.repost, 139 + playHaptic, 140 + queueRepost, 141 + queueUnrepost, 142 + sendInteraction, 143 + feedContext, 144 + ]) 114 145 115 146 const onQuote = useCallback(() => { 116 147 closeModal() 148 + sendInteraction({ 149 + item: post.uri, 150 + event: 'app.bsky.feed.defs#interactionQuote', 151 + feedContext, 152 + }) 117 153 openComposer({ 118 154 quote: { 119 155 uri: post.uri, ··· 133 169 post.indexedAt, 134 170 record.text, 135 171 playHaptic, 172 + sendInteraction, 173 + feedContext, 136 174 ]) 137 175 138 176 const onShare = useCallback(() => { ··· 140 178 const href = makeProfileLink(post.author, 'post', urip.rkey) 141 179 const url = toShareUrl(href) 142 180 shareUrl(url) 143 - }, [post.uri, post.author]) 181 + sendInteraction({ 182 + item: post.uri, 183 + event: 'app.bsky.feed.defs#interactionShare', 184 + feedContext, 185 + }) 186 + }, [post.uri, post.author, sendInteraction, feedContext]) 144 187 145 188 return ( 146 189 <View style={[styles.ctrls, style]}> ··· 268 311 postAuthor={post.author} 269 312 postCid={post.cid} 270 313 postUri={post.uri} 314 + postFeedContext={feedContext} 271 315 record={record} 272 316 richText={richText} 273 317 style={styles.btnPad}
+6 -1
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 19 19 20 20 export const ExternalLinkEmbed = ({ 21 21 link, 22 + onOpen, 22 23 style, 23 24 hideAlt, 24 25 }: { 25 26 link: AppBskyEmbedExternal.ViewExternal 27 + onOpen?: () => void 26 28 style?: StyleProp<ViewStyle> 27 29 hideAlt?: boolean 28 30 }) => { ··· 44 46 45 47 return ( 46 48 <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}> 47 - <LinkWrapper link={link} style={style}> 49 + <LinkWrapper link={link} onOpen={onOpen} style={style}> 48 50 {link.thumb && !embedPlayerParams ? ( 49 51 <Image 50 52 style={{ ··· 97 99 98 100 function LinkWrapper({ 99 101 link, 102 + onOpen, 100 103 style, 101 104 children, 102 105 }: { 103 106 link: AppBskyEmbedExternal.ViewExternal 107 + onOpen?: () => void 104 108 style?: StyleProp<ViewStyle> 105 109 children: React.ReactNode 106 110 }) { ··· 125 129 style, 126 130 ]} 127 131 hoverStyle={t.atoms.border_contrast_high} 132 + onBeforePress={onOpen} 128 133 onLongPress={onShareExternal}> 129 134 {children} 130 135 </Link>
+17 -2
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 42 42 43 43 export function MaybeQuoteEmbed({ 44 44 embed, 45 + onOpen, 45 46 style, 46 47 }: { 47 48 embed: AppBskyEmbedRecord.View 49 + onOpen?: () => void 48 50 style?: StyleProp<ViewStyle> 49 51 }) { 50 52 const pal = usePalette('default') ··· 57 59 <QuoteEmbedModerated 58 60 viewRecord={embed.record} 59 61 postRecord={embed.record.value} 62 + onOpen={onOpen} 60 63 style={style} 61 64 /> 62 65 ) ··· 85 88 function QuoteEmbedModerated({ 86 89 viewRecord, 87 90 postRecord, 91 + onOpen, 88 92 style, 89 93 }: { 90 94 viewRecord: AppBskyEmbedRecord.ViewRecord 91 95 postRecord: AppBskyFeedPost.Record 96 + onOpen?: () => void 92 97 style?: StyleProp<ViewStyle> 93 98 }) { 94 99 const moderationOpts = useModerationOpts() ··· 108 113 embeds: viewRecord.embeds, 109 114 } 110 115 111 - return <QuoteEmbed quote={quote} moderation={moderation} style={style} /> 116 + return ( 117 + <QuoteEmbed 118 + quote={quote} 119 + moderation={moderation} 120 + onOpen={onOpen} 121 + style={style} 122 + /> 123 + ) 112 124 } 113 125 114 126 export function QuoteEmbed({ 115 127 quote, 116 128 moderation, 129 + onOpen, 117 130 style, 118 131 }: { 119 132 quote: ComposerOptsQuote 120 133 moderation?: ModerationDecision 134 + onOpen?: () => void 121 135 style?: StyleProp<ViewStyle> 122 136 }) { 123 137 const queryClient = useQueryClient() ··· 150 164 151 165 const onBeforePress = React.useCallback(() => { 152 166 precacheProfile(queryClient, quote.author) 153 - }, [queryClient, quote.author]) 167 + onOpen?.() 168 + }, [queryClient, quote.author, onOpen]) 154 169 155 170 return ( 156 171 <ContentHider modui={moderation?.ui('contentList')}>
+10 -4
src/view/com/util/post-embeds/index.tsx
··· 38 38 export function PostEmbeds({ 39 39 embed, 40 40 moderation, 41 + onOpen, 41 42 style, 42 43 }: { 43 44 embed?: Embed 44 45 moderation?: ModerationDecision 46 + onOpen?: () => void 45 47 style?: StyleProp<ViewStyle> 46 48 }) { 47 49 const pal = usePalette('default') ··· 52 54 if (AppBskyEmbedRecordWithMedia.isView(embed)) { 53 55 return ( 54 56 <View style={style}> 55 - <PostEmbeds embed={embed.media} moderation={moderation} /> 56 - <MaybeQuoteEmbed embed={embed.record} /> 57 + <PostEmbeds 58 + embed={embed.media} 59 + moderation={moderation} 60 + onOpen={onOpen} 61 + /> 62 + <MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} /> 57 63 </View> 58 64 ) 59 65 } ··· 80 86 81 87 // quote post 82 88 // = 83 - return <MaybeQuoteEmbed embed={embed} style={style} /> 89 + return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} /> 84 90 } 85 91 86 92 // image embed ··· 151 157 const link = embed.external 152 158 return ( 153 159 <ContentHider modui={moderation?.ui('contentMedia')}> 154 - <ExternalLinkEmbed link={link} style={style} /> 160 + <ExternalLinkEmbed link={link} onOpen={onOpen} style={style} /> 155 161 </ContentHider> 156 162 ) 157 163 }
+1
src/view/screens/DebugMod.tsx
··· 804 804 record={post.record as AppBskyFeedPost.Record} 805 805 moderation={moderation} 806 806 reason={undefined} 807 + feedContext={''} 807 808 /> 808 809 ) 809 810 }
+16 -11
src/view/screens/ProfileFeed.tsx
··· 10 10 import {logger} from '#/logger' 11 11 import {isNative} from '#/platform/detection' 12 12 import {listenSoftReset} from '#/state/events' 13 + import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 13 14 import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' 14 15 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 15 16 import {FeedDescriptor} from '#/state/queries/post-feed' ··· 462 463 const [isScrolledDown, setIsScrolledDown] = React.useState(false) 463 464 const queryClient = useQueryClient() 464 465 const isScreenFocused = useIsFocused() 466 + const {hasSession} = useSession() 467 + const feedFeedback = useFeedFeedback(feed, hasSession) 465 468 466 469 const onScrollToTop = useCallback(() => { 467 470 scrollElRef.current?.scrollToOffset({ ··· 489 492 490 493 return ( 491 494 <View> 492 - <Feed 493 - enabled={isFocused} 494 - feed={feed} 495 - pollInterval={60e3} 496 - disablePoll={hasNew} 497 - scrollElRef={scrollElRef} 498 - onHasNew={setHasNew} 499 - onScrolledDownChange={setIsScrolledDown} 500 - renderEmptyState={renderPostsEmpty} 501 - headerOffset={headerHeight} 502 - /> 495 + <FeedFeedbackProvider value={feedFeedback}> 496 + <Feed 497 + enabled={isFocused} 498 + feed={feed} 499 + pollInterval={60e3} 500 + disablePoll={hasNew} 501 + scrollElRef={scrollElRef} 502 + onHasNew={setHasNew} 503 + onScrolledDownChange={setIsScrolledDown} 504 + renderEmptyState={renderPostsEmpty} 505 + headerOffset={headerHeight} 506 + /> 507 + </FeedFeedbackProvider> 503 508 {(isScrolledDown || hasNew) && ( 504 509 <LoadLatestBtn 505 510 onPress={onScrollToTop}
+7
yarn.lock
··· 7729 7729 dependencies: 7730 7730 "@types/lodash" "*" 7731 7731 7732 + "@types/lodash.throttle@^4.1.9": 7733 + version "4.1.9" 7734 + resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5" 7735 + integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g== 7736 + dependencies: 7737 + "@types/lodash" "*" 7738 + 7732 7739 "@types/lodash@*": 7733 7740 version "4.14.197" 7734 7741 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"