diff options
60 files changed, 5416 insertions, 86 deletions
diff --git a/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..e34b5eb38 --- /dev/null +++ b/assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg @@ -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> diff --git a/assets/icons/circlePlus_stroke2_corner0_rounded.svg b/assets/icons/circlePlus_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..aa517a224 --- /dev/null +++ b/assets/icons/circlePlus_stroke2_corner0_rounded.svg @@ -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> diff --git a/assets/icons/tree_stroke2_corner0_rounded.svg b/assets/icons/tree_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..b8488c8be --- /dev/null +++ b/assets/icons/tree_stroke2_corner0_rounded.svg @@ -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> diff --git a/package.json b/package.json index ac2171a22..f0bc5bbe7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.native.tsx b/src/App.native.tsx index baab8c838..25d186dcf 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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> diff --git a/src/App.web.tsx b/src/App.web.tsx index c5ec0473c..fa8e24e53 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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> diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 02ad98c5f..79ec41679 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1051,4 +1051,8 @@ export const atoms = { transform: [], }, }) as {transform: Exclude<ViewStyle['transform'], string | undefined>}, + + pointer: web({ + cursor: 'pointer', + }), } as const diff --git a/src/alf/util/__tests__/colors.test.ts b/src/alf/util/__tests__/colors.test.ts new file mode 100644 index 000000000..350b6ff4a --- /dev/null +++ b/src/alf/util/__tests__/colors.test.ts @@ -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`, + ) + }) +}) diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts index 8d769b51b..574ab0a49 100644 --- a/src/alf/util/colorGeneration.ts +++ b/src/alf/util/colorGeneration.ts @@ -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 +} diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 000000000..14c3177c5 --- /dev/null +++ b/src/components/Skeleton.tsx @@ -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> +} diff --git a/src/components/icons/ArrowTopCircle.tsx b/src/components/icons/ArrowTopCircle.tsx new file mode 100644 index 000000000..2d250367f --- /dev/null +++ b/src/components/icons/ArrowTopCircle.tsx @@ -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', +}) diff --git a/src/components/icons/CirclePlus.tsx b/src/components/icons/CirclePlus.tsx new file mode 100644 index 000000000..690e77326 --- /dev/null +++ b/src/components/icons/CirclePlus.tsx @@ -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', +}) diff --git a/src/components/icons/Tree.tsx b/src/components/icons/Tree.tsx new file mode 100644 index 000000000..5c2c79872 --- /dev/null +++ b/src/components/icons/Tree.tsx @@ -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', +}) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index abf78de55..8a1729091 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -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 } diff --git a/src/lib/hooks/useCallOnce.ts b/src/lib/hooks/useCallOnce.ts new file mode 100644 index 000000000..fa01cf4aa --- /dev/null +++ b/src/lib/hooks/useCallOnce.ts @@ -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], + ) +} diff --git a/src/lib/hooks/useHideBottomBarBorder.tsx b/src/lib/hooks/useHideBottomBarBorder.tsx new file mode 100644 index 000000000..e21184fda --- /dev/null +++ b/src/lib/hooks/useHideBottomBarBorder.tsx @@ -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> + ) +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index c67bb60a3..3b1106480 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -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' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index d01a92825..31af1be2b 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -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': {} } diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index ce33ca3aa..c84371f2c 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -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({ diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx new file mode 100644 index 000000000..def3979b7 --- /dev/null +++ b/src/screens/PostThread/components/HeaderDropdown.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadError.tsx b/src/screens/PostThread/components/ThreadError.tsx new file mode 100644 index 000000000..e1ca23cf9 --- /dev/null +++ b/src/screens/PostThread/components/ThreadError.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx new file mode 100644 index 000000000..0aacd4e77 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx new file mode 100644 index 000000000..c8477e211 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx new file mode 100644 index 000000000..1f63b10cd --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx new file mode 100644 index 000000000..552d8f813 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemPostTombstone.tsx b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx new file mode 100644 index 000000000..4f1ab450b --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemPostTombstone.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemReadMore.tsx b/src/screens/PostThread/components/ThreadItemReadMore.tsx new file mode 100644 index 000000000..22ae63395 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMore.tsx @@ -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> + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx new file mode 100644 index 000000000..da18a19e9 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReadMoreUp.tsx @@ -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> + ) +}) diff --git a/src/screens/PostThread/components/ThreadItemReplyComposer.tsx b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx new file mode 100644 index 000000000..f1862569e --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemReplyComposer.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx new file mode 100644 index 000000000..e418375b6 --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx new file mode 100644 index 000000000..d86d2ef6f --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -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> + ) +} diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts new file mode 100644 index 000000000..cf559ac4e --- /dev/null +++ b/src/screens/PostThread/const.ts @@ -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 diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx new file mode 100644 index 000000000..a4f94851a --- /dev/null +++ b/src/screens/PostThread/index.tsx @@ -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 +} diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index 701d3d9e5..af3cf915f 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -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() diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 8a75751f7..495b3bc62 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -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> diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 3f9644879..90fddda2b 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -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 } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index a1212d8a2..31bf55d13 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -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) } diff --git a/src/state/queries/preferences/useThreadPreferences.ts b/src/state/queries/preferences/useThreadPreferences.ts new file mode 100644 index 000000000..dc3122a72 --- /dev/null +++ b/src/state/queries/preferences/useThreadPreferences.ts @@ -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' +} diff --git a/src/state/queries/usePostThread/const.ts b/src/state/queries/usePostThread/const.ts new file mode 100644 index 000000000..9b7436130 --- /dev/null +++ b/src/state/queries/usePostThread/const.ts @@ -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 diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts new file mode 100644 index 000000000..782888cfb --- /dev/null +++ b/src/state/queries/usePostThread/index.ts @@ -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, + ], + ) +} diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts new file mode 100644 index 000000000..871033395 --- /dev/null +++ b/src/state/queries/usePostThread/queryCache.ts @@ -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 + } + } + } + } +} diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts new file mode 100644 index 000000000..fbae4ecdb --- /dev/null +++ b/src/state/queries/usePostThread/traversal.ts @@ -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, + } +} diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts new file mode 100644 index 000000000..2f370b0ab --- /dev/null +++ b/src/state/queries/usePostThread/types.ts @@ -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 +} diff --git a/src/state/queries/usePostThread/utils.ts b/src/state/queries/usePostThread/utils.ts new file mode 100644 index 000000000..b8ab340d8 --- /dev/null +++ b/src/state/queries/usePostThread/utils.ts @@ -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), + } +} diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts new file mode 100644 index 000000000..71acfc77b --- /dev/null +++ b/src/state/queries/usePostThread/views.ts @@ -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, + }, + } +} diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index ad07333be..b31794248 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -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 diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx index 60806f570..9d116c7f9 100644 --- a/src/state/threadgate-hidden-replies.tsx +++ b/src/state/threadgate-hidden-replies.tsx @@ -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], + ) +} diff --git a/src/storage/hooks/dev-mode.ts b/src/storage/hooks/dev-mode.ts index 49eca3bb1..331825c48 100644 --- a/src/storage/hooks/dev-mode.ts +++ b/src/storage/hooks/dev-mode.ts @@ -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 +} diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 000000000..f64922a1f --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,5 @@ +export type Literal<T, A = string> = T extends A + ? string extends T + ? never + : T + : never diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 42f057803..f5b29664a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 5bec9ced1..94cc04f54 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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, - }, -}) diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 40acff376..f45b16085 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -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> ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 576b195a0..5184047cb 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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, }) } diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 1bad9b6cd..cc611e0d6 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -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> ) } diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 8b53f4041..393b8f80e 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -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} diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index e40c3528b..a17de6163 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -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} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index ce3695212..a27e89168 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -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} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 5e9168ecd..01aa4afc4 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -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, ]} diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 7a320cb43..8dce85cd1 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -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 diff --git a/yarn.lock b/yarn.lock index d299bede5..9c6a36847 100644 --- a/yarn.lock +++ b/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" |