[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
* WIP * Sorting working * Rough handling of hidden/muted * Better muted/hidden sorting and handling * Clarify some naming * Fix parents * Handle first reply under highlighted/composer * WIP RaW * WIP optimistic * Optimistic WIP * Little cleanup, inserting dupes * Re-org * Add in new optimistic insert logic * Update types * Sorta working linear view optimistic state * Simple working version, no pref for OP * Working optimistic reply insertions, preference for OP * Ensure deletes are coming through * WIP scroll handling * WIP scroll tweaks * Clean up scrolling * Clean up onPostSuccess * Add annotations * Fix highlighted post calc * WIP kill me * Update APIs * Nvm don't kill me * Fix optimistic insert * Handle read more cases in tree view * Basically working read more * Handle linear view * Reorg * More reorg * Split up thread post components * New reply tree layout * Fix up traversal metadata * Tighten some spacing * Use indent ya idiot * Some linear mode cleanup * Fix lines on read more items * Vibe coding to success * Almost there with read mores * Update APIs * Bump sdk * Update import * Checkpoint new traversal * Checkpoint cleanup * Checkpoint, need to fix blocked posts * Checkpoint: think we're good, needs more cleanup * Clean it up * Two passes only * Set to default params, update comment * Fix render bug on native * Checkpoint parent rendering, can opt for slower handling here * Clean up parent handling, reply handling * Fix read more extra space * Fix read more in linear view * Fix hidden reply handling, seen count, before/after calc * Update naming * Rename Slice to ThreadItem * Add basic post and anchor skeletons * Refactor client-side hidden * WIP hidden fetching * Update types * Clean up query a bit * Scrolling still broken * Ok maybe fix scrolling * Checkpoint move state into meta query * Don't load remote hidden items unless needed * skeleton view * Reset hidden items when params change * Split up traversal and avoid multiple passes * Clean up * Checkpoint: handling exhausted replies * Clean up traversal functions further * Clean up pagination * Limit optimistic reply depth * Handle optimistic insert in hidden replies * Share root query key for easier cache extraction * Make blurred posts not look like ass * Fix double deleted item * Make optimistic deleted state not look like crap in tree view * Fix parents traversal 4 real * Rename tree post * Make optimistic deletions of linear posts not look bad * Rename linear post components * Handle tombstone views * Rename read more component * Add moreParents handling * Align interaction states of read more * Fix read more on FF * Tree view skeleton * Reply composer skele * Remove hack for showing more replies * Checkpoint: sort change scrolling fixed * Checkpoint: learned new things, reset to base * Feature gate * Rename * Replace show more * Update settings screen * Update pkg and endpoint * Remove console * Eureka * Cleanup last commit * No tests atm * Remove scroll provider * Clean up callbacks, better error state * Remove todo * Remove todo * Remove todos * Format * Ok I think scrolling is solid * Add back mobile compose input * Ok need to compute headerHeight every time * Update comments * Ok button up web too * Threads v2 tweaks (#8467) * fix error screen collapsing * use personx icon for blocked posts * Remove height/width * Revert unused Header change * Clarify code * Relate consts to theme values * Remove debug code * Typo * Fix debounce of threads prefs * Update metadata comments, dev mode * Missed a spot * Clean up todo * Fix up no-unauthenticated posts * Truncate parents if no-unauth * Update getBranch docs * Remove debug code * Expand fetching in some cases * Clear scroll need for root post to fix jump bug * Fix reply composer skeleton state * Remove uneeded initialized value * Add profile shadow cache * Some metrics * prettier tweak * eslint ignore * Fix optimistic insertion * Typo * Rename, comment * Remove wait * Counter naming * Replies seen counter for moderated sub-trees * Remove borders on skeleton * Align tombstone with optimistic deletion state * Fix optimistic deletion for thread * Add tree view icon * Rename * Cleanup * Update settings copy * Header menu open metric * Bump package * Better reply prompt (#8474) * restyle reply prompt * hide bottom bar border for cleaner look * use new border hiding hook in DMs * create `transparentifyColor` function * adjust padding * fix padding in immersive lpayer * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Integrate post-source (cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9) --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
parent
143d5f3b81
commit
61004b887b
1
assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z"/></svg>
|
After Width: | Height: | Size: 371 B |
1
assets/icons/circlePlus_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/circlePlus_stroke2_corner0_rounded.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z"/></svg>
|
After Width: | Height: | Size: 321 B |
1
assets/icons/tree_stroke2_corner0_rounded.svg
Normal file
1
assets/icons/tree_stroke2_corner0_rounded.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>
|
After Width: | Height: | Size: 478 B |
@ -69,7 +69,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.9",
|
||||
"@atproto/api": "^0.15.14",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
|
@ -72,6 +72,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
if (isIOS) {
|
||||
@ -150,14 +151,16 @@ function InnerApp() {
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GestureHandlerRootView>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
|
@ -61,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
|
||||
|
||||
/**
|
||||
* Begin geolocation ASAP
|
||||
@ -131,10 +132,12 @@ function InnerApp() {
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
|
@ -1051,4 +1051,8 @@ export const atoms = {
|
||||
transform: [],
|
||||
},
|
||||
}) as {transform: Exclude<ViewStyle['transform'], string | undefined>},
|
||||
|
||||
pointer: web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
} as const
|
||||
|
48
src/alf/util/__tests__/colors.test.ts
Normal file
48
src/alf/util/__tests__/colors.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {jest} from '@jest/globals'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {transparentifyColor} from '../colorGeneration'
|
||||
|
||||
jest.mock('#/logger', () => ({
|
||||
logger: {warn: jest.fn()},
|
||||
}))
|
||||
|
||||
describe('transparentifyColor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('converts hsl() to hsla()', () => {
|
||||
const result = transparentifyColor('hsl(120 100% 50%)', 0.5)
|
||||
expect(result).toBe('hsla(120 100% 50%, 0.5)')
|
||||
})
|
||||
|
||||
it('converts hsl() to hsla() - fully transparent', () => {
|
||||
const result = transparentifyColor('hsl(120 100% 50%)', 0)
|
||||
expect(result).toBe('hsla(120 100% 50%, 0)')
|
||||
})
|
||||
|
||||
it('converts rgb() to rgba()', () => {
|
||||
const result = transparentifyColor('rgb(255 0 0)', 0.75)
|
||||
expect(result).toBe('rgba(255 0 0, 0.75)')
|
||||
})
|
||||
|
||||
it('expands 3-digit hex and appends alpha channel', () => {
|
||||
const result = transparentifyColor('#abc', 0.4)
|
||||
expect(result).toBe('#aabbcc66')
|
||||
})
|
||||
|
||||
it('appends alpha to 6-digit hex', () => {
|
||||
const result = transparentifyColor('#aabbcc', 0.4)
|
||||
expect(result).toBe('#aabbcc66')
|
||||
})
|
||||
|
||||
it('returns the original string and warns for unsupported formats', () => {
|
||||
const unsupported = 'blue'
|
||||
const result = transparentifyColor(unsupported, 0.5)
|
||||
expect(result).toBe(unsupported)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`Could not make '${unsupported}' transparent`,
|
||||
)
|
||||
})
|
||||
})
|
@ -1,3 +1,5 @@
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const BLUE_HUE = 211
|
||||
export const RED_HUE = 346
|
||||
export const GREEN_HUE = 152
|
||||
@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) {
|
||||
export const defaultScale = generateScale(6, 100)
|
||||
// dim shifted 6% lighter
|
||||
export const dimScale = generateScale(12, 100)
|
||||
|
||||
export function transparentifyColor(color: string, alpha: number) {
|
||||
if (color.startsWith('hsl(')) {
|
||||
return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})`
|
||||
} else if (color.startsWith('rgb(')) {
|
||||
return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})`
|
||||
} else if (color.startsWith('#')) {
|
||||
if (color.length === 7) {
|
||||
const alphaHex = Math.round(alpha * 255).toString(16)
|
||||
// Per MDN: If there is only one number, it is duplicated: e means ee
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
|
||||
return color.slice(0, 7) + alphaHex.padStart(2, alphaHex)
|
||||
} else if (color.length === 4) {
|
||||
// convert to 6-digit hex before adding alpha
|
||||
const [r, g, b] = color.slice(1).split('')
|
||||
const alphaHex = Math.round(alpha * 255).toString(16)
|
||||
return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart(
|
||||
2,
|
||||
alphaHex,
|
||||
)}`
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Could not make '${color}' transparent`)
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
107
src/components/Skeleton.tsx
Normal file
107
src/components/Skeleton.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import {type ReactNode} from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {
|
||||
atoms as a,
|
||||
flatten,
|
||||
type TextStyleProp,
|
||||
useAlf,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
} from '#/alf'
|
||||
import {normalizeTextStyles} from '#/alf/typography'
|
||||
|
||||
type SkeletonProps = {
|
||||
blend?: boolean
|
||||
}
|
||||
|
||||
export function Text({blend, style}: TextStyleProp & SkeletonProps) {
|
||||
const {fonts, flags, theme: t} = useAlf()
|
||||
const {width, ...flattened} = flatten(style)
|
||||
const {lineHeight = 14, ...rest} = normalizeTextStyles(
|
||||
[a.text_sm, a.leading_snug, flattened],
|
||||
{
|
||||
fontScale: fonts.scaleMultiplier,
|
||||
fontFamily: fonts.family,
|
||||
flags,
|
||||
},
|
||||
)
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_1, {maxWidth: width, paddingVertical: lineHeight * 0.15}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
height: lineHeight * 0.7,
|
||||
opacity: blend ? 0.6 : 1,
|
||||
},
|
||||
rest,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Circle({
|
||||
children,
|
||||
size,
|
||||
blend,
|
||||
style,
|
||||
}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
opacity: blend ? 0.6 : 1,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pill({
|
||||
size,
|
||||
blend,
|
||||
style,
|
||||
}: ViewStyleProp & {size: number} & SkeletonProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: size * 1.618,
|
||||
height: size,
|
||||
opacity: blend ? 0.6 : 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Col({
|
||||
children,
|
||||
style,
|
||||
}: ViewStyleProp & {children?: React.ReactNode}) {
|
||||
return <View style={[a.flex_1, style]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Row({
|
||||
children,
|
||||
style,
|
||||
}: ViewStyleProp & {children?: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, style]}>{children}</View>
|
||||
}
|
5
src/components/icons/ArrowTopCircle.tsx
Normal file
5
src/components/icons/ArrowTopCircle.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z',
|
||||
})
|
5
src/components/icons/CirclePlus.tsx
Normal file
5
src/components/icons/CirclePlus.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z',
|
||||
})
|
5
src/components/icons/Tree.tsx
Normal file
5
src/components/icons/Tree.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
|
||||
})
|
@ -1,17 +1,22 @@
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
|
||||
export async function retry<P>(
|
||||
retries: number,
|
||||
cond: (err: any) => boolean,
|
||||
fn: () => Promise<P>,
|
||||
shouldRetry: (err: any) => boolean,
|
||||
action: () => Promise<P>,
|
||||
delay?: number,
|
||||
): Promise<P> {
|
||||
let lastErr
|
||||
while (retries > 0) {
|
||||
try {
|
||||
return await fn()
|
||||
return await action()
|
||||
} catch (e: any) {
|
||||
lastErr = e
|
||||
if (cond(e)) {
|
||||
if (shouldRetry(e)) {
|
||||
if (delay) {
|
||||
await timeout(delay)
|
||||
}
|
||||
retries--
|
||||
continue
|
||||
}
|
||||
|
20
src/lib/hooks/useCallOnce.ts
Normal file
20
src/lib/hooks/useCallOnce.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
export enum OnceKey {
|
||||
PreferencesThread = 'preferences:thread',
|
||||
}
|
||||
|
||||
const called: Record<OnceKey, boolean> = {
|
||||
[OnceKey.PreferencesThread]: false,
|
||||
}
|
||||
|
||||
export function useCallOnce(key: OnceKey) {
|
||||
return useCallback(
|
||||
(cb: () => void) => {
|
||||
if (called[key] === true) return
|
||||
called[key] = true
|
||||
cb()
|
||||
},
|
||||
[key],
|
||||
)
|
||||
}
|
50
src/lib/hooks/useHideBottomBarBorder.tsx
Normal file
50
src/lib/hooks/useHideBottomBarBorder.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import {createContext, useCallback, useContext, useState} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
type HideBottomBarBorderSetter = () => () => void
|
||||
|
||||
const HideBottomBarBorderContext = createContext<boolean>(false)
|
||||
const HideBottomBarBorderSetterContext =
|
||||
createContext<HideBottomBarBorderSetter | null>(null)
|
||||
|
||||
export function useHideBottomBarBorderSetter() {
|
||||
const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
|
||||
if (!hideBottomBarBorder) {
|
||||
throw new Error(
|
||||
'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
|
||||
)
|
||||
}
|
||||
return hideBottomBarBorder
|
||||
}
|
||||
|
||||
export function useHideBottomBarBorderForScreen() {
|
||||
const hideBorder = useHideBottomBarBorderSetter()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const cleanup = hideBorder()
|
||||
return () => cleanup()
|
||||
}, [hideBorder]),
|
||||
)
|
||||
}
|
||||
|
||||
export function useHideBottomBarBorder() {
|
||||
return useContext(HideBottomBarBorderContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [refCount, setRefCount] = useState(0)
|
||||
|
||||
const setter = useCallback(() => {
|
||||
setRefCount(prev => prev + 1)
|
||||
return () => setRefCount(prev => prev - 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HideBottomBarBorderSetterContext.Provider value={setter}>
|
||||
<HideBottomBarBorderContext.Provider value={refCount > 0}>
|
||||
{children}
|
||||
</HideBottomBarBorderContext.Provider>
|
||||
</HideBottomBarBorderSetterContext.Provider>
|
||||
)
|
||||
}
|
@ -6,6 +6,7 @@ export type Gate =
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'post_threads_v2_unspecced'
|
||||
| 'remove_show_latest_button'
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
|
@ -434,4 +434,13 @@ export type MetricEvents = {
|
||||
'share:press:dmSelected': {}
|
||||
'share:press:recentDm': {}
|
||||
'share:press:embed': {}
|
||||
|
||||
'thread:click:showOtherReplies': {}
|
||||
'thread:preferences:load': {
|
||||
[key: string]: any
|
||||
}
|
||||
'thread:preferences:update': {
|
||||
[key: string]: any
|
||||
}
|
||||
'thread:click:headerMenuOpen': {}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
@ -106,6 +107,8 @@ export function MessagesList({
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
|
||||
useHideBottomBarBorderForScreen()
|
||||
|
||||
const flatListRef = useAnimatedRef<ListMethods>()
|
||||
|
||||
const [newMessagesPill, setNewMessagesPill] = useState({
|
||||
|
106
src/screens/PostThread/components/HeaderDropdown.tsx
Normal file
106
src/screens/PostThread/components/HeaderDropdown.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
export function HeaderDropdown({
|
||||
sort,
|
||||
view,
|
||||
setSort,
|
||||
setView,
|
||||
}: Pick<
|
||||
ThreadPreferences,
|
||||
'sort' | 'setSort' | 'view' | 'setView'
|
||||
>): React.ReactNode {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Thread options`)}>
|
||||
{({props: {onPress, ...props}}) => (
|
||||
<Button
|
||||
label={_(msg`Thread options`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={() => {
|
||||
logger.metric('thread:click:headerMenuOpen', {})
|
||||
onPress()
|
||||
}}
|
||||
{...props}>
|
||||
<ButtonIcon icon={SettingsSlider} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.LabelText>
|
||||
<Trans>Show replies as</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Linear`)}
|
||||
onPress={() => {
|
||||
setView('linear')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Linear</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={view === 'linear'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Threaded`)}
|
||||
onPress={() => {
|
||||
setView('tree')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Threaded</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={view === 'tree'} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.LabelText>
|
||||
<Trans>Reply sorting</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Top replies first`)}
|
||||
onPress={() => {
|
||||
setSort('top')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Top replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sort === 'top'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Oldest replies first`)}
|
||||
onPress={() => {
|
||||
setSort('oldest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Oldest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sort === 'oldest'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Newest replies first`)}
|
||||
onPress={() => {
|
||||
setSort('newest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Newest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sort === 'newest'} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
89
src/screens/PostThread/components/ThreadError.tsx
Normal file
89
src/screens/PostThread/components/ThreadError.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {OUTER_SPACE} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ThreadError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: Error
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const cleanError = useCleanError()
|
||||
|
||||
const {title, message} = useMemo(() => {
|
||||
let title = _(msg`Error loading post`)
|
||||
let message = _(msg`Something went wrong. Please try again in a moment.`)
|
||||
|
||||
const {raw, clean} = cleanError(error)
|
||||
|
||||
if (error.message.startsWith('Post not found')) {
|
||||
title = _(msg`Post not found`)
|
||||
message = clean || raw || message
|
||||
}
|
||||
|
||||
return {title, message}
|
||||
}, [_, error, cleanError])
|
||||
|
||||
return (
|
||||
<Layout.Center>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
{
|
||||
padding: OUTER_SPACE,
|
||||
paddingTop: OUTER_SPACE * 2,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.gap_xl,
|
||||
{
|
||||
maxWidth: 260,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text
|
||||
style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Retry`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary_inverted"
|
||||
onPress={onRetry}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={RetryIcon} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</Layout.Center>
|
||||
)
|
||||
}
|
706
src/screens/PostThread/components/ThreadItemAnchor.tsx
Normal file
706
src/screens/PostThread/components/ThreadItemAnchor.tsx
Normal file
@ -0,0 +1,706 @@
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
type AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {s} from '#/lib/styles'
|
||||
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
POST_TOMBSTONE,
|
||||
type Shadow,
|
||||
usePostShadow,
|
||||
} from '#/state/cache/post-shadow'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {type PostSource} from '#/state/unstable-post-source'
|
||||
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
OUTER_SPACE,
|
||||
REPLY_LINE_WIDTH,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {colors} from '#/components/Admonition'
|
||||
import {Button} from '#/components/Button'
|
||||
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
|
||||
import {WhoCanReply} from '#/components/WhoCanReply'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function ThreadItemAnchor({
|
||||
item,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
postSource,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
postSource?: PostSource
|
||||
}) {
|
||||
const postShadow = usePostShadow(item.value.post)
|
||||
const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
|
||||
const isRoot = threadRootUri === item.uri
|
||||
|
||||
if (postShadow === POST_TOMBSTONE) {
|
||||
return <ThreadItemAnchorDeleted isRoot={isRoot} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemAnchorInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
isRoot={isRoot}
|
||||
postShadow={postShadow}
|
||||
onPostSuccess={onPostSuccess}
|
||||
threadgateRecord={threadgateRecord}
|
||||
postSource={postSource}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
paddingBottom: OUTER_SPACE,
|
||||
},
|
||||
isRoot && [a.pt_lg],
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
width: LINEAR_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
<TrashIcon style={[t.atoms.text_contrast_medium]} />
|
||||
</View>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Post has been deleted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return !isRoot ? (
|
||||
<View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
|
||||
<View style={{width: 42}}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: REPLY_LINE_WIDTH,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
flexGrow: 1,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
|
||||
const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
item,
|
||||
isRoot,
|
||||
postShadow,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
postSource,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
isRoot: boolean
|
||||
postShadow: Shadow<AppBskyFeedDefs.PostView>
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
postSource?: PostSource
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
|
||||
|
||||
const post = item.value.post
|
||||
const record = item.value.post.record
|
||||
const moderation = item.moderation
|
||||
const authorShadow = useProfileShadow(post.author)
|
||||
const {isActive: live} = useActorStatus(post.author)
|
||||
const richText = useMemo(
|
||||
() =>
|
||||
new RichTextAPI({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
|
||||
const threadRootUri = record.reply?.root?.uri || post.uri
|
||||
const authorHref = makeProfileLink(post.author)
|
||||
const authorTitle = post.author.handle
|
||||
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
|
||||
|
||||
const likesHref = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||
}, [post.uri, post.author])
|
||||
const repostsHref = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
|
||||
}, [post.uri, post.author])
|
||||
const quotesHref = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
|
||||
}, [post.uri, post.author])
|
||||
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
|
||||
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
||||
const isControlledByViewer =
|
||||
new AtUri(threadRootUri).host === currentAccount?.did
|
||||
return isControlledByViewer && isPostHiddenByThreadgate
|
||||
? [
|
||||
{
|
||||
type: 'reply-hidden',
|
||||
source: {type: 'user', did: currentAccount?.did},
|
||||
priority: 6,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
|
||||
const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
|
||||
rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
|
||||
)
|
||||
const showFollowButton =
|
||||
currentAccount?.did !== post.author.did && !onlyFollowersCanReply
|
||||
|
||||
const viaRepost = useMemo(() => {
|
||||
const reason = postSource?.post.reason
|
||||
|
||||
if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
|
||||
return {
|
||||
uri: reason.uri,
|
||||
cid: reason.cid,
|
||||
}
|
||||
}
|
||||
}, [postSource])
|
||||
|
||||
const onPressReply = useCallback(() => {
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
onPostSuccess: onPostSuccess,
|
||||
})
|
||||
|
||||
if (postSource) {
|
||||
feedFeedback.sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#interactionReply',
|
||||
feedContext: postSource.post.feedContext,
|
||||
reqId: postSource.post.reqId,
|
||||
})
|
||||
}
|
||||
}, [
|
||||
openComposer,
|
||||
post,
|
||||
record,
|
||||
onPostSuccess,
|
||||
moderation,
|
||||
postSource,
|
||||
feedFeedback,
|
||||
])
|
||||
|
||||
const onOpenAuthor = () => {
|
||||
if (postSource) {
|
||||
feedFeedback.sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughAuthor',
|
||||
feedContext: postSource.post.feedContext,
|
||||
reqId: postSource.post.reqId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenEmbed = () => {
|
||||
if (postSource) {
|
||||
feedFeedback.sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughEmbed',
|
||||
feedContext: postSource.post.feedContext,
|
||||
reqId: postSource.post.reqId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
|
||||
|
||||
<View
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
style={[
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
},
|
||||
isRoot && [a.pt_lg],
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
|
||||
<PreviewableUserAvatar
|
||||
size={42}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
onBeforePress={onOpenAuthor}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<Link
|
||||
style={[a.flex_shrink]}
|
||||
href={authorHref}
|
||||
title={authorTitle}
|
||||
onBeforePress={onOpenAuthor}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
post.author.displayName ||
|
||||
sanitizeHandle(post.author.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<View style={[{paddingLeft: 3, top: -1}]}>
|
||||
<VerificationCheckButton profile={authorShadow} size="md" />
|
||||
</View>
|
||||
</View>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
{showFollowButton && (
|
||||
<View>
|
||||
<PostThreadFollowBtn did={post.author.did} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={[a.pb_sm]}>
|
||||
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
|
||||
<ContentHider
|
||||
modui={moderation.ui('contentView')}
|
||||
ignoreMute
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentView')}
|
||||
size="lg"
|
||||
includeMute
|
||||
style={[a.pb_sm]}
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<RichText
|
||||
enableTags
|
||||
selectable
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_xl]}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.py_xs]}>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.ThreadHighlighted}
|
||||
onOpen={onOpenEmbed}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ContentHider>
|
||||
<ExpandedPostDetails
|
||||
post={item.value.post}
|
||||
isThreadAuthor={isThreadAuthor}
|
||||
/>
|
||||
{post.repostCount !== 0 ||
|
||||
post.likeCount !== 0 ||
|
||||
post.quoteCount !== 0 ? (
|
||||
// Show this section unless we're *sure* it has no engagement.
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_lg,
|
||||
a.border_t,
|
||||
a.border_b,
|
||||
a.mt_md,
|
||||
a.py_md,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||
<Link href={repostsHref} title={_(msg`Reposts of this post`)}>
|
||||
<Text
|
||||
testID="repostCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.repostCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.repostCount}
|
||||
one="repost"
|
||||
other="reposts"
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.quoteCount != null &&
|
||||
post.quoteCount !== 0 &&
|
||||
!post.viewer?.embeddingDisabled ? (
|
||||
<Link href={quotesHref} title={_(msg`Quotes of this post`)}>
|
||||
<Text
|
||||
testID="quoteCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.quoteCount)}
|
||||
</Text>{' '}
|
||||
<Plural
|
||||
value={post.quoteCount}
|
||||
one="quote"
|
||||
other="quotes"
|
||||
/>
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.likeCount != null && post.likeCount !== 0 ? (
|
||||
<Link href={likesHref} title={_(msg`Likes on this post`)}>
|
||||
<Text
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{formatCount(i18n, post.likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={post.likeCount} one="like" other="likes" />
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.pb_2xs,
|
||||
{
|
||||
marginLeft: -5,
|
||||
},
|
||||
]}>
|
||||
<FeedFeedbackProvider value={feedFeedback}>
|
||||
<PostControls
|
||||
big
|
||||
post={postShadow}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
threadgateRecord={threadgateRecord}
|
||||
feedContext={postSource?.post?.feedContext}
|
||||
reqId={postSource?.post?.reqId}
|
||||
viaRepost={viaRepost}
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function ExpandedPostDetails({
|
||||
post,
|
||||
isThreadAuthor,
|
||||
}: {
|
||||
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
|
||||
isThreadAuthor: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const openLink = useOpenLink()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
|
||||
const translatorUrl = getTranslatorLink(
|
||||
post.record?.text || '',
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
const needsTranslation = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
langPrefs.primaryLanguage &&
|
||||
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
|
||||
),
|
||||
[post, langPrefs.primaryLanguage],
|
||||
)
|
||||
|
||||
const onTranslatePress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
openLink(translatorUrl, true)
|
||||
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
logger.metric('translate', {
|
||||
sourceLanguages: post.record.langs ?? [],
|
||||
targetLanguage: langPrefs.primaryLanguage,
|
||||
textLength: post.record.text.length,
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[openLink, translatorUrl, langPrefs, post],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md, a.pt_md, a.align_start]}>
|
||||
<BackdatedPostIndicator post={post} />
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{niceDate(i18n, post.indexedAt)}
|
||||
</Text>
|
||||
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
||||
{needsTranslation && (
|
||||
<>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
·
|
||||
</Text>
|
||||
|
||||
<InlineLinkText
|
||||
to={translatorUrl}
|
||||
label={_(msg`Translate`)}
|
||||
style={[a.text_sm]}
|
||||
onPress={onTranslatePress}>
|
||||
<Trans>Translate</Trans>
|
||||
</InlineLinkText>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const indexedAt = new Date(post.indexedAt)
|
||||
const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
? new Date(post.record.createdAt)
|
||||
: new Date(post.indexedAt)
|
||||
|
||||
// backdated if createdAt is 24 hours or more before indexedAt
|
||||
const isBackdated =
|
||||
indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
|
||||
|
||||
if (!isBackdated) return null
|
||||
|
||||
const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Archived post`)}
|
||||
accessibilityHint={_(
|
||||
msg`Shows information about when this post was created`,
|
||||
)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||
{
|
||||
gap: 3,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
]}>
|
||||
<CalendarClockIcon fill={orange} size="sm" aria-hidden />
|
||||
<Text
|
||||
style={[
|
||||
a.text_xs,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Archived post</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
This post claims to have been created on{' '}
|
||||
<RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
|
||||
but was first seen by Bluesky on{' '}
|
||||
<RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
a.pb_xl,
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky cannot confirm the authenticity of the claimed date.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function getThreadAuthor(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
record: AppBskyFeedPost.Record,
|
||||
): string {
|
||||
if (!record.reply) {
|
||||
return post.author.did
|
||||
}
|
||||
try {
|
||||
return new AtUri(record.reply.root.uri).host
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function ThreadItemAnchorSkeleton() {
|
||||
return (
|
||||
<View style={[a.p_lg, a.gap_md]}>
|
||||
<Skele.Row style={[a.align_center, a.gap_md]}>
|
||||
<Skele.Circle size={42} />
|
||||
|
||||
<Skele.Col>
|
||||
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
|
||||
</Skele.Col>
|
||||
</Skele.Row>
|
||||
|
||||
<View>
|
||||
<Skele.Text style={[a.text_xl, {width: '100%'}]} />
|
||||
<Skele.Text style={[a.text_xl, {width: '60%'}]} />
|
||||
</View>
|
||||
|
||||
<Skele.Text style={[a.text_sm, {width: '50%'}]} />
|
||||
|
||||
<Skele.Row style={[a.justify_between]}>
|
||||
<Skele.Pill blend size={24} />
|
||||
<Skele.Pill blend size={24} />
|
||||
<Skele.Pill blend size={24} />
|
||||
<Skele.Circle blend size={24} />
|
||||
<Skele.Circle blend size={24} />
|
||||
</Skele.Row>
|
||||
</View>
|
||||
)
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ThreadItemAnchorNoUnauthenticated() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.p_lg, a.gap_md]}>
|
||||
<Skele.Row style={[a.align_center, a.gap_md]}>
|
||||
<Skele.Circle size={42}>
|
||||
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Skele.Circle>
|
||||
|
||||
<Skele.Col>
|
||||
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
|
||||
</Skele.Col>
|
||||
</Skele.Row>
|
||||
|
||||
<View style={[a.py_sm]}>
|
||||
<Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>You must sign in to view this post.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
405
src/screens/PostThread/components/ThreadItemPost.tsx
Normal file
405
src/screens/PostThread/components/ThreadItemPost.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {
|
||||
POST_TOMBSTONE,
|
||||
type Shadow,
|
||||
usePostShadow,
|
||||
} from '#/state/cache/post-shadow'
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {TextLink} from '#/view/com/util/Link'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
OUTER_SPACE,
|
||||
REPLY_LINE_WIDTH,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {PostHider} from '#/components/moderation/PostHider'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type ThreadItemPostProps = {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
overrides?: {
|
||||
moderation?: boolean
|
||||
topBorder?: boolean
|
||||
}
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}
|
||||
|
||||
export function ThreadItemPost({
|
||||
item,
|
||||
overrides,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
}: ThreadItemPostProps) {
|
||||
const postShadow = usePostShadow(item.value.post)
|
||||
|
||||
if (postShadow === POST_TOMBSTONE) {
|
||||
return <ThreadItemPostDeleted item={item} overrides={overrides} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemPostInner
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
overrides={overrides}
|
||||
onPostSuccess={onPostSuccess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadItemPostDeleted({
|
||||
item,
|
||||
overrides,
|
||||
}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
|
||||
<ThreadItemPostParentReplyLine item={item} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
width: LINEAR_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
<TrashIcon style={[t.atoms.text_contrast_medium]} />
|
||||
</View>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Post has been deleted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[{height: 4}]} />
|
||||
</ThreadItemPostOuterWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
|
||||
item,
|
||||
overrides,
|
||||
children,
|
||||
}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const showTopBorder =
|
||||
!item.ui.showParentReplyLine && overrides?.topBorder !== true
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
},
|
||||
// If there's no next child, add a little padding to bottom
|
||||
!item.ui.showChildReplyLine &&
|
||||
!item.ui.precedesChildReadMore && {
|
||||
paddingBottom: OUTER_SPACE / 2,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Provides some space between posts as well as contains the reply line
|
||||
*/
|
||||
const ThreadItemPostParentReplyLine = memo(
|
||||
function ThreadItemPostParentReplyLine({
|
||||
item,
|
||||
}: Pick<ThreadItemPostProps, 'item'>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.flex_row, {height: 12}]}>
|
||||
<View style={{width: LINEAR_AVI_WIDTH}}>
|
||||
{item.ui.showParentReplyLine && (
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.flex_1,
|
||||
a.mb_xs,
|
||||
{
|
||||
width: REPLY_LINE_WIDTH,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
||||
item,
|
||||
postShadow,
|
||||
overrides,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
}: ThreadItemPostProps & {
|
||||
postShadow: Shadow<AppBskyFeedDefs.PostView>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const post = item.value.post
|
||||
const record = item.value.post.record
|
||||
const moderation = item.moderation
|
||||
const richText = useMemo(
|
||||
() =>
|
||||
new RichTextAPI({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
const [limitLines, setLimitLines] = useState(
|
||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const threadRootUri = record.reply?.root?.uri || post.uri
|
||||
const postHref = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
}, [post.uri, post.author])
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
|
||||
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
||||
const isControlledByViewer =
|
||||
new AtUri(threadRootUri).host === currentAccount?.did
|
||||
return isControlledByViewer && isPostHiddenByThreadgate
|
||||
? [
|
||||
{
|
||||
type: 'reply-hidden',
|
||||
source: {type: 'user', did: currentAccount?.did},
|
||||
priority: 6,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
|
||||
|
||||
const onPressReply = useCallback(() => {
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
onPostSuccess: onPostSuccess,
|
||||
})
|
||||
}, [openComposer, post, record, onPostSuccess, moderation])
|
||||
|
||||
const onPressShowMore = useCallback(() => {
|
||||
setLimitLines(false)
|
||||
}, [setLimitLines])
|
||||
|
||||
const {isActive: live} = useActorStatus(post.author)
|
||||
|
||||
return (
|
||||
<SubtleHover>
|
||||
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
|
||||
<PostHider
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
href={postHref}
|
||||
disabled={overrides?.moderation === true}
|
||||
modui={moderation.ui('contentList')}
|
||||
iconSize={LINEAR_AVI_WIDTH}
|
||||
iconStyles={{marginLeft: 2, marginRight: 2}}
|
||||
profile={post.author}
|
||||
interpretFilterAsBlur>
|
||||
<ThreadItemPostParentReplyLine item={item} />
|
||||
|
||||
<View style={[a.flex_row, a.gap_md]}>
|
||||
<View>
|
||||
<PreviewableUserAvatar
|
||||
size={LINEAR_AVI_WIDTH}
|
||||
profile={post.author}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
live={live}
|
||||
/>
|
||||
|
||||
{(item.ui.showChildReplyLine ||
|
||||
item.ui.precedesChildReadMore) && (
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.mt_xs,
|
||||
a.flex_1,
|
||||
{
|
||||
width: REPLY_LINE_WIDTH,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_1]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={postHref}
|
||||
style={[a.pb_xs]}
|
||||
/>
|
||||
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
style={[a.pb_2xs]}
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
) : undefined}
|
||||
{limitLines ? (
|
||||
<TextLink
|
||||
text={_(msg`Show More`)}
|
||||
style={pal.link}
|
||||
onPress={onPressShowMore}
|
||||
href="#"
|
||||
/>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<PostControls
|
||||
post={postShadow}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
threadgateRecord={threadgateRecord}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</PostHider>
|
||||
</ThreadItemPostOuterWrapper>
|
||||
</SubtleHover>
|
||||
)
|
||||
})
|
||||
|
||||
function SubtleHover({children}: {children: ReactNode}) {
|
||||
const {
|
||||
state: hover,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={onHoverIn}
|
||||
onPointerLeave={onHoverOut}
|
||||
style={a.pointer}>
|
||||
<SubtleWebHover hover={hover} />
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreadItemPostSkeleton({index}: {index: number}) {
|
||||
const even = index % 2 === 0
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
|
||||
a.gap_md,
|
||||
]}>
|
||||
<Skele.Row style={[a.align_start, a.gap_md]}>
|
||||
<Skele.Circle size={LINEAR_AVI_WIDTH} />
|
||||
|
||||
<Skele.Col style={[a.gap_xs]}>
|
||||
<Skele.Row style={[a.gap_sm]}>
|
||||
<Skele.Text style={[a.text_md, {width: '20%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
|
||||
</Skele.Row>
|
||||
|
||||
<Skele.Col>
|
||||
{even ? (
|
||||
<>
|
||||
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
|
||||
</>
|
||||
) : (
|
||||
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
|
||||
)}
|
||||
</Skele.Col>
|
||||
|
||||
<Skele.Row style={[a.justify_between, a.pt_xs]}>
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Circle blend size={16} />
|
||||
<View />
|
||||
</Skele.Row>
|
||||
</Skele.Col>
|
||||
</Skele.Row>
|
||||
</View>
|
||||
)
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
OUTER_SPACE,
|
||||
REPLY_LINE_WIDTH,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ThreadItemPostNoUnauthenticated({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[{paddingHorizontal: OUTER_SPACE}]}>
|
||||
<View style={[a.flex_row, {height: 12}]}>
|
||||
<View style={{width: LINEAR_AVI_WIDTH}}>
|
||||
{item.ui.showParentReplyLine && (
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.flex_1,
|
||||
a.mb_xs,
|
||||
{
|
||||
width: REPLY_LINE_WIDTH,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Skele.Row style={[a.align_center, a.gap_md]}>
|
||||
<Skele.Circle size={LINEAR_AVI_WIDTH}>
|
||||
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
</Skele.Circle>
|
||||
|
||||
<Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>You must sign in to view this post.</Trans>
|
||||
</Text>
|
||||
</Skele.Row>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_center,
|
||||
{
|
||||
height: OUTER_SPACE / 1.5,
|
||||
width: LINEAR_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
{item.ui.showChildReplyLine && (
|
||||
<View
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.h_full,
|
||||
{
|
||||
width: REPLY_LINE_WIDTH,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type ThreadItemPostTombstoneProps = {
|
||||
type: 'not-found' | 'blocked'
|
||||
}
|
||||
|
||||
export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {copy, Icon} = useMemo(() => {
|
||||
switch (type) {
|
||||
case 'blocked':
|
||||
return {copy: _(msg`Post blocked`), Icon: PersonXIcon}
|
||||
case 'not-found':
|
||||
default:
|
||||
return {copy: _(msg`Post not found`), Icon: TrashIcon}
|
||||
}
|
||||
}, [_, type])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mb_xs,
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
paddingTop: OUTER_SPACE / 1.2,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{paddingVertical: OUTER_SPACE / 1.2},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}>
|
||||
<Icon style={[t.atoms.text_contrast_medium]} />
|
||||
</View>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{copy}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
107
src/screens/PostThread/components/ThreadItemReadMore.tsx
Normal file
107
src/screens/PostThread/components/ThreadItemReadMore.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import {memo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
type PostThreadParams,
|
||||
type ThreadItem,
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
REPLY_LINE_WIDTH,
|
||||
TREE_AVI_WIDTH,
|
||||
TREE_INDENT,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export const ThreadItemReadMore = memo(function ThreadItemReadMore({
|
||||
item,
|
||||
view,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'readMore'}>
|
||||
view: PostThreadParams['view']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const isTreeView = view === 'tree'
|
||||
const indent = Math.max(0, item.depth - 1)
|
||||
|
||||
const spacers = isTreeView
|
||||
? Array.from(Array(indent)).map((_, n: number) => {
|
||||
const isSkipped = item.skippedIndentIndices.has(n)
|
||||
return (
|
||||
<View
|
||||
key={`${item.key}-padding-${n}`}
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
|
||||
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
|
||||
left: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: null
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row]}>
|
||||
{spacers}
|
||||
<View
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
marginLeft: isTreeView
|
||||
? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1
|
||||
: (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16,
|
||||
borderLeftWidth: 2,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomLeftRadius: a.rounded_sm.borderRadius,
|
||||
height: 18, // magic, Link below is 38px tall
|
||||
width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
label={_(msg`Read more replies`)}
|
||||
to={item.href}
|
||||
style={[a.pt_sm, a.pb_md, a.gap_xs]}>
|
||||
{({hovered, pressed}) => {
|
||||
const interacted = hovered || pressed
|
||||
return (
|
||||
<>
|
||||
<CirclePlus
|
||||
fill={
|
||||
interacted
|
||||
? t.atoms.text_contrast_high.color
|
||||
: t.atoms.text_contrast_low.color
|
||||
}
|
||||
width={18}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
interacted && a.underline,
|
||||
]}>
|
||||
<Trans>
|
||||
Read {item.moreReplies} more{' '}
|
||||
<Plural
|
||||
one="reply"
|
||||
other="replies"
|
||||
value={item.moreReplies}
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
})
|
89
src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
Normal file
89
src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import {memo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
OUTER_SPACE,
|
||||
REPLY_LINE_WIDTH,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'readMoreUp'}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Link
|
||||
label={_(msg`Continue thread`)}
|
||||
to={item.href}
|
||||
style={[
|
||||
a.gap_xs,
|
||||
{
|
||||
paddingTop: OUTER_SPACE,
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
},
|
||||
]}>
|
||||
{({hovered, pressed}) => {
|
||||
const interacted = hovered || pressed
|
||||
return (
|
||||
<View>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
{
|
||||
width: LINEAR_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
<UpIcon
|
||||
fill={
|
||||
interacted
|
||||
? t.atoms.text_contrast_high.color
|
||||
: t.atoms.text_contrast_low.color
|
||||
}
|
||||
width={24}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
interacted && [a.underline],
|
||||
]}>
|
||||
<Trans>Continue thread...</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
{
|
||||
width: LINEAR_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.mt_xs,
|
||||
{
|
||||
height: OUTER_SPACE / 2,
|
||||
width: REPLY_LINE_WIDTH,
|
||||
backgroundColor: t.atoms.border_contrast_low.borderColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
)
|
||||
})
|
@ -0,0 +1,31 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {OUTER_SPACE} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
|
||||
/*
|
||||
* Wacky padding here is just replicating what we have in the actual
|
||||
* `PostThreadComposePrompt` component
|
||||
*/
|
||||
export function ThreadItemReplyComposerSkeleton() {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
|
||||
<Skele.Circle size={gtMobile ? 24 : 22} />
|
||||
<Skele.Text style={[a.text_md]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const label = _(msg`Show more replies`)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onPress()
|
||||
logger.metric('thread:click:showOtherReplies', {})
|
||||
}}
|
||||
label={label}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.py_lg,
|
||||
a.px_xl,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
marginRight: 4,
|
||||
},
|
||||
]}>
|
||||
<EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
456
src/screens/PostThread/components/ThreadItemTreePost.tsx
Normal file
456
src/screens/PostThread/components/ThreadItemTreePost.tsx
Normal file
@ -0,0 +1,456 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {
|
||||
POST_TOMBSTONE,
|
||||
type Shadow,
|
||||
usePostShadow,
|
||||
} from '#/state/cache/post-shadow'
|
||||
import {type ThreadItem} from '#/state/queries/usePostThread/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {TextLink} from '#/view/com/util/Link'
|
||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {
|
||||
OUTER_SPACE,
|
||||
REPLY_LINE_WIDTH,
|
||||
TREE_AVI_WIDTH,
|
||||
TREE_INDENT,
|
||||
} from '#/screens/PostThread/const'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {PostHider} from '#/components/moderation/PostHider'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {PostControls} from '#/components/PostControls'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/**
|
||||
* Mimic the space in PostMeta
|
||||
*/
|
||||
const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
|
||||
|
||||
export function ThreadItemTreePost({
|
||||
item,
|
||||
overrides,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
overrides?: {
|
||||
moderation?: boolean
|
||||
topBorder?: boolean
|
||||
}
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}) {
|
||||
const postShadow = usePostShadow(item.value.post)
|
||||
|
||||
if (postShadow === POST_TOMBSTONE) {
|
||||
return <ThreadItemTreePostDeleted item={item} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadItemTreePostInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadow.uri}
|
||||
item={item}
|
||||
postShadow={postShadow}
|
||||
threadgateRecord={threadgateRecord}
|
||||
overrides={overrides}
|
||||
onPostSuccess={onPostSuccess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadItemTreePostDeleted({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<ThreadItemTreePostOuterWrapper item={item}>
|
||||
<ThreadItemTreePostInnerWrapper item={item}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 6,
|
||||
paddingHorizontal: OUTER_SPACE / 2,
|
||||
height: TREE_AVI_WIDTH,
|
||||
},
|
||||
]}>
|
||||
<TrashIcon style={[t.atoms.text]} width={14} />
|
||||
<Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
|
||||
<Trans>Post has been deleted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{item.ui.isLastChild && !item.ui.precedesChildReadMore && (
|
||||
<View style={{height: OUTER_SPACE / 2}} />
|
||||
)}
|
||||
</ThreadItemTreePostInnerWrapper>
|
||||
</ThreadItemTreePostOuterWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const ThreadItemTreePostOuterWrapper = memo(
|
||||
function ThreadItemTreePostOuterWrapper({
|
||||
item,
|
||||
children,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const indents = Math.max(0, item.ui.indent - 1)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
item.ui.indent === 1 &&
|
||||
!item.ui.showParentReplyLine && [
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
],
|
||||
]}>
|
||||
{Array.from(Array(indents)).map((_, n: number) => {
|
||||
const isSkipped = item.ui.skippedIndentIndices.has(n)
|
||||
return (
|
||||
<View
|
||||
key={`${item.value.post.uri}-padding-${n}`}
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
|
||||
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
|
||||
left: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ThreadItemTreePostInnerWrapper = memo(
|
||||
function ThreadItemTreePostInnerWrapper({
|
||||
item,
|
||||
children,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1, // TODO check on ios
|
||||
{
|
||||
paddingHorizontal: OUTER_SPACE,
|
||||
paddingTop: OUTER_SPACE / 2,
|
||||
},
|
||||
item.ui.indent === 1 && [
|
||||
!item.ui.showParentReplyLine && a.pt_lg,
|
||||
!item.ui.showChildReplyLine && a.pb_sm,
|
||||
],
|
||||
item.ui.isLastChild &&
|
||||
!item.ui.precedesChildReadMore && [
|
||||
{
|
||||
paddingBottom: OUTER_SPACE / 2,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
{item.ui.indent > 1 && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
left: -1,
|
||||
top: 0,
|
||||
height:
|
||||
TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
|
||||
width: OUTER_SPACE,
|
||||
borderLeftWidth: REPLY_LINE_WIDTH,
|
||||
borderBottomWidth: REPLY_LINE_WIDTH,
|
||||
borderBottomLeftRadius: a.rounded_sm.borderRadius,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ThreadItemTreeReplyChildReplyLine = memo(
|
||||
function ThreadItemTreeReplyChildReplyLine({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
|
||||
{item.ui.showChildReplyLine && (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
borderRightWidth: 2,
|
||||
width: '50%',
|
||||
left: -1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
||||
item,
|
||||
postShadow,
|
||||
overrides,
|
||||
onPostSuccess,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
item: Extract<ThreadItem, {type: 'threadPost'}>
|
||||
postShadow: Shadow<AppBskyFeedDefs.PostView>
|
||||
overrides?: {
|
||||
moderation?: boolean
|
||||
topBorder?: boolean
|
||||
}
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const post = item.value.post
|
||||
const record = item.value.post.record
|
||||
const moderation = item.moderation
|
||||
const richText = useMemo(
|
||||
() =>
|
||||
new RichTextAPI({
|
||||
text: record.text,
|
||||
facets: record.facets,
|
||||
}),
|
||||
[record],
|
||||
)
|
||||
const [limitLines, setLimitLines] = React.useState(
|
||||
() => countLines(richText?.text) >= MAX_POST_LINES,
|
||||
)
|
||||
const threadRootUri = record.reply?.root?.uri || post.uri
|
||||
const postHref = React.useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
}, [post.uri, post.author])
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
|
||||
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
||||
const isControlledByViewer =
|
||||
new AtUri(threadRootUri).host === currentAccount?.did
|
||||
return isControlledByViewer && isPostHiddenByThreadgate
|
||||
? [
|
||||
{
|
||||
type: 'reply-hidden',
|
||||
source: {type: 'user', did: currentAccount?.did},
|
||||
priority: 6,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
onPostSuccess: onPostSuccess,
|
||||
})
|
||||
}, [openComposer, post, record, onPostSuccess, moderation])
|
||||
|
||||
const onPressShowMore = React.useCallback(() => {
|
||||
setLimitLines(false)
|
||||
}, [setLimitLines])
|
||||
|
||||
return (
|
||||
<ThreadItemTreePostOuterWrapper item={item}>
|
||||
<SubtleHover>
|
||||
<PostHider
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
href={postHref}
|
||||
disabled={overrides?.moderation === true}
|
||||
modui={moderation.ui('contentList')}
|
||||
iconSize={42}
|
||||
iconStyles={{marginLeft: 2, marginRight: 2}}
|
||||
profile={post.author}
|
||||
interpretFilterAsBlur>
|
||||
<ThreadItemTreePostInnerWrapper item={item}>
|
||||
<View style={[a.flex_1]}>
|
||||
<PostMeta
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={postHref}
|
||||
avatarSize={TREE_AVI_WIDTH}
|
||||
style={[a.pb_2xs]}
|
||||
showAvatar
|
||||
/>
|
||||
<View style={[a.flex_row]}>
|
||||
<ThreadItemTreeReplyChildReplyLine item={item} />
|
||||
<View style={[a.flex_1]}>
|
||||
<LabelsOnMyPost post={post} style={[a.pb_2xs]} />
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
style={[a.pb_2xs]}
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
authorHandle={post.author.handle}
|
||||
shouldProxyLinks={true}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{limitLines ? (
|
||||
<TextLink
|
||||
text={_(msg`Show More`)}
|
||||
style={pal.link}
|
||||
onPress={onPressShowMore}
|
||||
href="#"
|
||||
/>
|
||||
) : undefined}
|
||||
{post.embed && (
|
||||
<View style={[a.pb_xs]}>
|
||||
<PostEmbeds
|
||||
embed={post.embed}
|
||||
moderation={moderation}
|
||||
viewContext={PostEmbedViewContext.Feed}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<PostControls
|
||||
post={postShadow}
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
threadgateRecord={threadgateRecord}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ThreadItemTreePostInnerWrapper>
|
||||
</PostHider>
|
||||
</SubtleHover>
|
||||
</ThreadItemTreePostOuterWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
function SubtleHover({children}: {children: React.ReactNode}) {
|
||||
const {
|
||||
state: hover,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={onHoverIn}
|
||||
onPointerLeave={onHoverOut}
|
||||
style={[a.flex_1, a.pointer]}>
|
||||
<SubtleWebHover hover={hover} />
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreadItemTreePostSkeleton({index}: {index: number}) {
|
||||
const t = useTheme()
|
||||
const even = index % 2 === 0
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
|
||||
a.gap_md,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Skele.Row style={[a.align_start, a.gap_md]}>
|
||||
<Skele.Circle size={TREE_AVI_WIDTH} />
|
||||
|
||||
<Skele.Col style={[a.gap_xs]}>
|
||||
<Skele.Row style={[a.gap_sm]}>
|
||||
<Skele.Text style={[a.text_md, {width: '20%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
|
||||
</Skele.Row>
|
||||
|
||||
<Skele.Col>
|
||||
{even ? (
|
||||
<>
|
||||
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
|
||||
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
|
||||
</>
|
||||
) : (
|
||||
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
|
||||
)}
|
||||
</Skele.Col>
|
||||
|
||||
<Skele.Row style={[a.justify_between, a.pt_xs]}>
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Pill blend size={16} />
|
||||
<Skele.Circle blend size={16} />
|
||||
<View />
|
||||
</Skele.Row>
|
||||
</Skele.Col>
|
||||
</Skele.Row>
|
||||
</View>
|
||||
)
|
||||
}
|
7
src/screens/PostThread/const.ts
Normal file
7
src/screens/PostThread/const.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import {tokens} from '#/alf'
|
||||
|
||||
export const TREE_INDENT = tokens.space.lg
|
||||
export const TREE_AVI_WIDTH = 24
|
||||
export const LINEAR_AVI_WIDTH = 42
|
||||
export const REPLY_LINE_WIDTH = 2
|
||||
export const OUTER_SPACE = tokens.space.lg
|
577
src/screens/PostThread/index.tsx
Normal file
577
src/screens/PostThread/index.tsx
Normal file
@ -0,0 +1,577 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useUnstablePostSource} from '#/state/unstable-post-source'
|
||||
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
|
||||
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
|
||||
import {
|
||||
ThreadItemAnchor,
|
||||
ThreadItemAnchorSkeleton,
|
||||
} from '#/screens/PostThread/components/ThreadItemAnchor'
|
||||
import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
|
||||
import {
|
||||
ThreadItemPost,
|
||||
ThreadItemPostSkeleton,
|
||||
} from '#/screens/PostThread/components/ThreadItemPost'
|
||||
import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
|
||||
import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
|
||||
import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
|
||||
import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
|
||||
import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
|
||||
import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
|
||||
import {
|
||||
ThreadItemTreePost,
|
||||
ThreadItemTreePostSkeleton,
|
||||
} from '#/screens/PostThread/components/ThreadItemTreePost'
|
||||
import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
|
||||
const PARENT_CHUNK_SIZE = 5
|
||||
const CHILDREN_CHUNK_SIZE = 50
|
||||
|
||||
export function PostThread({uri}: {uri: string}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {hasSession} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender() // TODO
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const anchorPostSource = useUnstablePostSource(uri)
|
||||
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
|
||||
|
||||
/*
|
||||
* One query to rule them all
|
||||
*/
|
||||
const thread = usePostThread({anchor: uri})
|
||||
const anchor = useMemo(() => {
|
||||
for (const item of thread.data.items) {
|
||||
if (item.type === 'threadPost' && item.depth === 0) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [thread.data.items])
|
||||
|
||||
const {openComposer} = useOpenComposer()
|
||||
const optimisticOnPostReply = useCallback(
|
||||
(payload: OnPostSuccessData) => {
|
||||
if (payload) {
|
||||
const {replyToUri, posts} = payload
|
||||
if (replyToUri && posts.length) {
|
||||
thread.actions.insertReplies(replyToUri, posts)
|
||||
}
|
||||
}
|
||||
},
|
||||
[thread],
|
||||
)
|
||||
const onReplyToAnchor = useCallback(() => {
|
||||
if (anchor?.type !== 'threadPost') {
|
||||
return
|
||||
}
|
||||
const post = anchor.value.post
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: anchor.uri,
|
||||
cid: post.cid,
|
||||
text: post.record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation: anchor.moderation,
|
||||
},
|
||||
onPostSuccess: optimisticOnPostReply,
|
||||
})
|
||||
|
||||
if (anchorPostSource) {
|
||||
feedFeedback.sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#interactionReply',
|
||||
feedContext: anchorPostSource.post.feedContext,
|
||||
reqId: anchorPostSource.post.reqId,
|
||||
})
|
||||
}
|
||||
}, [
|
||||
anchor,
|
||||
openComposer,
|
||||
optimisticOnPostReply,
|
||||
anchorPostSource,
|
||||
feedFeedback,
|
||||
])
|
||||
|
||||
const isRoot = !!anchor && anchor.value.post.record.reply === undefined
|
||||
const canReply = !anchor?.value.post?.viewer?.replyDisabled
|
||||
const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
|
||||
const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
|
||||
const totalParentCount = useRef(0) // recomputed below
|
||||
const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const anchorRef = useRef<View | null>(null)
|
||||
const headerRef = useRef<View | null>(null)
|
||||
|
||||
/*
|
||||
* On a cold load, parents are not prepended until the anchor post has
|
||||
* rendered as the first item in the list. This gives us a consistent
|
||||
* reference point for which to pin the anchor post to the top of the screen.
|
||||
*
|
||||
* We simulate a cold load any time the user changes the view or sort params
|
||||
* so that this handling is consistent.
|
||||
*
|
||||
* On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
|
||||
* us this for free, since the anchor post is the first item in the list.
|
||||
*
|
||||
* On web, `onContentSizeChange` is used to get ahead of next paint and handle
|
||||
* this scrolling.
|
||||
*/
|
||||
const [deferParents, setDeferParents] = useState(true)
|
||||
/**
|
||||
* Used to flag whether we should scroll to the anchor post. On a cold load,
|
||||
* this is always true. And when a user changes thread parameters, we also
|
||||
* manually set this to true.
|
||||
*/
|
||||
const shouldHandleScroll = useRef(true)
|
||||
/**
|
||||
* Called any time the content size of the list changes, _just_ before paint.
|
||||
*
|
||||
* We want this to fire every time we change params (which will reset
|
||||
* `deferParents` via `onLayout` on the anchor post, due to the key change),
|
||||
* or click into a new post (which will result in a fresh `deferParents`
|
||||
* hook).
|
||||
*
|
||||
* The result being: any intentional change in view by the user will result
|
||||
* in the anchor being pinned as the first item.
|
||||
*/
|
||||
const onContentSizeChangeWebOnly = web(() => {
|
||||
const list = listRef.current
|
||||
const anchor = anchorRef.current as any as Element
|
||||
const header = headerRef.current as any as Element
|
||||
|
||||
if (list && anchor && header && shouldHandleScroll.current) {
|
||||
const anchorOffsetTop = anchor.getBoundingClientRect().top
|
||||
const headerHeight = header.getBoundingClientRect().height
|
||||
|
||||
/*
|
||||
* `deferParents` is `true` on a cold load, and always reset to
|
||||
* `true` when params change via `prepareForParamsUpdate`.
|
||||
*
|
||||
* On a cold load or a push to a new post, on the first pass of this
|
||||
* logic, the anchor post is the first item in the list. Therefore
|
||||
* `anchorOffsetTop - headerHeight` will be 0.
|
||||
*
|
||||
* When a user changes thread params, on the first pass of this logic,
|
||||
* the anchor post may not move (if there are no parents above it), or it
|
||||
* may have gone off the screen above, because of the sudden lack of
|
||||
* parents due to `deferParents === true`. This negative value (minus
|
||||
* `headerHeight`) will result in a _negative_ `offset` value, which will
|
||||
* scroll the anchor post _down_ to the top of the screen.
|
||||
*
|
||||
* However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
|
||||
* changes params, the anchor post's offset will actually be equivalent
|
||||
* to the `headerHeight` because of how the DOM is stacked on web.
|
||||
* Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
|
||||
* which means the first pass in this case will result in no scroll.
|
||||
*
|
||||
* Then, once parents are prepended, this will fire again. Now, the
|
||||
* `anchorOffsetTop` will be positive, which minus the header height,
|
||||
* will give us a _positive_ offset, which will scroll the anchor post
|
||||
* back _up_ to the top of the screen.
|
||||
*/
|
||||
list.scrollToOffset({
|
||||
offset: anchorOffsetTop - headerHeight,
|
||||
})
|
||||
|
||||
/*
|
||||
* After the second pass, `deferParents` will be `false`, and we need
|
||||
* to ensure this doesn't run again until scroll handling is requested
|
||||
* again via `shouldHandleScroll.current === true` and a params
|
||||
* change via `prepareForParamsUpdate`.
|
||||
*
|
||||
* The `isRoot` here is needed because if we're looking at the anchor
|
||||
* post, this handler will not fire after `deferParents` is set to
|
||||
* `false`, since there are no parents to render above it. In this case,
|
||||
* we want to make sure `shouldHandleScroll` is set to `false` so that
|
||||
* subsequent size changes unrelated to a params change (like pagination)
|
||||
* do not affect scroll.
|
||||
*/
|
||||
if (!deferParents || isRoot) shouldHandleScroll.current = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Ditto the above, but for native.
|
||||
*/
|
||||
const onContentSizeChangeNativeOnly = native(() => {
|
||||
const list = listRef.current
|
||||
const anchor = anchorRef.current
|
||||
|
||||
if (list && anchor && shouldHandleScroll.current) {
|
||||
/*
|
||||
* `prepareForParamsUpdate` is called any time the user changes thread params like
|
||||
* `view` or `sort`, which sets `deferParents(true)` and resets the
|
||||
* scroll to the top of the list. However, there is a split second
|
||||
* where the top of the list is wherever the parents _just were_. So if
|
||||
* there were parents, the anchor is not at the top of the list just
|
||||
* prior to this handler being called.
|
||||
*
|
||||
* Once this handler is called, the anchor post is the first item in
|
||||
* the list (because of `deferParents` being `true`), and so we can
|
||||
* synchronously scroll the list back to the top of the list (which is
|
||||
* 0 on native, no need to handle `headerHeight`).
|
||||
*/
|
||||
list.scrollToOffset({
|
||||
animated: false,
|
||||
offset: 0,
|
||||
})
|
||||
|
||||
/*
|
||||
* After this first pass, `deferParents` will be `false`, and those
|
||||
* will render in. However, the anchor post will retain its position
|
||||
* because of `maintainVisibleContentPosition` handling on native. So we
|
||||
* don't need to let this handler run again, like we do on web.
|
||||
*/
|
||||
shouldHandleScroll.current = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Called any time the user changes thread params, such as `view` or `sort`.
|
||||
* Prepares the UI for repositioning of the scroll so that the anchor post is
|
||||
* always at the top after a params change.
|
||||
*
|
||||
* No need to handle max parents here, deferParents will handle that and we
|
||||
* want it to re-render with the same items above the anchor.
|
||||
*/
|
||||
const prepareForParamsUpdate = useCallback(() => {
|
||||
/**
|
||||
* Truncate list so that anchor post is the first item in the list. Manual
|
||||
* scroll handling on web is predicated on this, and on native, this allows
|
||||
* `maintainVisibleContentPosition` to do its thing.
|
||||
*/
|
||||
setDeferParents(true)
|
||||
// reset this to a lower value for faster re-render
|
||||
setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
|
||||
// set flag
|
||||
shouldHandleScroll.current = true
|
||||
}, [setDeferParents, setMaxChildrenCount])
|
||||
|
||||
const setSortWrapped = useCallback(
|
||||
(sort: string) => {
|
||||
prepareForParamsUpdate()
|
||||
thread.actions.setSort(sort)
|
||||
},
|
||||
[thread, prepareForParamsUpdate],
|
||||
)
|
||||
|
||||
const setViewWrapped = useCallback(
|
||||
(view: ThreadViewOption) => {
|
||||
prepareForParamsUpdate()
|
||||
thread.actions.setView(view)
|
||||
},
|
||||
[thread, prepareForParamsUpdate],
|
||||
)
|
||||
|
||||
const onStartReached = () => {
|
||||
if (thread.state.isFetching) return
|
||||
// can be true after `prepareForParamsUpdate` is called
|
||||
if (deferParents) return
|
||||
// prevent any state mutations if we know we're done
|
||||
if (maxParentCount >= totalParentCount.current) return
|
||||
setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
|
||||
}
|
||||
|
||||
const onEndReached = () => {
|
||||
if (thread.state.isFetching) return
|
||||
// can be true after `prepareForParamsUpdate` is called
|
||||
if (deferParents) return
|
||||
// prevent any state mutations if we know we're done
|
||||
if (maxChildrenCount >= totalChildrenCount.current) return
|
||||
setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
|
||||
}
|
||||
|
||||
const slices = useMemo(() => {
|
||||
const results: ThreadItem[] = []
|
||||
|
||||
if (!thread.data.items.length) return results
|
||||
|
||||
/*
|
||||
* Pagination hack, tracks the # of items below the anchor post.
|
||||
*/
|
||||
let childrenCount = 0
|
||||
|
||||
for (let i = 0; i < thread.data.items.length; i++) {
|
||||
const item = thread.data.items[i]
|
||||
/*
|
||||
* Need to check `depth`, since not found or blocked posts are not
|
||||
* `threadPost`s, but still have `depth`.
|
||||
*/
|
||||
const hasDepth = 'depth' in item
|
||||
|
||||
/*
|
||||
* Handle anchor post.
|
||||
*/
|
||||
if (hasDepth && item.depth === 0) {
|
||||
results.push(item)
|
||||
|
||||
// Recalculate total parents current index.
|
||||
totalParentCount.current = i
|
||||
// Recalculate total children using (length - 1) - current index.
|
||||
totalChildrenCount.current = thread.data.items.length - 1 - i
|
||||
|
||||
/*
|
||||
* Walk up the parents, limiting by `maxParentCount`
|
||||
*/
|
||||
if (!deferParents) {
|
||||
const start = i - 1
|
||||
if (start >= 0) {
|
||||
const limit = Math.max(0, start - maxParentCount)
|
||||
for (let pi = start; pi >= limit; pi--) {
|
||||
results.unshift(thread.data.items[pi])
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ignore any parent items
|
||||
if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
|
||||
// can exit early if we've reached the max children count
|
||||
if (childrenCount > maxChildrenCount) break
|
||||
|
||||
results.push(item)
|
||||
childrenCount++
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}, [thread, deferParents, maxParentCount, maxChildrenCount])
|
||||
|
||||
const isTombstoneView = useMemo(() => {
|
||||
if (slices.length > 1) return false
|
||||
return slices.every(
|
||||
s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
|
||||
)
|
||||
}, [slices])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item, index}: {item: ThreadItem; index: number}) => {
|
||||
if (item.type === 'threadPost') {
|
||||
if (item.depth < 0) {
|
||||
return (
|
||||
<ThreadItemPost
|
||||
item={item}
|
||||
threadgateRecord={thread.data.threadgate?.record ?? undefined}
|
||||
overrides={{
|
||||
topBorder: index === 0,
|
||||
}}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
/>
|
||||
)
|
||||
} else if (item.depth === 0) {
|
||||
return (
|
||||
/*
|
||||
* Keep this view wrapped so that the anchor post is always index 0
|
||||
* in the list and `maintainVisibleContentPosition` can do its
|
||||
* thing.
|
||||
*/
|
||||
<View collapsable={false}>
|
||||
<View
|
||||
/*
|
||||
* IMPORTANT: this is a load-bearing key on all platforms. We
|
||||
* want to force `onLayout` to fire any time the thread params
|
||||
* change so that `deferParents` is always reset to `false` once
|
||||
* the anchor post is rendered.
|
||||
*
|
||||
* If we ever add additional thread params to this screen, they
|
||||
* will need to be added here.
|
||||
*/
|
||||
key={item.uri + thread.state.view + thread.state.sort}
|
||||
ref={anchorRef}
|
||||
onLayout={() => setDeferParents(false)}
|
||||
/>
|
||||
<ThreadItemAnchor
|
||||
item={item}
|
||||
threadgateRecord={thread.data.threadgate?.record ?? undefined}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
postSource={anchorPostSource}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
if (thread.state.view === 'tree') {
|
||||
return (
|
||||
<ThreadItemTreePost
|
||||
item={item}
|
||||
threadgateRecord={thread.data.threadgate?.record ?? undefined}
|
||||
overrides={{
|
||||
moderation: thread.state.otherItemsVisible && item.depth > 0,
|
||||
}}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<ThreadItemPost
|
||||
item={item}
|
||||
threadgateRecord={thread.data.threadgate?.record ?? undefined}
|
||||
overrides={{
|
||||
moderation: thread.state.otherItemsVisible && item.depth > 0,
|
||||
}}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (item.type === 'threadPostNoUnauthenticated') {
|
||||
if (item.depth < 0) {
|
||||
return <ThreadItemPostNoUnauthenticated item={item} />
|
||||
} else if (item.depth === 0) {
|
||||
return <ThreadItemAnchorNoUnauthenticated />
|
||||
}
|
||||
} else if (item.type === 'readMore') {
|
||||
return (
|
||||
<ThreadItemReadMore
|
||||
item={item}
|
||||
view={thread.state.view === 'tree' ? 'tree' : 'linear'}
|
||||
/>
|
||||
)
|
||||
} else if (item.type === 'readMoreUp') {
|
||||
return <ThreadItemReadMoreUp item={item} />
|
||||
} else if (item.type === 'threadPostBlocked') {
|
||||
return <ThreadItemPostTombstone type="blocked" />
|
||||
} else if (item.type === 'threadPostNotFound') {
|
||||
return <ThreadItemPostTombstone type="not-found" />
|
||||
} else if (item.type === 'replyComposer') {
|
||||
return (
|
||||
<View>
|
||||
{gtMobile && (
|
||||
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'showOtherReplies') {
|
||||
return <ThreadItemShowOtherReplies onPress={item.onPress} />
|
||||
} else if (item.type === 'skeleton') {
|
||||
if (item.item === 'anchor') {
|
||||
return <ThreadItemAnchorSkeleton />
|
||||
} else if (item.item === 'reply') {
|
||||
if (thread.state.view === 'linear') {
|
||||
return <ThreadItemPostSkeleton index={index} />
|
||||
} else {
|
||||
return <ThreadItemTreePostSkeleton index={index} />
|
||||
}
|
||||
} else if (item.item === 'replyComposer') {
|
||||
return <ThreadItemReplyComposerSkeleton />
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[
|
||||
thread,
|
||||
optimisticOnPostReply,
|
||||
onReplyToAnchor,
|
||||
gtMobile,
|
||||
anchorPostSource,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout.Header.Outer headerRef={headerRef}>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans context="description">Post</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot>
|
||||
<HeaderDropdown
|
||||
sort={thread.state.sort}
|
||||
setSort={setSortWrapped}
|
||||
view={thread.state.view}
|
||||
setView={setViewWrapped}
|
||||
/>
|
||||
</Layout.Header.Slot>
|
||||
</Layout.Header.Outer>
|
||||
|
||||
{thread.state.error ? (
|
||||
<ThreadError
|
||||
error={thread.state.error}
|
||||
onRetry={thread.actions.refetch}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
ref={listRef}
|
||||
data={slices}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={platform({
|
||||
web: onContentSizeChangeWebOnly,
|
||||
default: onContentSizeChangeNativeOnly,
|
||||
})}
|
||||
onStartReached={onStartReached}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onStartReachedThreshold={1}
|
||||
/**
|
||||
* NATIVE ONLY
|
||||
* {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
|
||||
*/
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
desktopFixedHeight
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
/*
|
||||
* On native, if `deferParents` is true, we need some extra buffer to
|
||||
* account for the `on*ReachedThreshold` values.
|
||||
*
|
||||
* Otherwise, and on web, this value needs to be the height of
|
||||
* the viewport _minus_ a sensible min-post height e.g. 200, so
|
||||
* that there's enough scroll remaining to get the anchor post
|
||||
* back to the top of the screen when handling scroll.
|
||||
*/
|
||||
height={platform({
|
||||
web: windowHeight - 200,
|
||||
default: deferParents ? windowHeight * 2 : windowHeight - 200,
|
||||
})}
|
||||
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!gtMobile && canReply && hasSession && (
|
||||
<MobileComposePrompt onPressReply={onReplyToAnchor} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const keyExtractor = (item: ThreadItem) => {
|
||||
return item.key
|
||||
}
|
@ -2,22 +2,156 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useSetThreadViewPreferencesMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {
|
||||
normalizeSort,
|
||||
normalizeView,
|
||||
useThreadPreferences,
|
||||
} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
|
||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as SettingsList from './components/SettingsList'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
|
||||
export function ThreadPreferencesScreen({}: Props) {
|
||||
const gate = useGate()
|
||||
|
||||
return gate('post_threads_v2_unspecced') ? (
|
||||
<ThreadPreferencesV2 />
|
||||
) : (
|
||||
<ThreadPreferencesV1 />
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreadPreferencesV2() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
sort,
|
||||
setSort,
|
||||
view,
|
||||
setView,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers,
|
||||
} = useThreadPreferences({save: true})
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="threadPreferencesScreen">
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Thread Preferences</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content>
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Group>
|
||||
<SettingsList.ItemIcon icon={BubblesIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Sort replies</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<View style={[a.w_full, a.gap_md]}>
|
||||
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Sort replies to the same post by:</Trans>
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
label={_(msg`Sort replies by`)}
|
||||
type="radio"
|
||||
values={sort ? [sort] : []}
|
||||
onChange={values => setSort(normalizeSort(values[0]))}>
|
||||
<View style={[a.gap_sm, a.flex_1]}>
|
||||
<Toggle.Item name="top" label={_(msg`Top replies first`)}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Top replies first</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="oldest"
|
||||
label={_(msg`Oldest replies first`)}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Oldest replies first</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="newest"
|
||||
label={_(msg`Newest replies first`)}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Newest replies first</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
</SettingsList.Group>
|
||||
|
||||
<SettingsList.Group contentContainerStyle={{minHeight: 0}}>
|
||||
<SettingsList.ItemIcon icon={PersonGroupIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Prioritize your Follows</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Item
|
||||
type="checkbox"
|
||||
name="prioritize-follows"
|
||||
label={_(msg`Prioritize your Follows`)}
|
||||
value={prioritizeFollowedUsers}
|
||||
onChange={value => setPrioritizeFollowedUsers(value)}
|
||||
style={[a.w_full, a.gap_md]}>
|
||||
<Toggle.LabelText style={[a.flex_1]}>
|
||||
<Trans>
|
||||
Show replies by people you follow before all other replies
|
||||
</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</Toggle.Item>
|
||||
</SettingsList.Group>
|
||||
|
||||
<SettingsList.Group>
|
||||
<SettingsList.ItemIcon icon={TreeIcon} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Tree view</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Item
|
||||
type="checkbox"
|
||||
name="threaded-mode"
|
||||
label={_(msg`Tree view`)}
|
||||
value={view === 'tree'}
|
||||
onChange={value =>
|
||||
setView(normalizeView({treeViewEnabled: value}))
|
||||
}
|
||||
style={[a.w_full, a.gap_md]}>
|
||||
<Toggle.LabelText style={[a.flex_1]}>
|
||||
<Trans>Show post replies in a threaded tree view</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</Toggle.Item>
|
||||
</SettingsList.Group>
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThreadPreferencesV1() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
|
@ -882,7 +882,10 @@ function Overlay({
|
||||
player={player}
|
||||
seekingAnimationSV={seekingAnimationSV}
|
||||
scrollGesture={scrollGesture}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
<PostThreadComposePrompt
|
||||
onPressCompose={onPressReply}
|
||||
style={[a.pt_md, a.pb_sm]}
|
||||
/>
|
||||
</Scrubber>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
4
src/state/cache/post-shadow.ts
vendored
4
src/state/cache/post-shadow.ts
vendored
@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||
import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
|
||||
import {useProfileShadow} from './profile-shadow'
|
||||
import {castAsShadow, type Shadow} from './types'
|
||||
export type {Shadow} from './types'
|
||||
@ -157,6 +158,9 @@ function* findPostsInCache(
|
||||
yield node.post
|
||||
}
|
||||
}
|
||||
for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
|
||||
yield post
|
||||
}
|
||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||
yield post
|
||||
}
|
||||
|
2
src/state/cache/profile-shadow.ts
vendored
2
src/state/cache/profile-shadow.ts
vendored
@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {castAsShadow, type Shadow} from './types'
|
||||
|
||||
@ -167,6 +168,7 @@ function* findProfilesInCache(
|
||||
yield* findAllProfilesInListConvosQueryData(queryClient, did)
|
||||
yield* findAllProfilesInFeedsQueryData(queryClient, did)
|
||||
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
|
||||
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
|
||||
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
|
||||
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
|
||||
}
|
||||
|
179
src/state/queries/preferences/useThreadPreferences.ts
Normal file
179
src/state/queries/preferences/useThreadPreferences.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useSetThreadViewPreferencesMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {type ThreadViewPreferences} from '#/state/queries/preferences/types'
|
||||
import {type Literal} from '#/types/utils'
|
||||
|
||||
export type ThreadSortOption = Literal<
|
||||
AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'],
|
||||
string
|
||||
>
|
||||
export type ThreadViewOption = 'linear' | 'tree'
|
||||
export type ThreadPreferences = {
|
||||
isLoaded: boolean
|
||||
isSaving: boolean
|
||||
sort: ThreadSortOption
|
||||
setSort: (sort: string) => void
|
||||
view: ThreadViewOption
|
||||
setView: (view: ThreadViewOption) => void
|
||||
prioritizeFollowedUsers: boolean
|
||||
setPrioritizeFollowedUsers: (prioritize: boolean) => void
|
||||
}
|
||||
|
||||
export function useThreadPreferences({
|
||||
save,
|
||||
}: {save?: boolean} = {}): ThreadPreferences {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const serverPrefs = preferences?.threadViewPrefs
|
||||
const once = useCallOnce(OnceKey.PreferencesThread)
|
||||
|
||||
/*
|
||||
* Create local state representations of server state
|
||||
*/
|
||||
const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top'))
|
||||
const [view, setView] = useState(
|
||||
normalizeView({
|
||||
treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled,
|
||||
}),
|
||||
)
|
||||
const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
|
||||
!!serverPrefs?.prioritizeFollowedUsers,
|
||||
)
|
||||
|
||||
/**
|
||||
* If we get a server update, update local state
|
||||
*/
|
||||
const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
|
||||
const isLoaded = !!prevServerPrefs
|
||||
if (serverPrefs && prevServerPrefs !== serverPrefs) {
|
||||
setPrevServerPrefs(serverPrefs)
|
||||
|
||||
/*
|
||||
* Update
|
||||
*/
|
||||
setSort(normalizeSort(serverPrefs.sort))
|
||||
setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers)
|
||||
setView(
|
||||
normalizeView({
|
||||
treeViewEnabled: !!serverPrefs.lab_treeViewEnabled,
|
||||
}),
|
||||
)
|
||||
|
||||
once(() => {
|
||||
logger.metric('thread:preferences:load', {
|
||||
sort: serverPrefs.sort,
|
||||
view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear',
|
||||
prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const userUpdatedPrefs = useRef(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const {mutateAsync} = useSetThreadViewPreferencesMutation()
|
||||
const savePrefs = useMemo(() => {
|
||||
return debounce(async (prefs: ThreadViewPreferences) => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await mutateAsync(prefs)
|
||||
logger.metric('thread:preferences:update', {
|
||||
sort: prefs.sort,
|
||||
view: prefs.lab_treeViewEnabled ? 'tree' : 'linear',
|
||||
prioritizeFollowedUsers: prefs.prioritizeFollowedUsers,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.error('useThreadPreferences failed to save', {
|
||||
safeMessage: e,
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, 4e3)
|
||||
}, [mutateAsync])
|
||||
|
||||
if (save && userUpdatedPrefs.current) {
|
||||
savePrefs({
|
||||
sort,
|
||||
prioritizeFollowedUsers,
|
||||
lab_treeViewEnabled: view === 'tree',
|
||||
})
|
||||
userUpdatedPrefs.current = false
|
||||
}
|
||||
|
||||
const setSortWrapped = useCallback(
|
||||
(next: string) => {
|
||||
userUpdatedPrefs.current = true
|
||||
setSort(normalizeSort(next))
|
||||
},
|
||||
[setSort],
|
||||
)
|
||||
const setViewWrapped = useCallback(
|
||||
(next: ThreadViewOption) => {
|
||||
userUpdatedPrefs.current = true
|
||||
setView(next)
|
||||
},
|
||||
[setView],
|
||||
)
|
||||
const setPrioritizeFollowedUsersWrapped = useCallback(
|
||||
(next: boolean) => {
|
||||
userUpdatedPrefs.current = true
|
||||
setPrioritizeFollowedUsers(next)
|
||||
},
|
||||
[setPrioritizeFollowedUsers],
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isLoaded,
|
||||
isSaving,
|
||||
sort,
|
||||
setSort: setSortWrapped,
|
||||
view,
|
||||
setView: setViewWrapped,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped,
|
||||
}),
|
||||
[
|
||||
isLoaded,
|
||||
isSaving,
|
||||
sort,
|
||||
setSortWrapped,
|
||||
view,
|
||||
setViewWrapped,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsersWrapped,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates user thread preferences from the old sort values to V2
|
||||
*/
|
||||
export function normalizeSort(sort: string): ThreadSortOption {
|
||||
switch (sort) {
|
||||
case 'oldest':
|
||||
return 'oldest'
|
||||
case 'newest':
|
||||
return 'newest'
|
||||
default:
|
||||
return 'top'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms existing treeViewEnabled preference into a ThreadViewOption
|
||||
*/
|
||||
export function normalizeView({
|
||||
treeViewEnabled,
|
||||
}: {
|
||||
treeViewEnabled: boolean
|
||||
}): ThreadViewOption {
|
||||
return treeViewEnabled ? 'tree' : 'linear'
|
||||
}
|
27
src/state/queries/usePostThread/const.ts
Normal file
27
src/state/queries/usePostThread/const.ts
Normal file
@ -0,0 +1,27 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const LINEAR_VIEW_BELOW = 10
|
||||
|
||||
/**
|
||||
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const LINEAR_VIEW_BF = 1
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BELOW = 4
|
||||
|
||||
/**
|
||||
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BF = undefined
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BELOW_DESKTOP = 6
|
325
src/state/queries/usePostThread/index.ts
Normal file
325
src/state/queries/usePostThread/index.ts
Normal file
@ -0,0 +1,325 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {
|
||||
LINEAR_VIEW_BELOW,
|
||||
LINEAR_VIEW_BF,
|
||||
TREE_VIEW_BELOW,
|
||||
TREE_VIEW_BELOW_DESKTOP,
|
||||
TREE_VIEW_BF,
|
||||
} from '#/state/queries/usePostThread/const'
|
||||
import {
|
||||
createCacheMutator,
|
||||
getThreadPlaceholder,
|
||||
} from '#/state/queries/usePostThread/queryCache'
|
||||
import {
|
||||
buildThread,
|
||||
sortAndAnnotateThreadItems,
|
||||
} from '#/state/queries/usePostThread/traversal'
|
||||
import {
|
||||
createPostThreadOtherQueryKey,
|
||||
createPostThreadQueryKey,
|
||||
type ThreadItem,
|
||||
type UsePostThreadQueryResult,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
|
||||
import * as views from '#/state/queries/usePostThread/views'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
export * from '#/state/queries/usePostThread/types'
|
||||
|
||||
export function usePostThread({anchor}: {anchor?: string}) {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
|
||||
const {
|
||||
isLoaded: isThreadPreferencesLoaded,
|
||||
sort,
|
||||
setSort: baseSetSort,
|
||||
view,
|
||||
setView: baseSetView,
|
||||
prioritizeFollowedUsers,
|
||||
} = useThreadPreferences()
|
||||
const below = useMemo(() => {
|
||||
return view === 'linear'
|
||||
? LINEAR_VIEW_BELOW
|
||||
: isWeb && gtPhone
|
||||
? TREE_VIEW_BELOW_DESKTOP
|
||||
: TREE_VIEW_BELOW
|
||||
}, [view, gtPhone])
|
||||
|
||||
const postThreadQueryKey = createPostThreadQueryKey({
|
||||
anchor,
|
||||
sort,
|
||||
view,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
|
||||
anchor,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
|
||||
const query = useQuery<UsePostThreadQueryResult>({
|
||||
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
|
||||
queryKey: postThreadQueryKey,
|
||||
async queryFn(ctx) {
|
||||
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
|
||||
anchor: anchor!,
|
||||
branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
|
||||
below,
|
||||
sort: sort,
|
||||
prioritizeFollowedUsers: prioritizeFollowedUsers,
|
||||
})
|
||||
|
||||
/*
|
||||
* Initialize `ctx.meta` to track if we know we have additional replies
|
||||
* we could fetch once we hit the end.
|
||||
*/
|
||||
ctx.meta = ctx.meta || {
|
||||
hasOtherReplies: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* If we know we have additional replies, we'll set this to true.
|
||||
*/
|
||||
if (data.hasOtherReplies) {
|
||||
ctx.meta.hasOtherReplies = true
|
||||
}
|
||||
|
||||
const result = {
|
||||
thread: data.thread || [],
|
||||
threadgate: data.threadgate,
|
||||
hasOtherReplies: !!ctx.meta.hasOtherReplies,
|
||||
}
|
||||
|
||||
const record = getThreadgateRecord(result.threadgate)
|
||||
if (result.threadgate && record) {
|
||||
result.threadgate.record = record
|
||||
}
|
||||
|
||||
return result as UsePostThreadQueryResult
|
||||
},
|
||||
placeholderData() {
|
||||
if (!anchor) return
|
||||
const placeholder = getThreadPlaceholder(qc, anchor)
|
||||
/*
|
||||
* Always return something here, even empty data, so that
|
||||
* `isPlaceholderData` is always true, which we'll use to insert
|
||||
* skeletons.
|
||||
*/
|
||||
const thread = placeholder ? [placeholder] : []
|
||||
return {thread, threadgate: undefined, hasOtherReplies: false}
|
||||
},
|
||||
select(data) {
|
||||
const record = getThreadgateRecord(data.threadgate)
|
||||
if (data.threadgate && record) {
|
||||
data.threadgate.record = record
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
|
||||
const threadgate = useMemo(
|
||||
() => query.data?.threadgate,
|
||||
[query.data?.threadgate],
|
||||
)
|
||||
const hasOtherThreadItems = useMemo(
|
||||
() => !!query.data?.hasOtherReplies,
|
||||
[query.data?.hasOtherReplies],
|
||||
)
|
||||
const [otherItemsVisible, setOtherItemsVisible] = useState(false)
|
||||
|
||||
/**
|
||||
* Creates a mutator for the post thread cache. This is used to insert
|
||||
* replies into the thread cache after posting.
|
||||
*/
|
||||
const mutator = useMemo(
|
||||
() =>
|
||||
createCacheMutator({
|
||||
params: {view, below},
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
queryClient: qc,
|
||||
}),
|
||||
[qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
|
||||
)
|
||||
|
||||
/**
|
||||
* If we have additional items available from the server and the user has
|
||||
* chosen to view them, start loading data
|
||||
*/
|
||||
const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
|
||||
const additionalItemsQuery = useQuery({
|
||||
enabled: additionalQueryEnabled,
|
||||
queryKey: postThreadOtherQueryKey,
|
||||
async queryFn() {
|
||||
const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
|
||||
anchor: anchor!,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
|
||||
if (!additionalQueryEnabled) return []
|
||||
if (additionalItemsQuery.isLoading) {
|
||||
return Array.from({length: 2}).map((_, i) =>
|
||||
views.skeleton({
|
||||
key: `other-reply-${i}`,
|
||||
item: 'reply',
|
||||
}),
|
||||
)
|
||||
} else if (additionalItemsQuery.isError) {
|
||||
/*
|
||||
* We could insert an special error component in here, but since these
|
||||
* are optional additional replies, it's not critical that they're shown
|
||||
* atm.
|
||||
*/
|
||||
return []
|
||||
} else if (additionalItemsQuery.data?.thread) {
|
||||
const {threadItems} = sortAndAnnotateThreadItems(
|
||||
additionalItemsQuery.data.thread,
|
||||
{
|
||||
view,
|
||||
skipModerationHandling: true,
|
||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
|
||||
threadgate?.record,
|
||||
),
|
||||
moderationOpts: moderationOpts!,
|
||||
},
|
||||
)
|
||||
return threadItems
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}, [
|
||||
view,
|
||||
additionalQueryEnabled,
|
||||
additionalItemsQuery,
|
||||
mergeThreadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
threadgate?.record,
|
||||
])
|
||||
|
||||
/**
|
||||
* Sets the sort order for the thread and resets the additional thread items
|
||||
*/
|
||||
const setSort: typeof baseSetSort = useCallback(
|
||||
nextSort => {
|
||||
setOtherItemsVisible(false)
|
||||
baseSetSort(nextSort)
|
||||
},
|
||||
[baseSetSort, setOtherItemsVisible],
|
||||
)
|
||||
|
||||
/**
|
||||
* Sets the view variant for the thread and resets the additional thread items
|
||||
*/
|
||||
const setView: typeof baseSetView = useCallback(
|
||||
nextView => {
|
||||
setOtherItemsVisible(false)
|
||||
baseSetView(nextView)
|
||||
},
|
||||
[baseSetView, setOtherItemsVisible],
|
||||
)
|
||||
|
||||
/*
|
||||
* This is the main thread response, sorted into separate buckets based on
|
||||
* moderation, and annotated with all UI state needed for rendering.
|
||||
*/
|
||||
const {threadItems, otherThreadItems} = useMemo(() => {
|
||||
return sortAndAnnotateThreadItems(thread, {
|
||||
view: view,
|
||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
|
||||
moderationOpts: moderationOpts!,
|
||||
})
|
||||
}, [
|
||||
thread,
|
||||
threadgate?.record,
|
||||
mergeThreadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
view,
|
||||
])
|
||||
|
||||
/*
|
||||
* Take all three sets of thread items and combine them into a single thread,
|
||||
* along with any other thread items required for rendering e.g. "Show more
|
||||
* replies" or the reply composer.
|
||||
*/
|
||||
const items = useMemo(() => {
|
||||
return buildThread({
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
isLoading: query.isPlaceholderData,
|
||||
hasSession,
|
||||
hasOtherThreadItems,
|
||||
otherItemsVisible,
|
||||
showOtherItems: () => setOtherItemsVisible(true),
|
||||
})
|
||||
}, [
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
query.isPlaceholderData,
|
||||
hasSession,
|
||||
hasOtherThreadItems,
|
||||
otherItemsVisible,
|
||||
setOtherItemsVisible,
|
||||
])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state: {
|
||||
/*
|
||||
* Copy in any query state that is useful
|
||||
*/
|
||||
isFetching: query.isFetching,
|
||||
isPlaceholderData: query.isPlaceholderData,
|
||||
error: query.error,
|
||||
/*
|
||||
* Other state
|
||||
*/
|
||||
sort,
|
||||
view,
|
||||
otherItemsVisible,
|
||||
},
|
||||
data: {
|
||||
items,
|
||||
threadgate,
|
||||
},
|
||||
actions: {
|
||||
/*
|
||||
* Copy in any query actions that are useful
|
||||
*/
|
||||
insertReplies: mutator.insertReplies,
|
||||
refetch: query.refetch,
|
||||
/*
|
||||
* Other actions
|
||||
*/
|
||||
setSort,
|
||||
setView,
|
||||
},
|
||||
}),
|
||||
[
|
||||
query,
|
||||
mutator.insertReplies,
|
||||
otherItemsVisible,
|
||||
sort,
|
||||
view,
|
||||
setSort,
|
||||
setView,
|
||||
threadgate,
|
||||
items,
|
||||
],
|
||||
)
|
||||
}
|
300
src/state/queries/usePostThread/queryCache.ts
Normal file
300
src/state/queries/usePostThread/queryCache.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadOtherV2,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {type QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||
import {getBranch} from '#/state/queries/usePostThread/traversal'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type createPostThreadOtherQueryKey,
|
||||
type createPostThreadQueryKey,
|
||||
type PostThreadParams,
|
||||
postThreadQueryKeyRoot,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
|
||||
import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
|
||||
import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
|
||||
import {embedViewRecordToPostView} from '#/state/queries/util'
|
||||
|
||||
export function createCacheMutator({
|
||||
queryClient,
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
params,
|
||||
}: {
|
||||
queryClient: QueryClient
|
||||
postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
|
||||
postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
|
||||
params: Pick<PostThreadParams, 'view'> & {below: number}
|
||||
}) {
|
||||
return {
|
||||
insertReplies(
|
||||
parentUri: string,
|
||||
replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
|
||||
) {
|
||||
/*
|
||||
* Main thread query mutator.
|
||||
*/
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
|
||||
postThreadQueryKey,
|
||||
data => {
|
||||
if (!data) return
|
||||
return {
|
||||
...data,
|
||||
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
|
||||
...data.thread,
|
||||
]),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* Additional replies query mutator.
|
||||
*/
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>(
|
||||
postThreadOtherQueryKey,
|
||||
data => {
|
||||
if (!data) return
|
||||
return {
|
||||
...data,
|
||||
thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([
|
||||
...data.thread,
|
||||
]),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function mutator<T>(thread: ApiThreadItem[]): T[] {
|
||||
for (let i = 0; i < thread.length; i++) {
|
||||
const existingParent = thread[i]
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value))
|
||||
continue
|
||||
if (existingParent.uri !== parentUri) continue
|
||||
|
||||
/*
|
||||
* Update parent data
|
||||
*/
|
||||
existingParent.value.post = {
|
||||
...existingParent.value.post,
|
||||
replyCount: (existingParent.value.post.replyCount || 0) + 1,
|
||||
}
|
||||
|
||||
const opDid = getRootPostAtUri(existingParent.value.post)?.host
|
||||
const nextItem = thread.at(i + 1)
|
||||
const isReplyToRoot = existingParent.depth === 0
|
||||
const isEndOfReplyChain =
|
||||
!nextItem || nextItem.depth <= existingParent.depth
|
||||
const firstReply = replies.at(0)
|
||||
const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost(
|
||||
firstReply?.value,
|
||||
)
|
||||
? opDid === firstReply.value.post.author.did
|
||||
: false
|
||||
|
||||
/*
|
||||
* Always insert replies if the following conditions are met.
|
||||
*/
|
||||
const shouldAlwaysInsertReplies =
|
||||
isReplyToRoot ||
|
||||
params.view === 'tree' ||
|
||||
(params.view === 'linear' && isEndOfReplyChain)
|
||||
/*
|
||||
* Maybe insert replies if the replier is the OP and certain conditions are met
|
||||
*/
|
||||
const shouldReplaceWithOPReplies =
|
||||
!isReplyToRoot && params.view === 'linear' && opIsReplier
|
||||
|
||||
if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) {
|
||||
const branch = getBranch(thread, i, existingParent.depth)
|
||||
/*
|
||||
* OP insertions replace other replies _in linear view_.
|
||||
*/
|
||||
const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
|
||||
const itemsToInsert = replies
|
||||
.map((r, ri) => {
|
||||
r.depth = existingParent.depth + 1 + ri
|
||||
return r
|
||||
})
|
||||
.filter(r => {
|
||||
// Filter out replies that are too deep for our UI
|
||||
return r.depth <= params.below
|
||||
})
|
||||
|
||||
thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
|
||||
}
|
||||
}
|
||||
|
||||
return thread as T[]
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Unused atm, post shadow does the trick, but it would be nice to clean up
|
||||
* the whole sub-tree on deletes.
|
||||
*/
|
||||
deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
|
||||
postThreadQueryKey,
|
||||
queryData => {
|
||||
if (!queryData) return
|
||||
|
||||
const thread = [...queryData.thread]
|
||||
|
||||
for (let i = 0; i < thread.length; i++) {
|
||||
const existingPost = thread[i]
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
|
||||
|
||||
if (existingPost.uri === post.uri) {
|
||||
const branch = getBranch(thread, i, existingPost.depth)
|
||||
thread.splice(branch.start, branch.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
thread,
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPlaceholder(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void {
|
||||
let partial
|
||||
for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
|
||||
/*
|
||||
* Currently, the backend doesn't send full post info in some cases (for
|
||||
* example, for quoted posts). We use missing `likeCount` as a way to
|
||||
* detect that. In the future, we should fix this on the backend, which
|
||||
* will let us always stop on the first result.
|
||||
*
|
||||
* TODO can we send in feeds and quotes?
|
||||
*/
|
||||
const hasAllInfo = item.value.post.likeCount != null
|
||||
if (hasAllInfo) {
|
||||
return item
|
||||
} else {
|
||||
// Keep searching, we might still find a full post in the cache.
|
||||
partial = item
|
||||
}
|
||||
}
|
||||
return partial
|
||||
}
|
||||
|
||||
export function* getThreadPlaceholderCandidates(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<
|
||||
$Typed<
|
||||
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
}
|
||||
>,
|
||||
void
|
||||
> {
|
||||
/*
|
||||
* Check post thread queries first
|
||||
*/
|
||||
for (const post of findAllPostsInQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
|
||||
/*
|
||||
* Check notifications first. If you have a post in notifications, it's
|
||||
* often due to a like or a repost, and we want to prioritize a post object
|
||||
* with >0 likes/reposts over a stale version with no metrics in order to
|
||||
* avoid a notification->post scroll jump.
|
||||
*/
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
||||
queryClient,
|
||||
uri,
|
||||
)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<AppBskyFeedDefs.PostView, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
||||
queryKey: [postThreadQueryKeyRoot],
|
||||
})
|
||||
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) continue
|
||||
|
||||
const {thread} = queryData
|
||||
|
||||
for (const item of thread) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (didOrHandleUriMatches(atUri, item.value.post)) {
|
||||
yield item.value.post
|
||||
}
|
||||
|
||||
const qp = getEmbeddedPost(item.value.post.embed)
|
||||
if (qp && didOrHandleUriMatches(atUri, qp)) {
|
||||
yield embedViewRecordToPostView(qp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
||||
queryKey: [postThreadQueryKeyRoot],
|
||||
})
|
||||
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) continue
|
||||
|
||||
const {thread} = queryData
|
||||
|
||||
for (const item of thread) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (item.value.post.author.did === did) {
|
||||
yield item.value.post.author
|
||||
}
|
||||
|
||||
const qp = getEmbeddedPost(item.value.post.embed)
|
||||
if (qp && qp.author.did === did) {
|
||||
yield qp.author
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
539
src/state/queries/usePostThread/traversal.ts
Normal file
539
src/state/queries/usePostThread/traversal.ts
Normal file
@ -0,0 +1,539 @@
|
||||
/* eslint-disable no-labels */
|
||||
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
|
||||
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type PostThreadParams,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {
|
||||
getPostRecord,
|
||||
getThreadPostNoUnauthenticatedUI,
|
||||
getThreadPostUI,
|
||||
getTraversalMetadata,
|
||||
storeTraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/utils'
|
||||
import * as views from '#/state/queries/usePostThread/views'
|
||||
|
||||
export function sortAndAnnotateThreadItems(
|
||||
thread: ApiThreadItem[],
|
||||
{
|
||||
threadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
view,
|
||||
skipModerationHandling,
|
||||
}: {
|
||||
threadgateHiddenReplies: Set<string>
|
||||
moderationOpts: ModerationOpts
|
||||
view: PostThreadParams['view']
|
||||
/**
|
||||
* Set to `true` in cases where we already know the moderation state of the
|
||||
* post e.g. when fetching additional replies from the server. This will
|
||||
* prevent additional sorting or nested-branch truncation, and all replies,
|
||||
* regardless of moderation state, will be included in the resulting
|
||||
* `threadItems` array.
|
||||
*/
|
||||
skipModerationHandling?: boolean
|
||||
},
|
||||
) {
|
||||
const threadItems: ThreadItem[] = []
|
||||
const otherThreadItems: ThreadItem[] = []
|
||||
const metadatas = new Map<string, TraversalMetadata>()
|
||||
|
||||
traversal: for (let i = 0; i < thread.length; i++) {
|
||||
const item = thread[i]
|
||||
let parentMetadata: TraversalMetadata | undefined
|
||||
let metadata: TraversalMetadata | undefined
|
||||
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
parentMetadata = metadatas.get(
|
||||
getPostRecord(item.value.post).reply?.parent?.uri || '',
|
||||
)
|
||||
metadata = getTraversalMetadata({
|
||||
item,
|
||||
parentMetadata,
|
||||
prevItem: thread.at(i - 1),
|
||||
nextItem: thread.at(i + 1),
|
||||
})
|
||||
storeTraversalMetadata(metadatas, metadata)
|
||||
}
|
||||
|
||||
if (item.depth < 0) {
|
||||
/*
|
||||
* Parents are ignored until we find the anchor post, then we walk
|
||||
* _up_ from there.
|
||||
*/
|
||||
} else if (item.depth === 0) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
|
||||
threadItems.push(views.threadPostNoUnauthenticated(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
|
||||
threadItems.push(views.threadPostNotFound(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
|
||||
threadItems.push(views.threadPostBlocked(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
const post = views.threadPost({
|
||||
uri: item.uri,
|
||||
depth: item.depth,
|
||||
value: item.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
threadItems.push(post)
|
||||
|
||||
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
|
||||
const parent = thread[pi]
|
||||
|
||||
if (
|
||||
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
|
||||
) {
|
||||
const post = views.threadPostNoUnauthenticated(parent)
|
||||
post.ui = getThreadPostNoUnauthenticatedUI({
|
||||
depth: parent.depth,
|
||||
// ignore for now
|
||||
// prevItemDepth: thread[pi - 1]?.depth,
|
||||
nextItemDepth: thread[pi + 1]?.depth,
|
||||
})
|
||||
threadItems.unshift(post)
|
||||
// for now, break parent traversal at first no-unauthed
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
|
||||
threadItems.unshift(views.threadPostNotFound(parent))
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
|
||||
threadItems.unshift(views.threadPostBlocked(parent))
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
|
||||
threadItems.unshift(
|
||||
views.threadPost({
|
||||
uri: parent.uri,
|
||||
depth: parent.depth,
|
||||
value: parent.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.depth > 0) {
|
||||
/*
|
||||
* The API does not send down any unavailable replies, so this will
|
||||
* always be false (for now). If we ever wanted to tombstone them here,
|
||||
* we could.
|
||||
*/
|
||||
const shouldBreak =
|
||||
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
|
||||
AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
|
||||
AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
|
||||
|
||||
if (shouldBreak) {
|
||||
const branch = getBranch(thread, i, item.depth)
|
||||
// could insert tombstone
|
||||
i = branch.end
|
||||
continue traversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (parentMetadata) {
|
||||
/*
|
||||
* Set this value before incrementing the parent's repliesSeenCounter
|
||||
*/
|
||||
metadata!.replyIndex = parentMetadata.repliesIndexCounter
|
||||
// Increment the parent's repliesIndexCounter
|
||||
parentMetadata.repliesIndexCounter += 1
|
||||
}
|
||||
|
||||
const post = views.threadPost({
|
||||
uri: item.uri,
|
||||
depth: item.depth,
|
||||
value: item.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
|
||||
if (!post.isBlurred || skipModerationHandling) {
|
||||
/*
|
||||
* Not moderated, need to insert it
|
||||
*/
|
||||
threadItems.push(post)
|
||||
|
||||
/*
|
||||
* Update seen reply count of parent
|
||||
*/
|
||||
if (parentMetadata) {
|
||||
parentMetadata.repliesSeenCounter += 1
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
* Moderated in some way, we're going to walk children
|
||||
*/
|
||||
const parent = post
|
||||
const parentIsTopLevelReply = parent.depth === 1
|
||||
// get sub tree
|
||||
const branch = getBranch(thread, i, item.depth)
|
||||
|
||||
if (parentIsTopLevelReply) {
|
||||
// push branch anchor into sorted array
|
||||
otherThreadItems.push(parent)
|
||||
// skip branch anchor in branch traversal
|
||||
const startIndex = branch.start + 1
|
||||
|
||||
for (let ci = startIndex; ci <= branch.end; ci++) {
|
||||
const child = thread[ci]
|
||||
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
|
||||
const childParentMetadata = metadatas.get(
|
||||
getPostRecord(child.value.post).reply?.parent?.uri || '',
|
||||
)
|
||||
const childMetadata = getTraversalMetadata({
|
||||
item: child,
|
||||
prevItem: thread[ci - 1],
|
||||
nextItem: thread[ci + 1],
|
||||
parentMetadata: childParentMetadata,
|
||||
})
|
||||
storeTraversalMetadata(metadatas, childMetadata)
|
||||
if (childParentMetadata) {
|
||||
/*
|
||||
* Set this value before incrementing the parent's repliesIndexCounter
|
||||
*/
|
||||
childMetadata!.replyIndex =
|
||||
childParentMetadata.repliesIndexCounter
|
||||
childParentMetadata.repliesIndexCounter += 1
|
||||
}
|
||||
|
||||
const childPost = views.threadPost({
|
||||
uri: child.uri,
|
||||
depth: child.depth,
|
||||
value: child.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
|
||||
/*
|
||||
* If a child is moderated in any way, drop it an its sub-branch
|
||||
* entirely. To reveal these, the user must navigate to the
|
||||
* parent post directly.
|
||||
*/
|
||||
if (childPost.isBlurred) {
|
||||
ci = getBranch(thread, ci, child.depth).end
|
||||
} else {
|
||||
otherThreadItems.push(childPost)
|
||||
|
||||
if (childParentMetadata) {
|
||||
childParentMetadata.repliesSeenCounter += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
* Drop the rest of the branch if we hit anything unexpected
|
||||
*/
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip to next branch
|
||||
*/
|
||||
i = branch.end
|
||||
continue traversal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
|
||||
* UI state based on collected metadata. These arrays will be muted in situ.
|
||||
*/
|
||||
for (const subset of [threadItems, otherThreadItems]) {
|
||||
for (let i = 0; i < subset.length; i++) {
|
||||
const item = subset[i]
|
||||
const prevItem = subset.at(i - 1)
|
||||
const nextItem = subset.at(i + 1)
|
||||
|
||||
if (item.type === 'threadPost') {
|
||||
const metadata = metadatas.get(item.uri)
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.parentMetadata) {
|
||||
/*
|
||||
* Track what's before/after now that we've applied moderation
|
||||
*/
|
||||
if (prevItem?.type === 'threadPost')
|
||||
metadata.prevItemDepth = prevItem?.depth
|
||||
if (nextItem?.type === 'threadPost')
|
||||
metadata.nextItemDepth = nextItem?.depth
|
||||
|
||||
/*
|
||||
* We can now officially calculate `isLastSibling` and `isLastChild`
|
||||
* based on the actual data that we've seen.
|
||||
*/
|
||||
metadata.isLastSibling =
|
||||
metadata.replyIndex ===
|
||||
metadata.parentMetadata.repliesSeenCounter - 1
|
||||
metadata.isLastChild =
|
||||
metadata.nextItemDepth === undefined ||
|
||||
metadata.nextItemDepth <= metadata.depth
|
||||
|
||||
/*
|
||||
* If this is the last sibling, it's implicitly part of the last
|
||||
* branch of this sub-tree.
|
||||
*/
|
||||
if (metadata.isLastSibling) {
|
||||
metadata.isPartOfLastBranchFromDepth = metadata.depth
|
||||
|
||||
/**
|
||||
* If the parent is part of the last branch of the sub-tree, so is the child.
|
||||
*/
|
||||
if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
|
||||
metadata.isPartOfLastBranchFromDepth =
|
||||
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* If this is the last sibling, and the parent has unhydrated replies,
|
||||
* at some point down the line we will need to show a "read more".
|
||||
*/
|
||||
if (
|
||||
metadata.parentMetadata.repliesUnhydrated > 0 &&
|
||||
metadata.isLastSibling
|
||||
) {
|
||||
metadata.upcomingParentReadMore = metadata.parentMetadata
|
||||
}
|
||||
|
||||
/*
|
||||
* Copy in the parent's upcoming read more, if it exists. Once we
|
||||
* reach the bottom, we'll insert a "read more"
|
||||
*/
|
||||
if (metadata.parentMetadata.upcomingParentReadMore) {
|
||||
metadata.upcomingParentReadMore =
|
||||
metadata.parentMetadata.upcomingParentReadMore
|
||||
}
|
||||
|
||||
/*
|
||||
* Copy in the parent's skipped indents
|
||||
*/
|
||||
metadata.skippedIndentIndices = new Set([
|
||||
...metadata.parentMetadata.skippedIndentIndices,
|
||||
])
|
||||
|
||||
/**
|
||||
* If this is the last sibling, and the parent has no unhydrated
|
||||
* replies, then we know we can skip an indent line.
|
||||
*/
|
||||
if (
|
||||
metadata.parentMetadata.repliesUnhydrated <= 0 &&
|
||||
metadata.isLastSibling
|
||||
) {
|
||||
/**
|
||||
* Depth is 2 more than the 0-index of the indent calculation
|
||||
* bc of how we render these. So instead of handling that in the
|
||||
* component, we just adjust that back to 0-index here.
|
||||
*/
|
||||
metadata.skippedIndentIndices.add(item.depth - 2)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* If this post has unhydrated replies, and it is the last child, then
|
||||
* it itself needs a "read more"
|
||||
*/
|
||||
if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
|
||||
metadata.precedesChildReadMore = true
|
||||
subset.splice(i + 1, 0, views.readMore(metadata))
|
||||
i++ // skip next iteration
|
||||
}
|
||||
|
||||
/*
|
||||
* Tree-view only.
|
||||
*
|
||||
* If there's an upcoming parent read more, this branch is part of the
|
||||
* last branch of the sub-tree, and the item itself is the last child,
|
||||
* insert the parent "read more".
|
||||
*/
|
||||
if (
|
||||
view === 'tree' &&
|
||||
metadata.upcomingParentReadMore &&
|
||||
metadata.isPartOfLastBranchFromDepth ===
|
||||
metadata.upcomingParentReadMore.depth &&
|
||||
metadata.isLastChild
|
||||
) {
|
||||
subset.splice(
|
||||
i + 1,
|
||||
0,
|
||||
views.readMore(metadata.upcomingParentReadMore),
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
/**
|
||||
* Only occurs for the first item in the thread, which may have
|
||||
* additional parents not included in this request.
|
||||
*/
|
||||
if (item.value.moreParents) {
|
||||
metadata.followsReadMoreUp = true
|
||||
subset.splice(i, 0, views.readMoreUp(metadata))
|
||||
i++
|
||||
}
|
||||
|
||||
/*
|
||||
* Calculate the final UI state for the thread item.
|
||||
*/
|
||||
item.ui = getThreadPostUI(metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildThread({
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
isLoading,
|
||||
hasSession,
|
||||
otherItemsVisible,
|
||||
hasOtherThreadItems,
|
||||
showOtherItems,
|
||||
}: {
|
||||
threadItems: ThreadItem[]
|
||||
otherThreadItems: ThreadItem[]
|
||||
serverOtherThreadItems: ThreadItem[]
|
||||
isLoading: boolean
|
||||
hasSession: boolean
|
||||
otherItemsVisible: boolean
|
||||
hasOtherThreadItems: boolean
|
||||
showOtherItems: () => void
|
||||
}) {
|
||||
/**
|
||||
* `threadItems` is memoized here, so don't mutate it directly.
|
||||
*/
|
||||
const items = [...threadItems]
|
||||
|
||||
if (isLoading) {
|
||||
const anchorPost = items.at(0)
|
||||
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
|
||||
const skeletonReplies = hasAnchorFromCache
|
||||
? anchorPost.value.post.replyCount ?? 4
|
||||
: 4
|
||||
|
||||
if (!items.length) {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: 'anchor-skeleton',
|
||||
item: 'anchor',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSession) {
|
||||
// we might have this from cache
|
||||
const replyDisabled =
|
||||
hasAnchorFromCache &&
|
||||
anchorPost.value.post.viewer?.replyDisabled === true
|
||||
|
||||
if (hasAnchorFromCache) {
|
||||
if (!replyDisabled) {
|
||||
items.push({
|
||||
type: 'replyComposer',
|
||||
key: 'replyComposer',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: 'replyComposer',
|
||||
item: 'replyComposer',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < skeletonReplies; i++) {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: `anchor-skeleton-reply-${i}`,
|
||||
item: 'reply',
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (
|
||||
item.type === 'threadPost' &&
|
||||
item.depth === 0 &&
|
||||
!item.value.post.viewer?.replyDisabled &&
|
||||
hasSession
|
||||
) {
|
||||
items.splice(i + 1, 0, {
|
||||
type: 'replyComposer',
|
||||
key: 'replyComposer',
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (otherThreadItems.length || hasOtherThreadItems) {
|
||||
if (otherItemsVisible) {
|
||||
items.push(...otherThreadItems)
|
||||
items.push(...serverOtherThreadItems)
|
||||
} else {
|
||||
items.push({
|
||||
type: 'showOtherReplies',
|
||||
key: 'showOtherReplies',
|
||||
onPress: showOtherItems,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start and end index of a "branch" of the thread. A "branch" is a
|
||||
* parent and it's children (not siblings). Returned indices are inclusive of
|
||||
* the parent and its last child.
|
||||
*
|
||||
* items[] (index, depth)
|
||||
* └─┬ anchor ──────── (0, 0)
|
||||
* ├─── branch ───── (1, 1)
|
||||
* ├──┬ branch ───── (2, 1) (start)
|
||||
* │ ├──┬ leaf ──── (3, 2)
|
||||
* │ │ └── leaf ── (4, 3)
|
||||
* │ └─── leaf ──── (5, 2) (end)
|
||||
* ├─── branch ───── (6, 1)
|
||||
* └─── branch ───── (7, 1)
|
||||
*
|
||||
* const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
|
||||
*/
|
||||
export function getBranch(
|
||||
thread: ApiThreadItem[],
|
||||
branchStartIndex: number,
|
||||
branchStartDepth: number,
|
||||
) {
|
||||
let end = branchStartIndex
|
||||
|
||||
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
|
||||
const next = thread[ci]
|
||||
if (next.depth > branchStartDepth) {
|
||||
end = ci
|
||||
} else {
|
||||
end = ci - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: branchStartIndex,
|
||||
end,
|
||||
length: end - branchStartIndex,
|
||||
}
|
||||
}
|
227
src/state/queries/usePostThread/types.ts
Normal file
227
src/state/queries/usePostThread/types.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyFeedThreadgate,
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadOtherV2,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
|
||||
export type ApiThreadItem =
|
||||
| AppBskyUnspeccedGetPostThreadV2.ThreadItem
|
||||
| AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem
|
||||
|
||||
export const postThreadQueryKeyRoot = 'post-thread-v2' as const
|
||||
|
||||
export const createPostThreadQueryKey = (props: PostThreadParams) =>
|
||||
[postThreadQueryKeyRoot, props] as const
|
||||
|
||||
export const createPostThreadOtherQueryKey = (
|
||||
props: Omit<AppBskyUnspeccedGetPostThreadOtherV2.QueryParams, 'anchor'> & {
|
||||
anchor?: string
|
||||
},
|
||||
) => [postThreadQueryKeyRoot, 'other', props] as const
|
||||
|
||||
export type PostThreadParams = Pick<
|
||||
AppBskyUnspeccedGetPostThreadV2.QueryParams,
|
||||
'sort' | 'prioritizeFollowedUsers'
|
||||
> & {
|
||||
anchor?: string
|
||||
view: 'tree' | 'linear'
|
||||
}
|
||||
|
||||
export type UsePostThreadQueryResult = {
|
||||
hasOtherReplies: boolean
|
||||
thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
|
||||
threadgate?: Omit<AppBskyFeedDefs.ThreadgateView, 'record'> & {
|
||||
record: AppBskyFeedThreadgate.Record
|
||||
}
|
||||
}
|
||||
|
||||
export type ThreadItem =
|
||||
| {
|
||||
type: 'threadPost'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
|
||||
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
||||
record: AppBskyFeedPost.Record
|
||||
}
|
||||
}
|
||||
isBlurred: boolean
|
||||
moderation: ModerationDecision
|
||||
ui: {
|
||||
isAnchor: boolean
|
||||
showParentReplyLine: boolean
|
||||
showChildReplyLine: boolean
|
||||
indent: number
|
||||
isLastChild: boolean
|
||||
skippedIndentIndices: Set<number>
|
||||
precedesChildReadMore: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'threadPostNoUnauthenticated'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
||||
ui: {
|
||||
showParentReplyLine: boolean
|
||||
showChildReplyLine: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'threadPostNotFound'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemNotFound
|
||||
}
|
||||
| {
|
||||
type: 'threadPostBlocked'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemBlocked
|
||||
}
|
||||
| {
|
||||
type: 'replyComposer'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'showOtherReplies'
|
||||
key: string
|
||||
onPress: () => void
|
||||
}
|
||||
| {
|
||||
/*
|
||||
* Read more replies, downwards in the thread.
|
||||
*/
|
||||
type: 'readMore'
|
||||
key: string
|
||||
depth: number
|
||||
href: string
|
||||
moreReplies: number
|
||||
skippedIndentIndices: Set<number>
|
||||
}
|
||||
| {
|
||||
/*
|
||||
* Read more parents, upwards in the thread.
|
||||
*/
|
||||
type: 'readMoreUp'
|
||||
key: string
|
||||
href: string
|
||||
}
|
||||
| {
|
||||
type: 'skeleton'
|
||||
key: string
|
||||
item: 'anchor' | 'reply' | 'replyComposer'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata collected while traversing the raw data from the thread response.
|
||||
* Some values here can be computed immediately, while others need to be
|
||||
* computed during a second pass over the thread after we know things like
|
||||
* total number of replies, the reply index, etc.
|
||||
*
|
||||
* The idea here is that these values should be objectively true in all cases,
|
||||
* such that we can use them later — either individually on in composite — to
|
||||
* drive rendering behaviors.
|
||||
*/
|
||||
export type TraversalMetadata = {
|
||||
/**
|
||||
* The depth of the post in the reply tree, where 0 is the root post. This is
|
||||
* calculated on the server.
|
||||
*/
|
||||
depth: number
|
||||
/**
|
||||
* Indicates if this item is a "read more" link preceding this post that
|
||||
* continues the thread upwards.
|
||||
*/
|
||||
followsReadMoreUp: boolean
|
||||
/**
|
||||
* Indicates if the post is the last reply beneath its parent post.
|
||||
*/
|
||||
isLastSibling: boolean
|
||||
/**
|
||||
* Indicates the post is the end-of-the-line for a given branch of replies.
|
||||
*/
|
||||
isLastChild: boolean
|
||||
/**
|
||||
* Indicates if the post is the left/lower-most branch of the reply tree.
|
||||
* Value corresponds to the depth at which this branch started.
|
||||
*/
|
||||
isPartOfLastBranchFromDepth?: number
|
||||
/**
|
||||
* The depth of the slice immediately following this one, if it exists.
|
||||
*/
|
||||
nextItemDepth?: number
|
||||
/**
|
||||
* This is a live reference to the parent metadata object. Mutations to this
|
||||
* are available for later use in children.
|
||||
*/
|
||||
parentMetadata?: TraversalMetadata
|
||||
/**
|
||||
* Populated during the final traversal of the thread. Denotes whether
|
||||
* there is a "Read more" link for this item immediately following
|
||||
* this item.
|
||||
*/
|
||||
precedesChildReadMore: boolean
|
||||
/**
|
||||
* The depth of the slice immediately preceding this one, if it exists.
|
||||
*/
|
||||
prevItemDepth?: number
|
||||
/**
|
||||
* Any data needed to be passed along to the "read more" items. Keep this
|
||||
* trim for better memory usage.
|
||||
*/
|
||||
postData: {
|
||||
uri: string
|
||||
authorHandle: string
|
||||
}
|
||||
/**
|
||||
* The total number of replies to this post, including those not hydrated
|
||||
* and returned by the response.
|
||||
*/
|
||||
repliesCount: number
|
||||
/**
|
||||
* The number of replies to this post not hydrated and returned by the
|
||||
* response.
|
||||
*/
|
||||
repliesUnhydrated: number
|
||||
/**
|
||||
* The number of replies that have been seen so far in the traversal.
|
||||
* Excludes replies that are moderated in some way, since those are not
|
||||
* "seen" on first load. Use `repliesIndexCounter` for the total number of
|
||||
* replies that were hydrated in the response.
|
||||
*
|
||||
* After traversal, we can use this to calculate if we actually got all the
|
||||
* replies we expected, or if some were blocked, etc.
|
||||
*/
|
||||
repliesSeenCounter: number
|
||||
/**
|
||||
* The total number of replies to this post hydrated in this response. Used
|
||||
* for populating the `replyIndex` of the post by referencing this value on
|
||||
* the parent.
|
||||
*/
|
||||
repliesIndexCounter: number
|
||||
/**
|
||||
* The index-0-based index of this reply in the parent post's replies.
|
||||
*/
|
||||
replyIndex: number
|
||||
/**
|
||||
* Each slice is responsible for rendering reply lines based on its depth.
|
||||
* This value corresponds to any line indices that can be skipped e.g.
|
||||
* because there are no further replies below this sub-tree to render.
|
||||
*/
|
||||
skippedIndentIndices: Set<number>
|
||||
/**
|
||||
* Indicates and stores parent data IF that parent has additional unhydrated
|
||||
* replies. This value is passed down to children along the left/lower-most
|
||||
* branch of the tree. When the end is reached, a "read more" is inserted.
|
||||
*/
|
||||
upcomingParentReadMore?: TraversalMetadata
|
||||
}
|
170
src/state/queries/usePostThread/utils.ts
Normal file
170
src/state/queries/usePostThread/utils.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {isDevMode} from '#/storage/hooks/dev-mode'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function getThreadgateRecord(
|
||||
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
|
||||
) {
|
||||
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
|
||||
view?.record,
|
||||
AppBskyFeedThreadgate.isRecord,
|
||||
)
|
||||
? view?.record
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
if (post.record.reply?.root?.uri) {
|
||||
return new AtUri(post.record.reply.root.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPostRecord(post: AppBskyFeedDefs.PostView) {
|
||||
return post.record as AppBskyFeedPost.Record
|
||||
}
|
||||
|
||||
export function getTraversalMetadata({
|
||||
item,
|
||||
prevItem,
|
||||
nextItem,
|
||||
parentMetadata,
|
||||
}: {
|
||||
item: ApiThreadItem
|
||||
prevItem?: ApiThreadItem
|
||||
nextItem?: ApiThreadItem
|
||||
parentMetadata?: TraversalMetadata
|
||||
}): TraversalMetadata {
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
throw new Error(`Expected thread item to be a post`)
|
||||
}
|
||||
const repliesCount = item.value.post.replyCount || 0
|
||||
const repliesUnhydrated = item.value.moreReplies || 0
|
||||
const metadata = {
|
||||
depth: item.depth,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
isLastChild: false,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
isLastSibling: false,
|
||||
/*
|
||||
* If it's a top level reply, bc we render each top-level branch as a
|
||||
* separate tree, it's implicitly part of the last branch. For subsequent
|
||||
* replies, we'll override this after traversal.
|
||||
*/
|
||||
isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
|
||||
nextItemDepth: nextItem?.depth,
|
||||
parentMetadata,
|
||||
prevItemDepth: prevItem?.depth,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
precedesChildReadMore: false,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
followsReadMoreUp: false,
|
||||
postData: {
|
||||
uri: item.uri,
|
||||
authorHandle: item.value.post.author.handle,
|
||||
},
|
||||
repliesCount,
|
||||
repliesUnhydrated,
|
||||
repliesSeenCounter: 0,
|
||||
repliesIndexCounter: 0,
|
||||
replyIndex: 0,
|
||||
skippedIndentIndices: new Set<number>(),
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
// @ts-ignore dev only for debugging
|
||||
metadata.postData.text = getPostRecord(item.value.post).text
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
export function storeTraversalMetadata(
|
||||
metadatas: Map<string, TraversalMetadata>,
|
||||
metadata: TraversalMetadata,
|
||||
) {
|
||||
metadatas.set(metadata.postData.uri, metadata)
|
||||
|
||||
if (isDevMode()) {
|
||||
// @ts-ignore dev only for debugging
|
||||
metadatas.set(metadata.postData.text, metadata)
|
||||
// @ts-ignore
|
||||
window.__thread = metadatas
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPostUI({
|
||||
depth,
|
||||
repliesCount,
|
||||
prevItemDepth,
|
||||
isLastChild,
|
||||
skippedIndentIndices,
|
||||
repliesSeenCounter,
|
||||
repliesUnhydrated,
|
||||
precedesChildReadMore,
|
||||
followsReadMoreUp,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] {
|
||||
const isReplyAndHasReplies =
|
||||
depth > 0 &&
|
||||
repliesCount > 0 &&
|
||||
(repliesCount - repliesUnhydrated === repliesSeenCounter ||
|
||||
repliesSeenCounter > 0)
|
||||
return {
|
||||
isAnchor: depth === 0,
|
||||
showParentReplyLine:
|
||||
followsReadMoreUp ||
|
||||
(!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
|
||||
showChildReplyLine: depth < 0 || isReplyAndHasReplies,
|
||||
indent: depth,
|
||||
/*
|
||||
* If there are no slices below this one, or the next slice has a depth <=
|
||||
* than the depth of this post, it's the last child of the reply tree. It
|
||||
* is not necessarily the last leaf in the parent branch, since it could
|
||||
* have another sibling.
|
||||
*/
|
||||
isLastChild,
|
||||
skippedIndentIndices,
|
||||
precedesChildReadMore: precedesChildReadMore ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPostNoUnauthenticatedUI({
|
||||
depth,
|
||||
prevItemDepth,
|
||||
}: {
|
||||
depth: number
|
||||
prevItemDepth?: number
|
||||
nextItemDepth?: number
|
||||
}): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] {
|
||||
return {
|
||||
showChildReplyLine: depth < 0,
|
||||
showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
|
||||
}
|
||||
}
|
183
src/state/queries/usePostThread/views.ts
Normal file
183
src/state/queries/usePostThread/views.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
|
||||
export function threadPostNoUnauthenticated({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> {
|
||||
return {
|
||||
type: 'threadPostNoUnauthenticated',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated,
|
||||
// @ts-ignore populated by the traversal
|
||||
ui: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPostNotFound({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNotFound'}> {
|
||||
return {
|
||||
type: 'threadPostNotFound',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPostBlocked({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostBlocked'}> {
|
||||
return {
|
||||
type: 'threadPostBlocked',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPost({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
}: {
|
||||
uri: string
|
||||
depth: number
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
moderationOpts: ModerationOpts
|
||||
threadgateHiddenReplies: Set<string>
|
||||
}): Extract<ThreadItem, {type: 'threadPost'}> {
|
||||
const moderation = moderatePost(value.post, moderationOpts)
|
||||
const modui = moderation.ui('contentList')
|
||||
const blurred = modui.blur || modui.filter
|
||||
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
|
||||
const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
|
||||
const isBlurred = hiddenByThreadgate || blurred || muted
|
||||
return {
|
||||
type: 'threadPost',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: {
|
||||
...value,
|
||||
/*
|
||||
* Do not spread anything here, load bearing for post shadow strict
|
||||
* equality reference checks.
|
||||
*/
|
||||
post: value.post as Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
||||
record: AppBskyFeedPost.Record
|
||||
},
|
||||
},
|
||||
isBlurred,
|
||||
moderation,
|
||||
// @ts-ignore populated by the traversal
|
||||
ui: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function readMore({
|
||||
depth,
|
||||
repliesUnhydrated,
|
||||
skippedIndentIndices,
|
||||
postData,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMore'}> {
|
||||
const urip = new AtUri(postData.uri)
|
||||
const href = makeProfileLink(
|
||||
{
|
||||
did: urip.host,
|
||||
handle: postData.authorHandle,
|
||||
},
|
||||
'post',
|
||||
urip.rkey,
|
||||
)
|
||||
return {
|
||||
type: 'readMore' as const,
|
||||
key: `readMore:${postData.uri}`,
|
||||
href,
|
||||
moreReplies: repliesUnhydrated,
|
||||
depth,
|
||||
skippedIndentIndices,
|
||||
}
|
||||
}
|
||||
|
||||
export function readMoreUp({
|
||||
postData,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMoreUp'}> {
|
||||
const urip = new AtUri(postData.uri)
|
||||
const href = makeProfileLink(
|
||||
{
|
||||
did: urip.host,
|
||||
handle: postData.authorHandle,
|
||||
},
|
||||
'post',
|
||||
urip.rkey,
|
||||
)
|
||||
return {
|
||||
type: 'readMoreUp' as const,
|
||||
key: `readMoreUp:${postData.uri}`,
|
||||
href,
|
||||
}
|
||||
}
|
||||
|
||||
export function skeleton({
|
||||
key,
|
||||
item,
|
||||
}: Omit<Extract<ThreadItem, {type: 'skeleton'}>, 'type'>): Extract<
|
||||
ThreadItem,
|
||||
{type: 'skeleton'}
|
||||
> {
|
||||
return {
|
||||
type: 'skeleton',
|
||||
key,
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
export function postViewToThreadPlaceholder(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
): $Typed<
|
||||
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
}
|
||||
> {
|
||||
return {
|
||||
$type: 'app.bsky.unspecced.getPostThreadV2#threadItem',
|
||||
uri: post.uri,
|
||||
depth: 0, // reset to 0 for highlighted post
|
||||
value: {
|
||||
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
||||
post,
|
||||
opThread: false,
|
||||
moreParents: false,
|
||||
moreReplies: 0,
|
||||
hiddenByThreadgate: false,
|
||||
mutedByViewer: false,
|
||||
},
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import React from 'react'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
@ -24,9 +25,17 @@ export interface ComposerOptsPostRef {
|
||||
moderation?: ModerationDecision
|
||||
}
|
||||
|
||||
export type OnPostSuccessData =
|
||||
| {
|
||||
replyToUri?: string
|
||||
posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
|
||||
}
|
||||
| undefined
|
||||
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
quote?: AppBskyFeedDefs.PostView
|
||||
mention?: string // handle of user to mention
|
||||
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
|
||||
|
@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({
|
||||
return set
|
||||
}, [uris, recentlyUnhiddenUris, threadgateRecord])
|
||||
}
|
||||
|
||||
export function useMergeThreadgateHiddenReplies() {
|
||||
const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
|
||||
return React.useCallback(
|
||||
(threadgate?: AppBskyFeedThreadgate.Record) => {
|
||||
const set = new Set([...(threadgate?.hiddenReplies || []), ...uris])
|
||||
for (const uri of recentlyUnhiddenUris) {
|
||||
set.delete(uri)
|
||||
}
|
||||
return set
|
||||
},
|
||||
[uris, recentlyUnhiddenUris],
|
||||
)
|
||||
}
|
||||
|
@ -5,3 +5,17 @@ export function useDevMode() {
|
||||
|
||||
return [devMode, setDevMode] as const
|
||||
}
|
||||
|
||||
let cachedIsDevMode: boolean | undefined
|
||||
/**
|
||||
* Does not update when toggling dev mode on or off. This util simply retrieves
|
||||
* the value and caches in memory indefinitely. So after an update, you'll need
|
||||
* to reload the app so it can pull a fresh value from storage.
|
||||
*/
|
||||
export function isDevMode() {
|
||||
if (__DEV__) return true
|
||||
if (cachedIsDevMode === undefined) {
|
||||
cachedIsDevMode = device.get(['devMode']) ?? false
|
||||
}
|
||||
return cachedIsDevMode
|
||||
}
|
||||
|
5
src/types/utils.ts
Normal file
5
src/types/utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type Literal<T, A = string> = T extends A
|
||||
? string extends T
|
||||
? never
|
||||
: T
|
||||
: never
|
@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedGetPostThread,
|
||||
AppBskyUnspeccedDefs,
|
||||
type BskyAgent,
|
||||
type RichText,
|
||||
} from '@atproto/api'
|
||||
@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import * as apilib from '#/lib/api/index'
|
||||
import {EmbeddingDisabledError} from '#/lib/api/resolve'
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {
|
||||
MAX_GRAPHEME_LENGTH,
|
||||
@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type Gif} from '#/state/queries/tenor'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {type ComposerOpts} from '#/state/shell/composer'
|
||||
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||
import {
|
||||
@ -152,6 +154,7 @@ type Props = ComposerOpts
|
||||
export const ComposePost = ({
|
||||
replyTo,
|
||||
onPost,
|
||||
onPostSuccess,
|
||||
quote: initQuote,
|
||||
mention: initMention,
|
||||
openEmojiPicker,
|
||||
@ -388,8 +391,10 @@ export const ComposePost = ({
|
||||
setError('')
|
||||
setIsPublishing(true)
|
||||
|
||||
let postUri
|
||||
let postUri: string | undefined
|
||||
let postSuccessData: OnPostSuccessData
|
||||
try {
|
||||
logger.info(`composer: posting...`)
|
||||
postUri = (
|
||||
await apilib.post(agent, queryClient, {
|
||||
thread,
|
||||
@ -398,16 +403,48 @@ export const ComposePost = ({
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
})
|
||||
).uris[0]
|
||||
|
||||
/*
|
||||
* Wait for app view to have received the post(s). If this fails, it's
|
||||
* ok, because the post _was_ actually published above.
|
||||
*/
|
||||
try {
|
||||
await whenAppViewReady(agent, postUri, res => {
|
||||
const postedThread = res?.data?.thread
|
||||
return AppBskyFeedDefs.isThreadViewPost(postedThread)
|
||||
})
|
||||
if (postUri) {
|
||||
logger.info(`composer: waiting for app view`)
|
||||
|
||||
const posts = await retry(
|
||||
5,
|
||||
_e => true,
|
||||
async () => {
|
||||
const res = await agent.app.bsky.unspecced.getPostThreadV2({
|
||||
anchor: postUri!,
|
||||
above: false,
|
||||
below: thread.posts.length - 1,
|
||||
branchingFactor: 1,
|
||||
})
|
||||
if (res.data.thread.length !== thread.posts.length) {
|
||||
throw new Error(`composer: app view is not ready`)
|
||||
}
|
||||
if (
|
||||
!res.data.thread.every(p =>
|
||||
AppBskyUnspeccedDefs.isThreadItemPost(p.value),
|
||||
)
|
||||
) {
|
||||
throw new Error(`composer: app view returned non-post items`)
|
||||
}
|
||||
return res.data.thread
|
||||
},
|
||||
1e3,
|
||||
)
|
||||
postSuccessData = {
|
||||
replyToUri: replyTo?.uri,
|
||||
posts,
|
||||
}
|
||||
}
|
||||
} catch (waitErr: any) {
|
||||
logger.error(waitErr, {
|
||||
message: `Waiting for app view failed`,
|
||||
logger.info(`composer: waiting for app view failed`, {
|
||||
safeMessage: waitErr,
|
||||
})
|
||||
// Keep going because the post *was* published.
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
@ -465,12 +502,14 @@ export const ComposePost = ({
|
||||
quotedThread.post.quoteCount !== initQuote.quoteCount
|
||||
) {
|
||||
onPost?.(postUri)
|
||||
onPostSuccess?.(postSuccessData)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
onPost?.(postUri)
|
||||
onPostSuccess?.(postSuccessData)
|
||||
}
|
||||
onClose()
|
||||
Toast.show(
|
||||
@ -489,6 +528,7 @@ export const ComposePost = ({
|
||||
langPrefs.postLanguage,
|
||||
onClose,
|
||||
onPost,
|
||||
onPostSuccess,
|
||||
initQuote,
|
||||
replyTo,
|
||||
setLangPrefs,
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, {memo, useRef, useState} from 'react'
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedThreadgate,
|
||||
@ -13,11 +12,9 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
@ -37,6 +34,7 @@ import {
|
||||
import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {useUnstablePostSource} from '#/state/unstable-post-source'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
@ -301,11 +299,14 @@ export function PostThread({uri}: {uri: string}) {
|
||||
// maintainVisibleContentPosition and onContentSizeChange
|
||||
// to "hold onto" the correct row instead of the first one.
|
||||
|
||||
/*
|
||||
* This is basically `!!parents.length`, see notes on `isParentLoading`
|
||||
*/
|
||||
if (!highlightedPost.ctx.isParentLoading && !deferParents) {
|
||||
// When progressively revealing parents, rendering a placeholder
|
||||
// here will cause scrolling jumps. Don't add it unless you test it.
|
||||
// QT'ing this thread is a great way to test all the scrolling hacks:
|
||||
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
|
||||
// https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
|
||||
|
||||
// Everything is loaded
|
||||
let startIndex = Math.max(0, parents.length - maxParents)
|
||||
@ -581,6 +582,9 @@ export function PostThread({uri}: {uri: string}) {
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onScrollToTop={onScrollToTop}
|
||||
/**
|
||||
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
|
||||
*/
|
||||
maintainVisibleContentPosition={
|
||||
isNative && hasParents
|
||||
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
||||
@ -729,17 +733,16 @@ let ThreadMenu = ({
|
||||
ThreadMenu = memo(ThreadMenu)
|
||||
|
||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 13, 60),
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)
|
||||
@ -904,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
@ -1,20 +1,25 @@
|
||||
import {View} from 'react-native'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostThreadComposePrompt({
|
||||
onPressCompose,
|
||||
style,
|
||||
}: {
|
||||
onPressCompose: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
@ -28,29 +33,49 @@ export function PostThreadComposePrompt({
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
|
||||
useHideBottomBarBorderForScreen()
|
||||
|
||||
return (
|
||||
<PressableScale
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Compose reply`)}
|
||||
accessibilityHint={_(msg`Opens composer`)}
|
||||
<View
|
||||
style={[
|
||||
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
|
||||
a.px_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]}
|
||||
onPress={() => {
|
||||
onPressCompose()
|
||||
playHaptic('Light')
|
||||
}}
|
||||
onLongPress={ios(() => {
|
||||
onPressCompose()
|
||||
playHaptic('Heavy')
|
||||
})}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}>
|
||||
<View
|
||||
gtMobile
|
||||
? [
|
||||
a.py_xs,
|
||||
a.px_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]
|
||||
: [a.px_md, a.pb_2xs],
|
||||
style,
|
||||
]}>
|
||||
{!gtMobile && (
|
||||
<LinearGradient
|
||||
key={t.name} // android does not update when you change the colors. sigh.
|
||||
start={[0.5, 0]}
|
||||
end={[0.5, 1]}
|
||||
colors={[
|
||||
transparentifyColor(t.atoms.bg.backgroundColor, 0),
|
||||
t.atoms.bg.backgroundColor,
|
||||
]}
|
||||
locations={[0.15, 0.4]}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
)}
|
||||
<PressableScale
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Compose reply`)}
|
||||
accessibilityHint={_(msg`Opens composer`)}
|
||||
onPress={() => {
|
||||
onPressCompose()
|
||||
playHaptic('Light')
|
||||
}}
|
||||
onLongPress={ios(() => {
|
||||
onPressCompose()
|
||||
playHaptic('Heavy')
|
||||
})}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
@ -58,6 +83,7 @@ export function PostThreadComposePrompt({
|
||||
a.gap_sm,
|
||||
a.rounded_full,
|
||||
(!gtMobile || hovered) && t.atoms.bg_contrast_25,
|
||||
native([a.border, t.atoms.border_contrast_low]),
|
||||
a.transition_color,
|
||||
]}>
|
||||
<UserAvatar
|
||||
@ -68,7 +94,7 @@ export function PostThreadComposePrompt({
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Write your reply</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</PressableScale>
|
||||
</PressableScale>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {type ThreadPost} from '#/state/queries/post-thread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {type PostSource} from '#/state/unstable-post-source'
|
||||
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
|
||||
@ -85,6 +86,7 @@ export function PostThreadItem({
|
||||
hasPrecedingItem,
|
||||
overrideBlur,
|
||||
onPostReply,
|
||||
onPostSuccess,
|
||||
hideTopBorder,
|
||||
threadgateRecord,
|
||||
anchorPostSource,
|
||||
@ -103,6 +105,7 @@ export function PostThreadItem({
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
hideTopBorder?: boolean
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
anchorPostSource?: PostSource
|
||||
@ -139,6 +142,7 @@ export function PostThreadItem({
|
||||
hasPrecedingItem={hasPrecedingItem}
|
||||
overrideBlur={overrideBlur}
|
||||
onPostReply={onPostReply}
|
||||
onPostSuccess={onPostSuccess}
|
||||
hideTopBorder={hideTopBorder}
|
||||
threadgateRecord={threadgateRecord}
|
||||
anchorPostSource={anchorPostSource}
|
||||
@ -185,6 +189,7 @@ let PostThreadItemLoaded = ({
|
||||
hasPrecedingItem,
|
||||
overrideBlur,
|
||||
onPostReply,
|
||||
onPostSuccess,
|
||||
hideTopBorder,
|
||||
threadgateRecord,
|
||||
anchorPostSource,
|
||||
@ -204,6 +209,7 @@ let PostThreadItemLoaded = ({
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
onPostSuccess?: (data: OnPostSuccessData) => void
|
||||
hideTopBorder?: boolean
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
anchorPostSource?: PostSource
|
||||
@ -298,6 +304,7 @@ let PostThreadItemLoaded = ({
|
||||
moderation,
|
||||
},
|
||||
onPost: onPostReply,
|
||||
onPostSuccess: onPostSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,28 +1,38 @@
|
||||
import React from 'react'
|
||||
import {useCallback} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
|
||||
import {PostThread} from '#/screens/PostThread'
|
||||
import * as Layout from '#/components/Layout'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
||||
export function PostThreadScreen({route}: Props) {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const gate = useGate()
|
||||
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="postThreadScreen">
|
||||
<PostThreadComponent uri={uri} />
|
||||
{gate('post_threads_v2_unspecced') || __DEV__ ? (
|
||||
<PostThread uri={uri} />
|
||||
) : (
|
||||
<PostThreadComponent uri={uri} />
|
||||
)}
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ export function Composer({}: {winHeight: number}) {
|
||||
cancelRef={ref}
|
||||
replyTo={state?.replyTo}
|
||||
onPost={state?.onPost}
|
||||
onPostSuccess={state?.onPostSuccess}
|
||||
quote={state?.quote}
|
||||
mention={state?.mention}
|
||||
text={state?.text}
|
||||
|
@ -49,6 +49,7 @@ export function Composer({winHeight}: {winHeight: number}) {
|
||||
<ComposePost
|
||||
replyTo={state.replyTo}
|
||||
onPost={state.onPost}
|
||||
onPostSuccess={state.onPostSuccess}
|
||||
quote={state.quote}
|
||||
mention={state.mention}
|
||||
text={state.text}
|
||||
|
@ -105,6 +105,7 @@ function Inner({state}: {state: ComposerOpts}) {
|
||||
replyTo={state.replyTo}
|
||||
quote={state.quote}
|
||||
onPost={state.onPost}
|
||||
onPostSuccess={state.onPostSuccess}
|
||||
mention={state.mention}
|
||||
openEmojiPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
|
@ -12,6 +12,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {BOTTOM_BAR_AVI} from '#/lib/demo'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
@ -73,6 +74,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const playHaptic = useHaptics()
|
||||
const hasHomeBadge = useHomeBadge()
|
||||
const gate = useGate()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const iconWidth = 28
|
||||
|
||||
const showSignIn = useCallback(() => {
|
||||
@ -146,7 +148,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
pal.view,
|
||||
pal.border,
|
||||
hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
|
@ -5,16 +5,18 @@ import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useHomeBadge} from '#/state/home-badge'
|
||||
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
@ -49,6 +51,8 @@ export function BottomBarWeb() {
|
||||
const footerMinimalShellTransform = useMinimalShellFooterTransform()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const iconWidth = 26
|
||||
|
||||
const unreadMessageCount = useUnreadMessageCount()
|
||||
@ -74,9 +78,12 @@ export function BottomBarWeb() {
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
footerMinimalShellTransform,
|
||||
]}>
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
{hasSession ? (
|
||||
<>
|
||||
<NavItem
|
||||
|
14
yarn.lock
14
yarn.lock
@ -63,6 +63,20 @@
|
||||
"@atproto/xrpc" "^0.7.0"
|
||||
"@atproto/xrpc-server" "^0.7.18"
|
||||
|
||||
"@atproto/api@^0.15.14":
|
||||
version "0.15.14"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.14.tgz#41ff6ce2e7603119a005b7b5ce8e64551ec84879"
|
||||
integrity sha512-FHEMAdscG+r2OFcZUIzPyTDpwzRAyinRsIIaTcuqe0MgZWF4CEGNAKPos0IbecBzMxTOzUHE18dQDKhoXMdgvg==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.2"
|
||||
"@atproto/lexicon" "^0.4.11"
|
||||
"@atproto/syntax" "^0.4.0"
|
||||
"@atproto/xrpc" "^0.7.0"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/api@^0.15.9":
|
||||
version "0.15.9"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.9.tgz#f8c40afd6e414ab107d63d6f08d9e264bf9a149a"
|
||||
|
Loading…
x
Reference in New Issue
Block a user