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>zio/stable
parent
e264dfbb87
commit
4fad18b2fa
|
@ -0,0 +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>
|
After Width: | Height: | Size: 623 B |
|
@ -100,6 +100,7 @@
|
||||||
"@tiptap/react": "^2.0.0-beta.220",
|
"@tiptap/react": "^2.0.0-beta.220",
|
||||||
"@tiptap/suggestion": "^2.0.0-beta.220",
|
"@tiptap/suggestion": "^2.0.0-beta.220",
|
||||||
"@types/invariant": "^2.2.37",
|
"@types/invariant": "^2.2.37",
|
||||||
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
"@types/node": "^18.16.2",
|
"@types/node": "^18.16.2",
|
||||||
"@zxing/text-encoding": "^0.9.0",
|
"@zxing/text-encoding": "^0.9.0",
|
||||||
"array.prototype.findlast": "^1.2.3",
|
"array.prototype.findlast": "^1.2.3",
|
||||||
|
@ -151,6 +152,7 @@
|
||||||
"lodash.samplesize": "^4.2.0",
|
"lodash.samplesize": "^4.2.0",
|
||||||
"lodash.set": "^4.3.2",
|
"lodash.set": "^4.3.2",
|
||||||
"lodash.shuffle": "^4.2.0",
|
"lodash.shuffle": "^4.2.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
"mobx": "^6.6.1",
|
"mobx": "^6.6.1",
|
||||||
"mobx-react-lite": "^3.4.0",
|
"mobx-react-lite": "^3.4.0",
|
||||||
"mobx-utils": "^6.0.6",
|
"mobx-utils": "^6.0.6",
|
||||||
|
|
|
@ -4,6 +4,10 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
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',
|
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',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
|
||||||
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
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',
|
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',
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,151 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {AppState, AppStateStatus} from 'react-native'
|
||||||
|
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||||
|
import throttle from 'lodash.throttle'
|
||||||
|
|
||||||
|
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
FeedDescriptor,
|
||||||
|
FeedPostSliceItem,
|
||||||
|
isFeedPostSlice,
|
||||||
|
} from '#/state/queries/post-feed'
|
||||||
|
import {useAgent} from './session'
|
||||||
|
|
||||||
|
type StateContext = {
|
||||||
|
enabled: boolean
|
||||||
|
onItemSeen: (item: any) => void
|
||||||
|
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateContext = React.createContext<StateContext>({
|
||||||
|
enabled: false,
|
||||||
|
onItemSeen: (_item: any) => {},
|
||||||
|
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||||
|
const {getAgent} = useAgent()
|
||||||
|
const enabled = isDiscoverFeed(feed) && hasSession
|
||||||
|
const queue = React.useRef<Set<string>>(new Set())
|
||||||
|
const history = React.useRef<
|
||||||
|
// Use a WeakSet so that we don't need to clear it.
|
||||||
|
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
|
||||||
|
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
|
||||||
|
>(new WeakSet())
|
||||||
|
|
||||||
|
const sendToFeedNoDelay = React.useCallback(() => {
|
||||||
|
const proxyAgent = getAgent().withProxy(
|
||||||
|
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
||||||
|
'bsky_fg',
|
||||||
|
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
||||||
|
'did:web:discover.bsky.app',
|
||||||
|
) as BskyAgent
|
||||||
|
|
||||||
|
const interactions = Array.from(queue.current).map(toInteraction)
|
||||||
|
queue.current.clear()
|
||||||
|
|
||||||
|
proxyAgent.app.bsky.feed
|
||||||
|
.sendInteractions({interactions})
|
||||||
|
.catch((e: any) => {
|
||||||
|
logger.warn('Failed to send feed interactions', {error: e})
|
||||||
|
})
|
||||||
|
}, [getAgent])
|
||||||
|
|
||||||
|
const sendToFeed = React.useMemo(
|
||||||
|
() =>
|
||||||
|
throttle(sendToFeedNoDelay, 15e3, {
|
||||||
|
leading: false,
|
||||||
|
trailing: true,
|
||||||
|
}),
|
||||||
|
[sendToFeedNoDelay],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||||
|
if (state === 'background') {
|
||||||
|
sendToFeed.flush()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => sub.remove()
|
||||||
|
}, [enabled, sendToFeed])
|
||||||
|
|
||||||
|
const onItemSeen = React.useCallback(
|
||||||
|
(slice: any) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isFeedPostSlice(slice)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const postItem of slice.items) {
|
||||||
|
if (!history.current.has(postItem)) {
|
||||||
|
history.current.add(postItem)
|
||||||
|
queue.current.add(
|
||||||
|
toString({
|
||||||
|
item: postItem.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
|
feedContext: postItem.feedContext,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
sendToFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, sendToFeed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendInteraction = React.useCallback(
|
||||||
|
(interaction: AppBskyFeedDefs.Interaction) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!history.current.has(interaction)) {
|
||||||
|
history.current.add(interaction)
|
||||||
|
queue.current.add(toString(interaction))
|
||||||
|
sendToFeed()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, sendToFeed],
|
||||||
|
)
|
||||||
|
|
||||||
|
return React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
// pass this method to the <List> onItemSeen
|
||||||
|
onItemSeen,
|
||||||
|
// call on various events
|
||||||
|
// queues the event to be sent with the throttled sendToFeed call
|
||||||
|
sendInteraction,
|
||||||
|
}
|
||||||
|
}, [enabled, onItemSeen, sendInteraction])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedFeedbackProvider = stateContext.Provider
|
||||||
|
|
||||||
|
export function useFeedFeedbackContext() {
|
||||||
|
return React.useContext(stateContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// We will introduce a permissions framework for 3p feeds to
|
||||||
|
// take advantage of the feed feedback API. Until that's in
|
||||||
|
// place, we're hardcoding it to the discover feed.
|
||||||
|
// -prf
|
||||||
|
function isDiscoverFeed(feed: FeedDescriptor) {
|
||||||
|
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toString(interaction: AppBskyFeedDefs.Interaction): string {
|
||||||
|
return `${interaction.item}|${interaction.event}|${
|
||||||
|
interaction.feedContext || ''
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
|
||||||
|
const [item, event, feedContext] = str.split('|')
|
||||||
|
return {item, event, feedContext}
|
||||||
|
}
|
|
@ -70,10 +70,12 @@ export interface FeedPostSliceItem {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
||||||
|
feedContext: string | undefined
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedPostSlice {
|
export interface FeedPostSlice {
|
||||||
|
_isFeedPostSlice: boolean
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
rootUri: string
|
rootUri: string
|
||||||
isThread: boolean
|
isThread: boolean
|
||||||
|
@ -276,6 +278,7 @@ export function usePostFeedQuery(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_reactKey: slice._reactKey,
|
_reactKey: slice._reactKey,
|
||||||
|
_isFeedPostSlice: true,
|
||||||
rootUri: slice.rootItem.post.uri,
|
rootUri: slice.rootItem.post.uri,
|
||||||
isThread:
|
isThread:
|
||||||
slice.items.length > 1 &&
|
slice.items.length > 1 &&
|
||||||
|
@ -300,6 +303,7 @@ export function usePostFeedQuery(
|
||||||
i === 0 && slice.source
|
i === 0 && slice.source
|
||||||
? slice.source
|
? slice.source
|
||||||
: item.reason,
|
: item.reason,
|
||||||
|
feedContext: item.feedContext,
|
||||||
moderation: moderations[i],
|
moderation: moderations[i],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -507,3 +511,9 @@ export function resetProfilePostsQueries(
|
||||||
})
|
})
|
||||||
}, timeout)
|
}, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFeedPostSlice(v: any): v is FeedPostSlice {
|
||||||
|
return (
|
||||||
|
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
|
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||||
|
@ -51,6 +52,7 @@ export function FeedPage({
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const headerOffset = useHeaderOffset()
|
const headerOffset = useHeaderOffset()
|
||||||
|
const feedFeedback = useFeedFeedback(feed, hasSession)
|
||||||
const scrollElRef = React.useRef<ListMethods>(null)
|
const scrollElRef = React.useRef<ListMethods>(null)
|
||||||
const [hasNew, setHasNew] = React.useState(false)
|
const [hasNew, setHasNew] = React.useState(false)
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
@ -113,6 +115,7 @@ export function FeedPage({
|
||||||
return (
|
return (
|
||||||
<View testID={testID} style={s.h100pct}>
|
<View testID={testID} style={s.h100pct}>
|
||||||
<MainScrollProvider>
|
<MainScrollProvider>
|
||||||
|
<FeedFeedbackProvider value={feedFeedback}>
|
||||||
<Feed
|
<Feed
|
||||||
testID={testID ? `${testID}-feed` : undefined}
|
testID={testID ? `${testID}-feed` : undefined}
|
||||||
enabled={isPageFocused}
|
enabled={isPageFocused}
|
||||||
|
@ -127,6 +130,7 @@ export function FeedPage({
|
||||||
renderEndOfFeed={renderEndOfFeed}
|
renderEndOfFeed={renderEndOfFeed}
|
||||||
headerOffset={headerOffset}
|
headerOffset={headerOffset}
|
||||||
/>
|
/>
|
||||||
|
</FeedFeedbackProvider>
|
||||||
</MainScrollProvider>
|
</MainScrollProvider>
|
||||||
{(isScrolledDown || adjustedHasNew) && (
|
{(isScrolledDown || adjustedHasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {listenPostCreated} from '#/state/events'
|
import {listenPostCreated} from '#/state/events'
|
||||||
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {
|
import {
|
||||||
FeedDescriptor,
|
FeedDescriptor,
|
||||||
|
@ -88,6 +89,7 @@ let Feed = ({
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
const feedFeedback = useFeedFeedbackContext()
|
||||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||||
const lastFetchRef = React.useRef<number>(Date.now())
|
const lastFetchRef = React.useRef<number>(Date.now())
|
||||||
|
@ -353,6 +355,7 @@ let Feed = ({
|
||||||
}
|
}
|
||||||
initialNumToRender={initialNumToRender}
|
initialNumToRender={initialNumToRender}
|
||||||
windowSize={11}
|
windowSize={11}
|
||||||
|
onItemSeen={feedFeedback.onItemSeen}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||||
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
import {MAX_POST_LINES} from 'lib/constants'
|
import {MAX_POST_LINES} from 'lib/constants'
|
||||||
|
@ -45,6 +46,7 @@ export function FeedItem({
|
||||||
post,
|
post,
|
||||||
record,
|
record,
|
||||||
reason,
|
reason,
|
||||||
|
feedContext,
|
||||||
moderation,
|
moderation,
|
||||||
isThreadChild,
|
isThreadChild,
|
||||||
isThreadLastChild,
|
isThreadLastChild,
|
||||||
|
@ -53,6 +55,7 @@ export function FeedItem({
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||||
|
feedContext: string | undefined
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
isThreadChild?: boolean
|
isThreadChild?: boolean
|
||||||
isThreadLastChild?: boolean
|
isThreadLastChild?: boolean
|
||||||
|
@ -78,6 +81,7 @@ export function FeedItem({
|
||||||
post={postShadowed}
|
post={postShadowed}
|
||||||
record={record}
|
record={record}
|
||||||
reason={reason}
|
reason={reason}
|
||||||
|
feedContext={feedContext}
|
||||||
richText={richText}
|
richText={richText}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
isThreadChild={isThreadChild}
|
isThreadChild={isThreadChild}
|
||||||
|
@ -93,6 +97,7 @@ let FeedItemInner = ({
|
||||||
post,
|
post,
|
||||||
record,
|
record,
|
||||||
reason,
|
reason,
|
||||||
|
feedContext,
|
||||||
richText,
|
richText,
|
||||||
moderation,
|
moderation,
|
||||||
isThreadChild,
|
isThreadChild,
|
||||||
|
@ -102,6 +107,7 @@ let FeedItemInner = ({
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
||||||
|
feedContext: string | undefined
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
isThreadChild?: boolean
|
isThreadChild?: boolean
|
||||||
|
@ -116,6 +122,7 @@ let FeedItemInner = ({
|
||||||
const urip = new AtUri(post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||||
}, [post.uri, post.author])
|
}, [post.uri, post.author])
|
||||||
|
const {sendInteraction} = useFeedFeedbackContext()
|
||||||
|
|
||||||
const replyAuthorDid = useMemo(() => {
|
const replyAuthorDid = useMemo(() => {
|
||||||
if (!record?.reply) {
|
if (!record?.reply) {
|
||||||
|
@ -126,6 +133,11 @@ let FeedItemInner = ({
|
||||||
}, [record?.reply])
|
}, [record?.reply])
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = React.useCallback(() => {
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionReply',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
openComposer({
|
openComposer({
|
||||||
replyTo: {
|
replyTo: {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
|
@ -136,11 +148,40 @@ let FeedItemInner = ({
|
||||||
moderation,
|
moderation,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [post, record, openComposer, moderation])
|
}, [post, record, openComposer, moderation, sendInteraction, feedContext])
|
||||||
|
|
||||||
|
const onOpenAuthor = React.useCallback(() => {
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#clickthroughAuthor',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
|
const onOpenReposter = React.useCallback(() => {
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#clickthroughReposter',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
|
const onOpenEmbed = React.useCallback(() => {
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#clickthroughEmbed',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
const onBeforePress = React.useCallback(() => {
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#clickthroughItem',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
precacheProfile(queryClient, post.author)
|
precacheProfile(queryClient, post.author)
|
||||||
}, [queryClient, post.author])
|
}, [queryClient, post, sendInteraction, feedContext])
|
||||||
|
|
||||||
const outerStyles = [
|
const outerStyles = [
|
||||||
styles.outer,
|
styles.outer,
|
||||||
|
@ -207,7 +248,8 @@ let FeedItemInner = ({
|
||||||
msg`Reposted by ${sanitizeDisplayName(
|
msg`Reposted by ${sanitizeDisplayName(
|
||||||
reason.by.displayName || reason.by.handle,
|
reason.by.displayName || reason.by.handle,
|
||||||
)}`,
|
)}`,
|
||||||
)}>
|
)}
|
||||||
|
onBeforePress={onOpenReposter}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="retweet"
|
icon="retweet"
|
||||||
style={{
|
style={{
|
||||||
|
@ -235,6 +277,7 @@ let FeedItemInner = ({
|
||||||
moderation.ui('displayName'),
|
moderation.ui('displayName'),
|
||||||
)}
|
)}
|
||||||
href={makeProfileLink(reason.by)}
|
href={makeProfileLink(reason.by)}
|
||||||
|
onBeforePress={onOpenReposter}
|
||||||
/>
|
/>
|
||||||
</ProfileHoverCard>
|
</ProfileHoverCard>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
@ -251,6 +294,7 @@ let FeedItemInner = ({
|
||||||
profile={post.author}
|
profile={post.author}
|
||||||
moderation={moderation.ui('avatar')}
|
moderation={moderation.ui('avatar')}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
onBeforePress={onOpenAuthor}
|
||||||
/>
|
/>
|
||||||
{isThreadParent && (
|
{isThreadParent && (
|
||||||
<View
|
<View
|
||||||
|
@ -272,6 +316,7 @@ let FeedItemInner = ({
|
||||||
authorHasWarning={!!post.author.labels?.length}
|
authorHasWarning={!!post.author.labels?.length}
|
||||||
timestamp={post.indexedAt}
|
timestamp={post.indexedAt}
|
||||||
postHref={href}
|
postHref={href}
|
||||||
|
onOpenAuthor={onOpenAuthor}
|
||||||
/>
|
/>
|
||||||
{!isThreadChild && replyAuthorDid !== '' && (
|
{!isThreadChild && replyAuthorDid !== '' && (
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||||
|
@ -308,6 +353,7 @@ let FeedItemInner = ({
|
||||||
richText={richText}
|
richText={richText}
|
||||||
postEmbed={post.embed}
|
postEmbed={post.embed}
|
||||||
postAuthor={post.author}
|
postAuthor={post.author}
|
||||||
|
onOpenEmbed={onOpenEmbed}
|
||||||
/>
|
/>
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
post={post}
|
post={post}
|
||||||
|
@ -315,6 +361,7 @@ let FeedItemInner = ({
|
||||||
richText={richText}
|
richText={richText}
|
||||||
onPressReply={onPressReply}
|
onPressReply={onPressReply}
|
||||||
logContext="FeedItem"
|
logContext="FeedItem"
|
||||||
|
feedContext={feedContext}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
@ -328,11 +375,13 @@ let PostContent = ({
|
||||||
richText,
|
richText,
|
||||||
postEmbed,
|
postEmbed,
|
||||||
postAuthor,
|
postAuthor,
|
||||||
|
onOpenEmbed,
|
||||||
}: {
|
}: {
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
postEmbed: AppBskyFeedDefs.PostView['embed']
|
postEmbed: AppBskyFeedDefs.PostView['embed']
|
||||||
postAuthor: AppBskyFeedDefs.PostView['author']
|
postAuthor: AppBskyFeedDefs.PostView['author']
|
||||||
|
onOpenEmbed: () => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -373,7 +422,11 @@ let PostContent = ({
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{postEmbed ? (
|
{postEmbed ? (
|
||||||
<View style={[a.pb_sm]}>
|
<View style={[a.pb_sm]}>
|
||||||
<PostEmbeds embed={postEmbed} moderation={moderation} />
|
<PostEmbeds
|
||||||
|
embed={postEmbed}
|
||||||
|
moderation={moderation}
|
||||||
|
onOpen={onOpenEmbed}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import React, {memo} from 'react'
|
import React, {memo} from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {FeedPostSlice} from '#/state/queries/post-feed'
|
|
||||||
import {AtUri} from '@atproto/api'
|
|
||||||
import {Link} from '../util/Link'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
import Svg, {Circle, Line} from 'react-native-svg'
|
import Svg, {Circle, Line} from 'react-native-svg'
|
||||||
import {FeedItem} from './FeedItem'
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {FeedPostSlice} from '#/state/queries/post-feed'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Link} from '../util/Link'
|
||||||
|
import {Text} from '../util/text/Text'
|
||||||
|
import {FeedItem} from './FeedItem'
|
||||||
|
|
||||||
let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
if (slice.isThread && slice.items.length > 3) {
|
||||||
|
@ -20,6 +21,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
||||||
post={slice.items[0].post}
|
post={slice.items[0].post}
|
||||||
record={slice.items[0].record}
|
record={slice.items[0].record}
|
||||||
reason={slice.items[0].reason}
|
reason={slice.items[0].reason}
|
||||||
|
feedContext={slice.items[0].feedContext}
|
||||||
moderation={slice.items[0].moderation}
|
moderation={slice.items[0].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, 0)}
|
isThreadParent={isThreadParentAt(slice.items, 0)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, 0)}
|
isThreadChild={isThreadChildAt(slice.items, 0)}
|
||||||
|
@ -29,6 +31,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
||||||
post={slice.items[1].post}
|
post={slice.items[1].post}
|
||||||
record={slice.items[1].record}
|
record={slice.items[1].record}
|
||||||
reason={slice.items[1].reason}
|
reason={slice.items[1].reason}
|
||||||
|
feedContext={slice.items[1].feedContext}
|
||||||
moderation={slice.items[1].moderation}
|
moderation={slice.items[1].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, 1)}
|
isThreadParent={isThreadParentAt(slice.items, 1)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, 1)}
|
isThreadChild={isThreadChildAt(slice.items, 1)}
|
||||||
|
@ -39,6 +42,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
||||||
post={slice.items[last].post}
|
post={slice.items[last].post}
|
||||||
record={slice.items[last].record}
|
record={slice.items[last].record}
|
||||||
reason={slice.items[last].reason}
|
reason={slice.items[last].reason}
|
||||||
|
feedContext={slice.items[last].feedContext}
|
||||||
moderation={slice.items[last].moderation}
|
moderation={slice.items[last].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, last)}
|
isThreadParent={isThreadParentAt(slice.items, last)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, last)}
|
isThreadChild={isThreadChildAt(slice.items, last)}
|
||||||
|
@ -56,6 +60,7 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
|
||||||
post={slice.items[i].post}
|
post={slice.items[i].post}
|
||||||
record={slice.items[i].record}
|
record={slice.items[i].record}
|
||||||
reason={slice.items[i].reason}
|
reason={slice.items[i].reason}
|
||||||
|
feedContext={slice.items[i].feedContext}
|
||||||
moderation={slice.items[i].moderation}
|
moderation={slice.items[i].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, i)}
|
isThreadParent={isThreadParentAt(slice.items, i)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, i)}
|
isThreadChild={isThreadChildAt(slice.items, i)}
|
||||||
|
|
|
@ -220,6 +220,7 @@ export const TextLink = memo(function TextLink({
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
onBeforePress,
|
||||||
onPress,
|
onPress,
|
||||||
closeModal,
|
closeModal,
|
||||||
openModal,
|
openModal,
|
||||||
|
@ -229,7 +230,6 @@ export const TextLink = memo(function TextLink({
|
||||||
disableMismatchWarning,
|
disableMismatchWarning,
|
||||||
navigationAction,
|
navigationAction,
|
||||||
openLink,
|
openLink,
|
||||||
onBeforePress,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
const hrefAttrs = useMemo(() => {
|
const hrefAttrs = useMemo(() => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, {memo} from 'react'
|
import React, {memo} from 'react'
|
||||||
import {FlatListProps, RefreshControl} from 'react-native'
|
import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
|
||||||
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
|
@ -23,6 +23,7 @@ export type ListProps<ItemT> = Omit<
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
refreshing?: boolean
|
refreshing?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
|
onItemSeen?: (item: ItemT) => void
|
||||||
containWeb?: boolean
|
containWeb?: boolean
|
||||||
}
|
}
|
||||||
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
|
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
|
||||||
|
@ -34,6 +35,7 @@ function ListImpl<ItemT>(
|
||||||
onScrolledDownChange,
|
onScrolledDownChange,
|
||||||
refreshing,
|
refreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onItemSeen,
|
||||||
headerOffset,
|
headerOffset,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
|
@ -73,6 +75,25 @@ function ListImpl<ItemT>(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [onViewableItemsChanged, viewabilityConfig] = React.useMemo(() => {
|
||||||
|
if (!onItemSeen) {
|
||||||
|
return [undefined, undefined]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
|
||||||
|
for (const item of info.changed) {
|
||||||
|
if (item.isViewable) {
|
||||||
|
onItemSeen(item.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemVisiblePercentThreshold: 40,
|
||||||
|
minimumViewTime: 2e3,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [onItemSeen])
|
||||||
|
|
||||||
let refreshControl
|
let refreshControl
|
||||||
if (refreshing !== undefined || onRefresh !== undefined) {
|
if (refreshing !== undefined || onRefresh !== undefined) {
|
||||||
refreshControl = (
|
refreshControl = (
|
||||||
|
@ -102,6 +123,8 @@ function ListImpl<ItemT>(
|
||||||
refreshControl={refreshControl}
|
refreshControl={refreshControl}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollEventThrottle={1}
|
scrollEventThrottle={1}
|
||||||
|
onViewableItemsChanged={onViewableItemsChanged}
|
||||||
|
viewabilityConfig={viewabilityConfig}
|
||||||
style={style}
|
style={style}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -20,11 +20,17 @@ export type ListProps<ItemT> = Omit<
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
refreshing?: boolean
|
refreshing?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
|
onItemSeen?: (item: ItemT) => void
|
||||||
desktopFixedHeight: any // TODO: Better types.
|
desktopFixedHeight: any // TODO: Better types.
|
||||||
containWeb?: boolean
|
containWeb?: boolean
|
||||||
}
|
}
|
||||||
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
|
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
|
||||||
|
|
||||||
|
const ON_ITEM_SEEN_WAIT_DURATION = 2e3 // post must be "seen" 2 seconds before capturing
|
||||||
|
const ON_ITEM_SEEN_INTERSECTION_OPTS = {
|
||||||
|
rootMargin: '-200px 0px -200px 0px',
|
||||||
|
} // post must be 200px visible to be "seen"
|
||||||
|
|
||||||
function ListImpl<ItemT>(
|
function ListImpl<ItemT>(
|
||||||
{
|
{
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
|
@ -43,6 +49,7 @@ function ListImpl<ItemT>(
|
||||||
onRefresh: _unsupportedOnRefresh,
|
onRefresh: _unsupportedOnRefresh,
|
||||||
onScrolledDownChange,
|
onScrolledDownChange,
|
||||||
onContentSizeChange,
|
onContentSizeChange,
|
||||||
|
onItemSeen,
|
||||||
renderItem,
|
renderItem,
|
||||||
extraData,
|
extraData,
|
||||||
style,
|
style,
|
||||||
|
@ -319,15 +326,19 @@ function ListImpl<ItemT>(
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{header}
|
{header}
|
||||||
{(data as Array<ItemT>).map((item, index) => (
|
{(data as Array<ItemT>).map((item, index) => {
|
||||||
|
const key = keyExtractor!(item, index)
|
||||||
|
return (
|
||||||
<Row<ItemT>
|
<Row<ItemT>
|
||||||
key={keyExtractor!(item, index)}
|
key={key}
|
||||||
item={item}
|
item={item}
|
||||||
index={index}
|
index={index}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
|
onItemSeen={onItemSeen}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{onEndReached && (
|
{onEndReached && (
|
||||||
<Visibility
|
<Visibility
|
||||||
root={containWeb ? nativeRef : null}
|
root={containWeb ? nativeRef : null}
|
||||||
|
@ -372,6 +383,7 @@ let Row = function RowImpl<ItemT>({
|
||||||
index,
|
index,
|
||||||
renderItem,
|
renderItem,
|
||||||
extraData: _unused,
|
extraData: _unused,
|
||||||
|
onItemSeen,
|
||||||
}: {
|
}: {
|
||||||
item: ItemT
|
item: ItemT
|
||||||
index: number
|
index: number
|
||||||
|
@ -380,12 +392,57 @@ let Row = function RowImpl<ItemT>({
|
||||||
| undefined
|
| undefined
|
||||||
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
|
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
|
||||||
extraData: any
|
extraData: any
|
||||||
|
onItemSeen: ((item: any) => void) | undefined
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
|
const rowRef = React.useRef(null)
|
||||||
|
const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
|
||||||
|
|
||||||
|
const handleIntersection = useNonReactiveCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
batchedUpdates(() => {
|
||||||
|
if (!onItemSeen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (!intersectionTimeout.current) {
|
||||||
|
intersectionTimeout.current = setTimeout(() => {
|
||||||
|
intersectionTimeout.current = undefined
|
||||||
|
onItemSeen!(item)
|
||||||
|
}, ON_ITEM_SEEN_WAIT_DURATION)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (intersectionTimeout.current) {
|
||||||
|
clearTimeout(intersectionTimeout.current)
|
||||||
|
intersectionTimeout.current = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!onItemSeen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
handleIntersection,
|
||||||
|
ON_ITEM_SEEN_INTERSECTION_OPTS,
|
||||||
|
)
|
||||||
|
const row: Element | null = rowRef.current!
|
||||||
|
observer.observe(row)
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(row)
|
||||||
|
}
|
||||||
|
}, [handleIntersection, onItemSeen])
|
||||||
|
|
||||||
if (!renderItem) {
|
if (!renderItem) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row} ref={rowRef}>
|
||||||
{renderItem({item, index, separators: null as any})}
|
{renderItem({item, index, separators: null as any})}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -28,6 +28,7 @@ interface PostMetaOpts {
|
||||||
avatarSize?: number
|
avatarSize?: number
|
||||||
displayNameType?: TypographyVariant
|
displayNameType?: TypographyVariant
|
||||||
displayNameStyle?: StyleProp<TextStyle>
|
displayNameStyle?: StyleProp<TextStyle>
|
||||||
|
onOpenAuthor?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +44,12 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const onBeforePress = useCallback(() => {
|
const onOpenAuthor = opts.onOpenAuthor
|
||||||
|
const onBeforePressAuthor = useCallback(() => {
|
||||||
|
precacheProfile(queryClient, opts.author)
|
||||||
|
onOpenAuthor?.()
|
||||||
|
}, [queryClient, opts.author, onOpenAuthor])
|
||||||
|
const onBeforePressPost = useCallback(() => {
|
||||||
precacheProfile(queryClient, opts.author)
|
precacheProfile(queryClient, opts.author)
|
||||||
}, [queryClient, opts.author])
|
}, [queryClient, opts.author])
|
||||||
|
|
||||||
|
@ -77,7 +83,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
href={profileLink}
|
href={profileLink}
|
||||||
onBeforePress={onBeforePress}
|
onBeforePress={onBeforePressAuthor}
|
||||||
onPointerEnter={onPointerEnter}
|
onPointerEnter={onPointerEnter}
|
||||||
/>
|
/>
|
||||||
<TextLinkOnWebOnly
|
<TextLinkOnWebOnly
|
||||||
|
@ -86,7 +92,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||||
style={[pal.textLight, {flexShrink: 4}]}
|
style={[pal.textLight, {flexShrink: 4}]}
|
||||||
text={'\xa0' + sanitizeHandle(handle, '@')}
|
text={'\xa0' + sanitizeHandle(handle, '@')}
|
||||||
href={profileLink}
|
href={profileLink}
|
||||||
onBeforePress={onBeforePress}
|
onBeforePress={onBeforePressAuthor}
|
||||||
onPointerEnter={onPointerEnter}
|
onPointerEnter={onPointerEnter}
|
||||||
anchorNoUnderline
|
anchorNoUnderline
|
||||||
/>
|
/>
|
||||||
|
@ -112,7 +118,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||||
title={niceDate(opts.timestamp)}
|
title={niceDate(opts.timestamp)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
href={opts.postHref}
|
href={opts.postHref}
|
||||||
onBeforePress={onBeforePress}
|
onBeforePress={onBeforePressPost}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TimeElapsed>
|
</TimeElapsed>
|
||||||
|
|
|
@ -50,6 +50,7 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
|
|
||||||
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
moderation?: ModerationUI
|
moderation?: ModerationUI
|
||||||
|
onBeforePress?: () => void
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -382,14 +383,16 @@ export {EditableUserAvatar}
|
||||||
let PreviewableUserAvatar = ({
|
let PreviewableUserAvatar = ({
|
||||||
moderation,
|
moderation,
|
||||||
profile,
|
profile,
|
||||||
|
onBeforePress,
|
||||||
...rest
|
...rest
|
||||||
}: PreviewableUserAvatarProps): React.ReactNode => {
|
}: PreviewableUserAvatarProps): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
|
onBeforePress?.()
|
||||||
precacheProfile(queryClient, profile)
|
precacheProfile(queryClient, profile)
|
||||||
}, [profile, queryClient])
|
}, [profile, queryClient, onBeforePress])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProfileHoverCard did={profile.did}>
|
<ProfileHoverCard did={profile.did}>
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {getTranslatorLink} from '#/locale/helpers'
|
import {getTranslatorLink} from '#/locale/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
|
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
|
||||||
|
@ -36,6 +37,10 @@ import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons
|
||||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||||
|
import {
|
||||||
|
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
|
||||||
|
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
|
||||||
|
} from '#/components/icons/Emoji'
|
||||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
@ -53,6 +58,7 @@ let PostDropdownBtn = ({
|
||||||
postAuthor,
|
postAuthor,
|
||||||
postCid,
|
postCid,
|
||||||
postUri,
|
postUri,
|
||||||
|
postFeedContext,
|
||||||
record,
|
record,
|
||||||
richText,
|
richText,
|
||||||
style,
|
style,
|
||||||
|
@ -63,6 +69,7 @@ let PostDropdownBtn = ({
|
||||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||||
postCid: string
|
postCid: string
|
||||||
postUri: string
|
postUri: string
|
||||||
|
postFeedContext: string | undefined
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
@ -81,6 +88,7 @@ let PostDropdownBtn = ({
|
||||||
const postDeleteMutation = usePostDeleteMutation()
|
const postDeleteMutation = usePostDeleteMutation()
|
||||||
const hiddenPosts = useHiddenPosts()
|
const hiddenPosts = useHiddenPosts()
|
||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
|
const feedFeedback = useFeedFeedbackContext()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
@ -183,6 +191,24 @@ let PostDropdownBtn = ({
|
||||||
shareUrl(url)
|
shareUrl(url)
|
||||||
}, [href])
|
}, [href])
|
||||||
|
|
||||||
|
const onPressShowMore = React.useCallback(() => {
|
||||||
|
feedFeedback.sendInteraction({
|
||||||
|
event: 'app.bsky.feed.defs#requestMore',
|
||||||
|
item: postUri,
|
||||||
|
feedContext: postFeedContext,
|
||||||
|
})
|
||||||
|
Toast.show('Feedback sent!')
|
||||||
|
}, [feedFeedback, postUri, postFeedContext])
|
||||||
|
|
||||||
|
const onPressShowLess = React.useCallback(() => {
|
||||||
|
feedFeedback.sendInteraction({
|
||||||
|
event: 'app.bsky.feed.defs#requestLess',
|
||||||
|
item: postUri,
|
||||||
|
feedContext: postFeedContext,
|
||||||
|
})
|
||||||
|
Toast.show('Feedback sent!')
|
||||||
|
}, [feedFeedback, postUri, postFeedContext])
|
||||||
|
|
||||||
const canEmbed = isWeb && gtMobile && !hideInPWI
|
const canEmbed = isWeb && gtMobile && !hideInPWI
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -262,10 +288,32 @@ let PostDropdownBtn = ({
|
||||||
)}
|
)}
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
|
|
||||||
|
{hasSession && feedFeedback.enabled && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownShowMoreBtn"
|
||||||
|
label={_(msg`Show more like this`)}
|
||||||
|
onPress={onPressShowMore}>
|
||||||
|
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={EmojiSmile} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownShowLessBtn"
|
||||||
|
label={_(msg`Show less like this`)}
|
||||||
|
onPress={onPressShowLess}>
|
||||||
|
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasSession && (
|
{hasSession && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="postDropdownMuteThreadBtn"
|
testID="postDropdownMuteThreadBtn"
|
||||||
|
@ -308,7 +356,6 @@ let PostDropdownBtn = ({
|
||||||
{hasSession && (
|
{hasSession && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
<Menu.Group>
|
<Menu.Group>
|
||||||
{!isAuthor && (
|
{!isAuthor && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {
|
import {
|
||||||
usePostLikeMutationQueue,
|
usePostLikeMutationQueue,
|
||||||
|
@ -43,6 +44,7 @@ let PostCtrls = ({
|
||||||
post,
|
post,
|
||||||
record,
|
record,
|
||||||
richText,
|
richText,
|
||||||
|
feedContext,
|
||||||
style,
|
style,
|
||||||
onPressReply,
|
onPressReply,
|
||||||
logContext,
|
logContext,
|
||||||
|
@ -51,6 +53,7 @@ let PostCtrls = ({
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
|
feedContext?: string | undefined
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
onPressReply: () => void
|
onPressReply: () => void
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
|
@ -66,6 +69,7 @@ let PostCtrls = ({
|
||||||
)
|
)
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
const loggedOutWarningPromptControl = useDialogControl()
|
const loggedOutWarningPromptControl = useDialogControl()
|
||||||
|
const {sendInteraction} = useFeedFeedbackContext()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
|
|
||||||
const shouldShowLoggedOutWarning = React.useMemo(() => {
|
const shouldShowLoggedOutWarning = React.useMemo(() => {
|
||||||
|
@ -85,6 +89,11 @@ let PostCtrls = ({
|
||||||
try {
|
try {
|
||||||
if (!post.viewer?.like) {
|
if (!post.viewer?.like) {
|
||||||
playHaptic()
|
playHaptic()
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionLike',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
await queueLike()
|
await queueLike()
|
||||||
} else {
|
} else {
|
||||||
await queueUnlike()
|
await queueUnlike()
|
||||||
|
@ -94,13 +103,26 @@ let PostCtrls = ({
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [playHaptic, post.viewer?.like, queueLike, queueUnlike])
|
}, [
|
||||||
|
playHaptic,
|
||||||
|
post.uri,
|
||||||
|
post.viewer?.like,
|
||||||
|
queueLike,
|
||||||
|
queueUnlike,
|
||||||
|
sendInteraction,
|
||||||
|
feedContext,
|
||||||
|
])
|
||||||
|
|
||||||
const onRepost = useCallback(async () => {
|
const onRepost = useCallback(async () => {
|
||||||
closeModal()
|
closeModal()
|
||||||
try {
|
try {
|
||||||
if (!post.viewer?.repost) {
|
if (!post.viewer?.repost) {
|
||||||
playHaptic()
|
playHaptic()
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionRepost',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
await queueRepost()
|
await queueRepost()
|
||||||
} else {
|
} else {
|
||||||
await queueUnrepost()
|
await queueUnrepost()
|
||||||
|
@ -110,10 +132,24 @@ let PostCtrls = ({
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost])
|
}, [
|
||||||
|
closeModal,
|
||||||
|
post.uri,
|
||||||
|
post.viewer?.repost,
|
||||||
|
playHaptic,
|
||||||
|
queueRepost,
|
||||||
|
queueUnrepost,
|
||||||
|
sendInteraction,
|
||||||
|
feedContext,
|
||||||
|
])
|
||||||
|
|
||||||
const onQuote = useCallback(() => {
|
const onQuote = useCallback(() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionQuote',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
openComposer({
|
openComposer({
|
||||||
quote: {
|
quote: {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
|
@ -133,6 +169,8 @@ let PostCtrls = ({
|
||||||
post.indexedAt,
|
post.indexedAt,
|
||||||
record.text,
|
record.text,
|
||||||
playHaptic,
|
playHaptic,
|
||||||
|
sendInteraction,
|
||||||
|
feedContext,
|
||||||
])
|
])
|
||||||
|
|
||||||
const onShare = useCallback(() => {
|
const onShare = useCallback(() => {
|
||||||
|
@ -140,7 +178,12 @@ let PostCtrls = ({
|
||||||
const href = makeProfileLink(post.author, 'post', urip.rkey)
|
const href = makeProfileLink(post.author, 'post', urip.rkey)
|
||||||
const url = toShareUrl(href)
|
const url = toShareUrl(href)
|
||||||
shareUrl(url)
|
shareUrl(url)
|
||||||
}, [post.uri, post.author])
|
sendInteraction({
|
||||||
|
item: post.uri,
|
||||||
|
event: 'app.bsky.feed.defs#interactionShare',
|
||||||
|
feedContext,
|
||||||
|
})
|
||||||
|
}, [post.uri, post.author, sendInteraction, feedContext])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.ctrls, style]}>
|
<View style={[styles.ctrls, style]}>
|
||||||
|
@ -268,6 +311,7 @@ let PostCtrls = ({
|
||||||
postAuthor={post.author}
|
postAuthor={post.author}
|
||||||
postCid={post.cid}
|
postCid={post.cid}
|
||||||
postUri={post.uri}
|
postUri={post.uri}
|
||||||
|
postFeedContext={feedContext}
|
||||||
record={record}
|
record={record}
|
||||||
richText={richText}
|
richText={richText}
|
||||||
style={styles.btnPad}
|
style={styles.btnPad}
|
||||||
|
|
|
@ -19,10 +19,12 @@ import {Text} from '../text/Text'
|
||||||
|
|
||||||
export const ExternalLinkEmbed = ({
|
export const ExternalLinkEmbed = ({
|
||||||
link,
|
link,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
hideAlt,
|
hideAlt,
|
||||||
}: {
|
}: {
|
||||||
link: AppBskyEmbedExternal.ViewExternal
|
link: AppBskyEmbedExternal.ViewExternal
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hideAlt?: boolean
|
hideAlt?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -44,7 +46,7 @@ export const ExternalLinkEmbed = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
|
||||||
<LinkWrapper link={link} style={style}>
|
<LinkWrapper link={link} onOpen={onOpen} style={style}>
|
||||||
{link.thumb && !embedPlayerParams ? (
|
{link.thumb && !embedPlayerParams ? (
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
|
@ -97,10 +99,12 @@ export const ExternalLinkEmbed = ({
|
||||||
|
|
||||||
function LinkWrapper({
|
function LinkWrapper({
|
||||||
link,
|
link,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
link: AppBskyEmbedExternal.ViewExternal
|
link: AppBskyEmbedExternal.ViewExternal
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
@ -125,6 +129,7 @@ function LinkWrapper({
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
hoverStyle={t.atoms.border_contrast_high}
|
hoverStyle={t.atoms.border_contrast_high}
|
||||||
|
onBeforePress={onOpen}
|
||||||
onLongPress={onShareExternal}>
|
onLongPress={onShareExternal}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -42,9 +42,11 @@ import {PostEmbeds} from '.'
|
||||||
|
|
||||||
export function MaybeQuoteEmbed({
|
export function MaybeQuoteEmbed({
|
||||||
embed,
|
embed,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyEmbedRecord.View
|
embed: AppBskyEmbedRecord.View
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -57,6 +59,7 @@ export function MaybeQuoteEmbed({
|
||||||
<QuoteEmbedModerated
|
<QuoteEmbedModerated
|
||||||
viewRecord={embed.record}
|
viewRecord={embed.record}
|
||||||
postRecord={embed.record.value}
|
postRecord={embed.record.value}
|
||||||
|
onOpen={onOpen}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -85,10 +88,12 @@ export function MaybeQuoteEmbed({
|
||||||
function QuoteEmbedModerated({
|
function QuoteEmbedModerated({
|
||||||
viewRecord,
|
viewRecord,
|
||||||
postRecord,
|
postRecord,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
viewRecord: AppBskyEmbedRecord.ViewRecord
|
viewRecord: AppBskyEmbedRecord.ViewRecord
|
||||||
postRecord: AppBskyFeedPost.Record
|
postRecord: AppBskyFeedPost.Record
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
@ -108,16 +113,25 @@ function QuoteEmbedModerated({
|
||||||
embeds: viewRecord.embeds,
|
embeds: viewRecord.embeds,
|
||||||
}
|
}
|
||||||
|
|
||||||
return <QuoteEmbed quote={quote} moderation={moderation} style={style} />
|
return (
|
||||||
|
<QuoteEmbed
|
||||||
|
quote={quote}
|
||||||
|
moderation={moderation}
|
||||||
|
onOpen={onOpen}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuoteEmbed({
|
export function QuoteEmbed({
|
||||||
quote,
|
quote,
|
||||||
moderation,
|
moderation,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
quote: ComposerOptsQuote
|
quote: ComposerOptsQuote
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
@ -150,7 +164,8 @@ export function QuoteEmbed({
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
const onBeforePress = React.useCallback(() => {
|
||||||
precacheProfile(queryClient, quote.author)
|
precacheProfile(queryClient, quote.author)
|
||||||
}, [queryClient, quote.author])
|
onOpen?.()
|
||||||
|
}, [queryClient, quote.author, onOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={moderation?.ui('contentList')}>
|
<ContentHider modui={moderation?.ui('contentList')}>
|
||||||
|
|
|
@ -38,10 +38,12 @@ type Embed =
|
||||||
export function PostEmbeds({
|
export function PostEmbeds({
|
||||||
embed,
|
embed,
|
||||||
moderation,
|
moderation,
|
||||||
|
onOpen,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
embed?: Embed
|
embed?: Embed
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
|
onOpen?: () => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -52,8 +54,12 @@ export function PostEmbeds({
|
||||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<PostEmbeds embed={embed.media} moderation={moderation} />
|
<PostEmbeds
|
||||||
<MaybeQuoteEmbed embed={embed.record} />
|
embed={embed.media}
|
||||||
|
moderation={moderation}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -80,7 +86,7 @@ export function PostEmbeds({
|
||||||
|
|
||||||
// quote post
|
// quote post
|
||||||
// =
|
// =
|
||||||
return <MaybeQuoteEmbed embed={embed} style={style} />
|
return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// image embed
|
// image embed
|
||||||
|
@ -151,7 +157,7 @@ export function PostEmbeds({
|
||||||
const link = embed.external
|
const link = embed.external
|
||||||
return (
|
return (
|
||||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||||
<ExternalLinkEmbed link={link} style={style} />
|
<ExternalLinkEmbed link={link} onOpen={onOpen} style={style} />
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -804,6 +804,7 @@ function MockPostFeedItem({
|
||||||
record={post.record as AppBskyFeedPost.Record}
|
record={post.record as AppBskyFeedPost.Record}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
reason={undefined}
|
reason={undefined}
|
||||||
|
feedContext={''}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {HITSLOP_20} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
|
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||||
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||||
|
@ -462,6 +463,8 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
||||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const isScreenFocused = useIsFocused()
|
const isScreenFocused = useIsFocused()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const feedFeedback = useFeedFeedback(feed, hasSession)
|
||||||
|
|
||||||
const onScrollToTop = useCallback(() => {
|
const onScrollToTop = useCallback(() => {
|
||||||
scrollElRef.current?.scrollToOffset({
|
scrollElRef.current?.scrollToOffset({
|
||||||
|
@ -489,6 +492,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
|
<FeedFeedbackProvider value={feedFeedback}>
|
||||||
<Feed
|
<Feed
|
||||||
enabled={isFocused}
|
enabled={isFocused}
|
||||||
feed={feed}
|
feed={feed}
|
||||||
|
@ -500,6 +504,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
|
||||||
renderEmptyState={renderPostsEmpty}
|
renderEmptyState={renderPostsEmpty}
|
||||||
headerOffset={headerHeight}
|
headerOffset={headerHeight}
|
||||||
/>
|
/>
|
||||||
|
</FeedFeedbackProvider>
|
||||||
{(isScrolledDown || hasNew) && (
|
{(isScrolledDown || hasNew) && (
|
||||||
<LoadLatestBtn
|
<LoadLatestBtn
|
||||||
onPress={onScrollToTop}
|
onPress={onScrollToTop}
|
||||||
|
|
|
@ -7729,6 +7729,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/lodash" "*"
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash.throttle@^4.1.9":
|
||||||
|
version "4.1.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5"
|
||||||
|
integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
"@types/lodash@*":
|
"@types/lodash@*":
|
||||||
version "4.14.197"
|
version "4.14.197"
|
||||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"
|
||||||
|
|
Loading…
Reference in New Issue