Bluesky app fork with some witchin' additions 💫

[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)

* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

* Checkpoint: think we're good, needs more cleanup

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

* Fix hidden reply handling, seen count, before/after calc

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

* Make optimistic deleted state not look like crap in tree view

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

authored by Eric Bailey surfdude29 Samuel Newman and committed by GitHub 61004b88 143d5f3b

+1
assets/icons/arrowTopCircle_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/circlePlus_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/tree_stroke2_corner0_rounded.svg
··· 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>
+1 -1
package.json
··· 69 69 "icons:optimize": "svgo -f ./assets/icons" 70 70 }, 71 71 "dependencies": { 72 - "@atproto/api": "^0.15.9", 72 + "@atproto/api": "^0.15.14", 73 73 "@bitdrift/react-native": "^0.6.8", 74 74 "@braintree/sanitize-url": "^6.0.2", 75 75 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+11 -8
src/App.native.tsx
··· 72 72 import {Splash} from '#/Splash' 73 73 import {BottomSheetProvider} from '../modules/bottom-sheet' 74 74 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 75 + import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 75 76 76 77 SplashScreen.preventAutoHideAsync() 77 78 if (isIOS) { ··· 150 151 <MutedThreadsProvider> 151 152 <ProgressGuideProvider> 152 153 <ServiceAccountManager> 153 - <GestureHandlerRootView 154 - style={s.h100pct}> 155 - <IntentDialogProvider> 156 - <TestCtrls /> 157 - <Shell /> 158 - <NuxDialogs /> 159 - </IntentDialogProvider> 160 - </GestureHandlerRootView> 154 + <HideBottomBarBorderProvider> 155 + <GestureHandlerRootView 156 + style={s.h100pct}> 157 + <IntentDialogProvider> 158 + <TestCtrls /> 159 + <Shell /> 160 + <NuxDialogs /> 161 + </IntentDialogProvider> 162 + </GestureHandlerRootView> 163 + </HideBottomBarBorderProvider> 161 164 </ServiceAccountManager> 162 165 </ProgressGuideProvider> 163 166 </MutedThreadsProvider>
+7 -4
src/App.web.tsx
··· 61 61 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' 62 62 import {Provider as PortalProvider} from '#/components/Portal' 63 63 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 64 + import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 64 65 65 66 /** 66 67 * Begin geolocation ASAP ··· 131 132 <SafeAreaProvider> 132 133 <ProgressGuideProvider> 133 134 <ServiceConfigProvider> 134 - <IntentDialogProvider> 135 - <Shell /> 136 - <NuxDialogs /> 137 - </IntentDialogProvider> 135 + <HideBottomBarBorderProvider> 136 + <IntentDialogProvider> 137 + <Shell /> 138 + <NuxDialogs /> 139 + </IntentDialogProvider> 140 + </HideBottomBarBorderProvider> 138 141 </ServiceConfigProvider> 139 142 </ProgressGuideProvider> 140 143 </SafeAreaProvider>
+4
src/alf/atoms.ts
··· 1051 1051 transform: [], 1052 1052 }, 1053 1053 }) as {transform: Exclude<ViewStyle['transform'], string | undefined>}, 1054 + 1055 + pointer: web({ 1056 + cursor: 'pointer', 1057 + }), 1054 1058 } as const
+48
src/alf/util/__tests__/colors.test.ts
··· 1 + import {jest} from '@jest/globals' 2 + 3 + import {logger} from '#/logger' 4 + import {transparentifyColor} from '../colorGeneration' 5 + 6 + jest.mock('#/logger', () => ({ 7 + logger: {warn: jest.fn()}, 8 + })) 9 + 10 + describe('transparentifyColor', () => { 11 + beforeEach(() => { 12 + jest.clearAllMocks() 13 + }) 14 + 15 + it('converts hsl() to hsla()', () => { 16 + const result = transparentifyColor('hsl(120 100% 50%)', 0.5) 17 + expect(result).toBe('hsla(120 100% 50%, 0.5)') 18 + }) 19 + 20 + it('converts hsl() to hsla() - fully transparent', () => { 21 + const result = transparentifyColor('hsl(120 100% 50%)', 0) 22 + expect(result).toBe('hsla(120 100% 50%, 0)') 23 + }) 24 + 25 + it('converts rgb() to rgba()', () => { 26 + const result = transparentifyColor('rgb(255 0 0)', 0.75) 27 + expect(result).toBe('rgba(255 0 0, 0.75)') 28 + }) 29 + 30 + it('expands 3-digit hex and appends alpha channel', () => { 31 + const result = transparentifyColor('#abc', 0.4) 32 + expect(result).toBe('#aabbcc66') 33 + }) 34 + 35 + it('appends alpha to 6-digit hex', () => { 36 + const result = transparentifyColor('#aabbcc', 0.4) 37 + expect(result).toBe('#aabbcc66') 38 + }) 39 + 40 + it('returns the original string and warns for unsupported formats', () => { 41 + const unsupported = 'blue' 42 + const result = transparentifyColor(unsupported, 0.5) 43 + expect(result).toBe(unsupported) 44 + expect(logger.warn).toHaveBeenCalledWith( 45 + `Could not make '${unsupported}' transparent`, 46 + ) 47 + }) 48 + })
+28
src/alf/util/colorGeneration.ts
··· 1 + import {logger} from '#/logger' 2 + 1 3 export const BLUE_HUE = 211 2 4 export const RED_HUE = 346 3 5 export const GREEN_HUE = 152 ··· 19 21 export const defaultScale = generateScale(6, 100) 20 22 // dim shifted 6% lighter 21 23 export const dimScale = generateScale(12, 100) 24 + 25 + export function transparentifyColor(color: string, alpha: number) { 26 + if (color.startsWith('hsl(')) { 27 + return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})` 28 + } else if (color.startsWith('rgb(')) { 29 + return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})` 30 + } else if (color.startsWith('#')) { 31 + if (color.length === 7) { 32 + const alphaHex = Math.round(alpha * 255).toString(16) 33 + // Per MDN: If there is only one number, it is duplicated: e means ee 34 + // https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color 35 + return color.slice(0, 7) + alphaHex.padStart(2, alphaHex) 36 + } else if (color.length === 4) { 37 + // convert to 6-digit hex before adding alpha 38 + const [r, g, b] = color.slice(1).split('') 39 + const alphaHex = Math.round(alpha * 255).toString(16) 40 + return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart( 41 + 2, 42 + alphaHex, 43 + )}` 44 + } 45 + } else { 46 + logger.warn(`Could not make '${color}' transparent`) 47 + } 48 + return color 49 + }
+107
src/components/Skeleton.tsx
··· 1 + import {type ReactNode} from 'react' 2 + import {View} from 'react-native' 3 + 4 + import { 5 + atoms as a, 6 + flatten, 7 + type TextStyleProp, 8 + useAlf, 9 + useTheme, 10 + type ViewStyleProp, 11 + } from '#/alf' 12 + import {normalizeTextStyles} from '#/alf/typography' 13 + 14 + type SkeletonProps = { 15 + blend?: boolean 16 + } 17 + 18 + export function Text({blend, style}: TextStyleProp & SkeletonProps) { 19 + const {fonts, flags, theme: t} = useAlf() 20 + const {width, ...flattened} = flatten(style) 21 + const {lineHeight = 14, ...rest} = normalizeTextStyles( 22 + [a.text_sm, a.leading_snug, flattened], 23 + { 24 + fontScale: fonts.scaleMultiplier, 25 + fontFamily: fonts.family, 26 + flags, 27 + }, 28 + ) 29 + return ( 30 + <View 31 + style={[a.flex_1, {maxWidth: width, paddingVertical: lineHeight * 0.15}]}> 32 + <View 33 + style={[ 34 + a.rounded_md, 35 + t.atoms.bg_contrast_25, 36 + { 37 + height: lineHeight * 0.7, 38 + opacity: blend ? 0.6 : 1, 39 + }, 40 + rest, 41 + ]} 42 + /> 43 + </View> 44 + ) 45 + } 46 + 47 + export function Circle({ 48 + children, 49 + size, 50 + blend, 51 + style, 52 + }: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) { 53 + const t = useTheme() 54 + return ( 55 + <View 56 + style={[ 57 + a.justify_center, 58 + a.align_center, 59 + a.rounded_full, 60 + t.atoms.bg_contrast_25, 61 + { 62 + width: size, 63 + height: size, 64 + opacity: blend ? 0.6 : 1, 65 + }, 66 + style, 67 + ]}> 68 + {children} 69 + </View> 70 + ) 71 + } 72 + 73 + export function Pill({ 74 + size, 75 + blend, 76 + style, 77 + }: ViewStyleProp & {size: number} & SkeletonProps) { 78 + const t = useTheme() 79 + return ( 80 + <View 81 + style={[ 82 + a.rounded_full, 83 + t.atoms.bg_contrast_25, 84 + { 85 + width: size * 1.618, 86 + height: size, 87 + opacity: blend ? 0.6 : 1, 88 + }, 89 + style, 90 + ]} 91 + /> 92 + ) 93 + } 94 + 95 + export function Col({ 96 + children, 97 + style, 98 + }: ViewStyleProp & {children?: React.ReactNode}) { 99 + return <View style={[a.flex_1, style]}>{children}</View> 100 + } 101 + 102 + export function Row({ 103 + children, 104 + style, 105 + }: ViewStyleProp & {children?: React.ReactNode}) { 106 + return <View style={[a.flex_row, style]}>{children}</View> 107 + }
+5
src/components/icons/ArrowTopCircle.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z', 5 + })
+5
src/components/icons/CirclePlus.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z', 5 + })
+5
src/components/icons/Tree.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+9 -4
src/lib/async/retry.ts
··· 1 + import {timeout} from '#/lib/async/timeout' 1 2 import {isNetworkError} from '#/lib/strings/errors' 2 3 3 4 export async function retry<P>( 4 5 retries: number, 5 - cond: (err: any) => boolean, 6 - fn: () => Promise<P>, 6 + shouldRetry: (err: any) => boolean, 7 + action: () => Promise<P>, 8 + delay?: number, 7 9 ): Promise<P> { 8 10 let lastErr 9 11 while (retries > 0) { 10 12 try { 11 - return await fn() 13 + return await action() 12 14 } catch (e: any) { 13 15 lastErr = e 14 - if (cond(e)) { 16 + if (shouldRetry(e)) { 17 + if (delay) { 18 + await timeout(delay) 19 + } 15 20 retries-- 16 21 continue 17 22 }
+20
src/lib/hooks/useCallOnce.ts
··· 1 + import {useCallback} from 'react' 2 + 3 + export enum OnceKey { 4 + PreferencesThread = 'preferences:thread', 5 + } 6 + 7 + const called: Record<OnceKey, boolean> = { 8 + [OnceKey.PreferencesThread]: false, 9 + } 10 + 11 + export function useCallOnce(key: OnceKey) { 12 + return useCallback( 13 + (cb: () => void) => { 14 + if (called[key] === true) return 15 + called[key] = true 16 + cb() 17 + }, 18 + [key], 19 + ) 20 + }
+50
src/lib/hooks/useHideBottomBarBorder.tsx
··· 1 + import {createContext, useCallback, useContext, useState} from 'react' 2 + import {useFocusEffect} from '@react-navigation/native' 3 + 4 + type HideBottomBarBorderSetter = () => () => void 5 + 6 + const HideBottomBarBorderContext = createContext<boolean>(false) 7 + const HideBottomBarBorderSetterContext = 8 + createContext<HideBottomBarBorderSetter | null>(null) 9 + 10 + export function useHideBottomBarBorderSetter() { 11 + const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext) 12 + if (!hideBottomBarBorder) { 13 + throw new Error( 14 + 'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider', 15 + ) 16 + } 17 + return hideBottomBarBorder 18 + } 19 + 20 + export function useHideBottomBarBorderForScreen() { 21 + const hideBorder = useHideBottomBarBorderSetter() 22 + 23 + useFocusEffect( 24 + useCallback(() => { 25 + const cleanup = hideBorder() 26 + return () => cleanup() 27 + }, [hideBorder]), 28 + ) 29 + } 30 + 31 + export function useHideBottomBarBorder() { 32 + return useContext(HideBottomBarBorderContext) 33 + } 34 + 35 + export function Provider({children}: {children: React.ReactNode}) { 36 + const [refCount, setRefCount] = useState(0) 37 + 38 + const setter = useCallback(() => { 39 + setRefCount(prev => prev + 1) 40 + return () => setRefCount(prev => prev - 1) 41 + }, []) 42 + 43 + return ( 44 + <HideBottomBarBorderSetterContext.Provider value={setter}> 45 + <HideBottomBarBorderContext.Provider value={refCount > 0}> 46 + {children} 47 + </HideBottomBarBorderContext.Provider> 48 + </HideBottomBarBorderSetterContext.Provider> 49 + ) 50 + }
+1
src/lib/statsig/gates.ts
··· 6 6 | 'explore_show_suggested_feeds' 7 7 | 'old_postonboarding' 8 8 | 'onboarding_add_video_feed' 9 + | 'post_threads_v2_unspecced' 9 10 | 'remove_show_latest_button' 10 11 | 'test_gate_1' 11 12 | 'test_gate_2'
+9
src/logger/metrics.ts
··· 434 434 'share:press:dmSelected': {} 435 435 'share:press:recentDm': {} 436 436 'share:press:embed': {} 437 + 438 + 'thread:click:showOtherReplies': {} 439 + 'thread:preferences:load': { 440 + [key: string]: any 441 + } 442 + 'thread:preferences:update': { 443 + [key: string]: any 444 + } 445 + 'thread:click:headerMenuOpen': {} 437 446 }
+3
src/screens/Messages/components/MessagesList.tsx
··· 16 16 RichText, 17 17 } from '@atproto/api' 18 18 19 + import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' 19 20 import {ScrollProvider} from '#/lib/ScrollContext' 20 21 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 21 22 import { ··· 105 106 const agent = useAgent() 106 107 const getPost = useGetPost() 107 108 const {embedUri, setEmbed} = useMessageEmbed() 109 + 110 + useHideBottomBarBorderForScreen() 108 111 109 112 const flatListRef = useAnimatedRef<ListMethods>() 110 113
+106
src/screens/PostThread/components/HeaderDropdown.tsx
··· 1 + import {msg, Trans} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + 4 + import {HITSLOP_10} from '#/lib/constants' 5 + import {logger} from '#/logger' 6 + import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 7 + import {Button, ButtonIcon} from '#/components/Button' 8 + import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 9 + import * as Menu from '#/components/Menu' 10 + 11 + export function HeaderDropdown({ 12 + sort, 13 + view, 14 + setSort, 15 + setView, 16 + }: Pick< 17 + ThreadPreferences, 18 + 'sort' | 'setSort' | 'view' | 'setView' 19 + >): React.ReactNode { 20 + const {_} = useLingui() 21 + return ( 22 + <Menu.Root> 23 + <Menu.Trigger label={_(msg`Thread options`)}> 24 + {({props: {onPress, ...props}}) => ( 25 + <Button 26 + label={_(msg`Thread options`)} 27 + size="small" 28 + variant="ghost" 29 + color="secondary" 30 + shape="round" 31 + hitSlop={HITSLOP_10} 32 + onPress={() => { 33 + logger.metric('thread:click:headerMenuOpen', {}) 34 + onPress() 35 + }} 36 + {...props}> 37 + <ButtonIcon icon={SettingsSlider} size="md" /> 38 + </Button> 39 + )} 40 + </Menu.Trigger> 41 + <Menu.Outer> 42 + <Menu.LabelText> 43 + <Trans>Show replies as</Trans> 44 + </Menu.LabelText> 45 + <Menu.Group> 46 + <Menu.Item 47 + label={_(msg`Linear`)} 48 + onPress={() => { 49 + setView('linear') 50 + }}> 51 + <Menu.ItemText> 52 + <Trans>Linear</Trans> 53 + </Menu.ItemText> 54 + <Menu.ItemRadio selected={view === 'linear'} /> 55 + </Menu.Item> 56 + <Menu.Item 57 + label={_(msg`Threaded`)} 58 + onPress={() => { 59 + setView('tree') 60 + }}> 61 + <Menu.ItemText> 62 + <Trans>Threaded</Trans> 63 + </Menu.ItemText> 64 + <Menu.ItemRadio selected={view === 'tree'} /> 65 + </Menu.Item> 66 + </Menu.Group> 67 + <Menu.Divider /> 68 + <Menu.LabelText> 69 + <Trans>Reply sorting</Trans> 70 + </Menu.LabelText> 71 + <Menu.Group> 72 + <Menu.Item 73 + label={_(msg`Top replies first`)} 74 + onPress={() => { 75 + setSort('top') 76 + }}> 77 + <Menu.ItemText> 78 + <Trans>Top replies first</Trans> 79 + </Menu.ItemText> 80 + <Menu.ItemRadio selected={sort === 'top'} /> 81 + </Menu.Item> 82 + <Menu.Item 83 + label={_(msg`Oldest replies first`)} 84 + onPress={() => { 85 + setSort('oldest') 86 + }}> 87 + <Menu.ItemText> 88 + <Trans>Oldest replies first</Trans> 89 + </Menu.ItemText> 90 + <Menu.ItemRadio selected={sort === 'oldest'} /> 91 + </Menu.Item> 92 + <Menu.Item 93 + label={_(msg`Newest replies first`)} 94 + onPress={() => { 95 + setSort('newest') 96 + }}> 97 + <Menu.ItemText> 98 + <Trans>Newest replies first</Trans> 99 + </Menu.ItemText> 100 + <Menu.ItemRadio selected={sort === 'newest'} /> 101 + </Menu.Item> 102 + </Menu.Group> 103 + </Menu.Outer> 104 + </Menu.Root> 105 + ) 106 + }
+89
src/screens/PostThread/components/ThreadError.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {useCleanError} from '#/lib/hooks/useCleanError' 7 + import {OUTER_SPACE} from '#/screens/PostThread/const' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 11 + import * as Layout from '#/components/Layout' 12 + import {Text} from '#/components/Typography' 13 + 14 + export function ThreadError({ 15 + error, 16 + onRetry, 17 + }: { 18 + error: Error 19 + onRetry: () => void 20 + }) { 21 + const t = useTheme() 22 + const {_} = useLingui() 23 + const cleanError = useCleanError() 24 + 25 + const {title, message} = useMemo(() => { 26 + let title = _(msg`Error loading post`) 27 + let message = _(msg`Something went wrong. Please try again in a moment.`) 28 + 29 + const {raw, clean} = cleanError(error) 30 + 31 + if (error.message.startsWith('Post not found')) { 32 + title = _(msg`Post not found`) 33 + message = clean || raw || message 34 + } 35 + 36 + return {title, message} 37 + }, [_, error, cleanError]) 38 + 39 + return ( 40 + <Layout.Center> 41 + <View 42 + style={[ 43 + a.w_full, 44 + a.align_center, 45 + { 46 + padding: OUTER_SPACE, 47 + paddingTop: OUTER_SPACE * 2, 48 + }, 49 + ]}> 50 + <View 51 + style={[ 52 + a.w_full, 53 + a.align_center, 54 + a.gap_xl, 55 + { 56 + maxWidth: 260, 57 + }, 58 + ]}> 59 + <View style={[a.gap_xs]}> 60 + <Text 61 + style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}> 62 + {title} 63 + </Text> 64 + <Text 65 + style={[ 66 + a.text_center, 67 + a.text_sm, 68 + a.leading_snug, 69 + t.atoms.text_contrast_medium, 70 + ]}> 71 + {message} 72 + </Text> 73 + </View> 74 + <Button 75 + label={_(msg`Retry`)} 76 + size="small" 77 + variant="solid" 78 + color="secondary_inverted" 79 + onPress={onRetry}> 80 + <ButtonText> 81 + <Trans>Retry</Trans> 82 + </ButtonText> 83 + <ButtonIcon icon={RetryIcon} position="right" /> 84 + </Button> 85 + </View> 86 + </View> 87 + </Layout.Center> 88 + ) 89 + }
+706
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 1 + import {memo, useCallback, useMemo} from 'react' 2 + import {type GestureResponderEvent, Text as RNText, View} from 'react-native' 3 + import { 4 + AppBskyFeedDefs, 5 + AppBskyFeedPost, 6 + type AppBskyFeedThreadgate, 7 + AtUri, 8 + RichText as RichTextAPI, 9 + } from '@atproto/api' 10 + import {msg, Plural, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {useActorStatus} from '#/lib/actor-status' 14 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 + import {useOpenLink} from '#/lib/hooks/useOpenLink' 16 + import {makeProfileLink} from '#/lib/routes/links' 17 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 18 + import {sanitizeHandle} from '#/lib/strings/handles' 19 + import {niceDate} from '#/lib/strings/time' 20 + import {s} from '#/lib/styles' 21 + import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' 22 + import {logger} from '#/logger' 23 + import { 24 + POST_TOMBSTONE, 25 + type Shadow, 26 + usePostShadow, 27 + } from '#/state/cache/post-shadow' 28 + import {useProfileShadow} from '#/state/cache/profile-shadow' 29 + import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 30 + import {useLanguagePrefs} from '#/state/preferences' 31 + import {type ThreadItem} from '#/state/queries/usePostThread/types' 32 + import {useSession} from '#/state/session' 33 + import {type OnPostSuccessData} from '#/state/shell/composer' 34 + import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 35 + import {type PostSource} from '#/state/unstable-post-source' 36 + import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' 37 + import {Link} from '#/view/com/util/Link' 38 + import {formatCount} from '#/view/com/util/numeric/format' 39 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 40 + import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 41 + import { 42 + LINEAR_AVI_WIDTH, 43 + OUTER_SPACE, 44 + REPLY_LINE_WIDTH, 45 + } from '#/screens/PostThread/const' 46 + import {atoms as a, useTheme} from '#/alf' 47 + import {colors} from '#/components/Admonition' 48 + import {Button} from '#/components/Button' 49 + import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 50 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 51 + import {InlineLinkText} from '#/components/Link' 52 + import {ContentHider} from '#/components/moderation/ContentHider' 53 + import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 54 + import {PostAlerts} from '#/components/moderation/PostAlerts' 55 + import {type AppModerationCause} from '#/components/Pills' 56 + import {PostControls} from '#/components/PostControls' 57 + import * as Prompt from '#/components/Prompt' 58 + import {RichText} from '#/components/RichText' 59 + import * as Skele from '#/components/Skeleton' 60 + import {Text} from '#/components/Typography' 61 + import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' 62 + import {WhoCanReply} from '#/components/WhoCanReply' 63 + import * as bsky from '#/types/bsky' 64 + 65 + export function ThreadItemAnchor({ 66 + item, 67 + onPostSuccess, 68 + threadgateRecord, 69 + postSource, 70 + }: { 71 + item: Extract<ThreadItem, {type: 'threadPost'}> 72 + onPostSuccess?: (data: OnPostSuccessData) => void 73 + threadgateRecord?: AppBskyFeedThreadgate.Record 74 + postSource?: PostSource 75 + }) { 76 + const postShadow = usePostShadow(item.value.post) 77 + const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri 78 + const isRoot = threadRootUri === item.uri 79 + 80 + if (postShadow === POST_TOMBSTONE) { 81 + return <ThreadItemAnchorDeleted isRoot={isRoot} /> 82 + } 83 + 84 + return ( 85 + <ThreadItemAnchorInner 86 + // Safeguard from clobbering per-post state below: 87 + key={postShadow.uri} 88 + item={item} 89 + isRoot={isRoot} 90 + postShadow={postShadow} 91 + onPostSuccess={onPostSuccess} 92 + threadgateRecord={threadgateRecord} 93 + postSource={postSource} 94 + /> 95 + ) 96 + } 97 + 98 + function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { 99 + const t = useTheme() 100 + 101 + return ( 102 + <> 103 + <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 104 + 105 + <View 106 + style={[ 107 + { 108 + paddingHorizontal: OUTER_SPACE, 109 + paddingBottom: OUTER_SPACE, 110 + }, 111 + isRoot && [a.pt_lg], 112 + ]}> 113 + <View 114 + style={[ 115 + a.flex_row, 116 + a.align_center, 117 + a.py_md, 118 + a.rounded_sm, 119 + t.atoms.bg_contrast_25, 120 + ]}> 121 + <View 122 + style={[ 123 + a.flex_row, 124 + a.align_center, 125 + a.justify_center, 126 + { 127 + width: LINEAR_AVI_WIDTH, 128 + }, 129 + ]}> 130 + <TrashIcon style={[t.atoms.text_contrast_medium]} /> 131 + </View> 132 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 133 + <Trans>Post has been deleted</Trans> 134 + </Text> 135 + </View> 136 + </View> 137 + </> 138 + ) 139 + } 140 + 141 + function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { 142 + const t = useTheme() 143 + 144 + return !isRoot ? ( 145 + <View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}> 146 + <View style={{width: 42}}> 147 + <View 148 + style={[ 149 + { 150 + width: REPLY_LINE_WIDTH, 151 + marginLeft: 'auto', 152 + marginRight: 'auto', 153 + flexGrow: 1, 154 + backgroundColor: t.atoms.border_contrast_low.borderColor, 155 + }, 156 + ]} 157 + /> 158 + </View> 159 + </View> 160 + ) : null 161 + } 162 + 163 + const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ 164 + item, 165 + isRoot, 166 + postShadow, 167 + onPostSuccess, 168 + threadgateRecord, 169 + postSource, 170 + }: { 171 + item: Extract<ThreadItem, {type: 'threadPost'}> 172 + isRoot: boolean 173 + postShadow: Shadow<AppBskyFeedDefs.PostView> 174 + onPostSuccess?: (data: OnPostSuccessData) => void 175 + threadgateRecord?: AppBskyFeedThreadgate.Record 176 + postSource?: PostSource 177 + }) { 178 + const t = useTheme() 179 + const {_, i18n} = useLingui() 180 + const {openComposer} = useOpenComposer() 181 + const {currentAccount, hasSession} = useSession() 182 + const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) 183 + 184 + const post = item.value.post 185 + const record = item.value.post.record 186 + const moderation = item.moderation 187 + const authorShadow = useProfileShadow(post.author) 188 + const {isActive: live} = useActorStatus(post.author) 189 + const richText = useMemo( 190 + () => 191 + new RichTextAPI({ 192 + text: record.text, 193 + facets: record.facets, 194 + }), 195 + [record], 196 + ) 197 + 198 + const threadRootUri = record.reply?.root?.uri || post.uri 199 + const authorHref = makeProfileLink(post.author) 200 + const authorTitle = post.author.handle 201 + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did 202 + 203 + const likesHref = useMemo(() => { 204 + const urip = new AtUri(post.uri) 205 + return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') 206 + }, [post.uri, post.author]) 207 + const repostsHref = useMemo(() => { 208 + const urip = new AtUri(post.uri) 209 + return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 210 + }, [post.uri, post.author]) 211 + const quotesHref = useMemo(() => { 212 + const urip = new AtUri(post.uri) 213 + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 214 + }, [post.uri, post.author]) 215 + 216 + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 217 + threadgateRecord, 218 + }) 219 + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 220 + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 221 + const isControlledByViewer = 222 + new AtUri(threadRootUri).host === currentAccount?.did 223 + return isControlledByViewer && isPostHiddenByThreadgate 224 + ? [ 225 + { 226 + type: 'reply-hidden', 227 + source: {type: 'user', did: currentAccount?.did}, 228 + priority: 6, 229 + }, 230 + ] 231 + : [] 232 + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 233 + const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( 234 + rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', 235 + ) 236 + const showFollowButton = 237 + currentAccount?.did !== post.author.did && !onlyFollowersCanReply 238 + 239 + const viaRepost = useMemo(() => { 240 + const reason = postSource?.post.reason 241 + 242 + if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { 243 + return { 244 + uri: reason.uri, 245 + cid: reason.cid, 246 + } 247 + } 248 + }, [postSource]) 249 + 250 + const onPressReply = useCallback(() => { 251 + openComposer({ 252 + replyTo: { 253 + uri: post.uri, 254 + cid: post.cid, 255 + text: record.text, 256 + author: post.author, 257 + embed: post.embed, 258 + moderation, 259 + }, 260 + onPostSuccess: onPostSuccess, 261 + }) 262 + 263 + if (postSource) { 264 + feedFeedback.sendInteraction({ 265 + item: post.uri, 266 + event: 'app.bsky.feed.defs#interactionReply', 267 + feedContext: postSource.post.feedContext, 268 + reqId: postSource.post.reqId, 269 + }) 270 + } 271 + }, [ 272 + openComposer, 273 + post, 274 + record, 275 + onPostSuccess, 276 + moderation, 277 + postSource, 278 + feedFeedback, 279 + ]) 280 + 281 + const onOpenAuthor = () => { 282 + if (postSource) { 283 + feedFeedback.sendInteraction({ 284 + item: post.uri, 285 + event: 'app.bsky.feed.defs#clickthroughAuthor', 286 + feedContext: postSource.post.feedContext, 287 + reqId: postSource.post.reqId, 288 + }) 289 + } 290 + } 291 + 292 + const onOpenEmbed = () => { 293 + if (postSource) { 294 + feedFeedback.sendInteraction({ 295 + item: post.uri, 296 + event: 'app.bsky.feed.defs#clickthroughEmbed', 297 + feedContext: postSource.post.feedContext, 298 + reqId: postSource.post.reqId, 299 + }) 300 + } 301 + } 302 + 303 + return ( 304 + <> 305 + <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 306 + 307 + <View 308 + testID={`postThreadItem-by-${post.author.handle}`} 309 + style={[ 310 + { 311 + paddingHorizontal: OUTER_SPACE, 312 + }, 313 + isRoot && [a.pt_lg], 314 + ]}> 315 + <View style={[a.flex_row, a.gap_md, a.pb_md]}> 316 + <PreviewableUserAvatar 317 + size={42} 318 + profile={post.author} 319 + moderation={moderation.ui('avatar')} 320 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 321 + live={live} 322 + onBeforePress={onOpenAuthor} 323 + /> 324 + <View style={[a.flex_1]}> 325 + <View style={[a.flex_row, a.align_center]}> 326 + <Link 327 + style={[a.flex_shrink]} 328 + href={authorHref} 329 + title={authorTitle} 330 + onBeforePress={onOpenAuthor}> 331 + <Text 332 + emoji 333 + style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]} 334 + numberOfLines={1}> 335 + {sanitizeDisplayName( 336 + post.author.displayName || 337 + sanitizeHandle(post.author.handle), 338 + moderation.ui('displayName'), 339 + )} 340 + </Text> 341 + </Link> 342 + 343 + <View style={[{paddingLeft: 3, top: -1}]}> 344 + <VerificationCheckButton profile={authorShadow} size="md" /> 345 + </View> 346 + </View> 347 + <Link style={s.flex1} href={authorHref} title={authorTitle}> 348 + <Text 349 + emoji 350 + style={[ 351 + a.text_md, 352 + a.leading_snug, 353 + t.atoms.text_contrast_medium, 354 + ]} 355 + numberOfLines={1}> 356 + {sanitizeHandle(post.author.handle, '@')} 357 + </Text> 358 + </Link> 359 + </View> 360 + {showFollowButton && ( 361 + <View> 362 + <PostThreadFollowBtn did={post.author.did} /> 363 + </View> 364 + )} 365 + </View> 366 + <View style={[a.pb_sm]}> 367 + <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 368 + <ContentHider 369 + modui={moderation.ui('contentView')} 370 + ignoreMute 371 + childContainerStyle={[a.pt_sm]}> 372 + <PostAlerts 373 + modui={moderation.ui('contentView')} 374 + size="lg" 375 + includeMute 376 + style={[a.pb_sm]} 377 + additionalCauses={additionalPostAlerts} 378 + /> 379 + {richText?.text ? ( 380 + <RichText 381 + enableTags 382 + selectable 383 + value={richText} 384 + style={[a.flex_1, a.text_xl]} 385 + authorHandle={post.author.handle} 386 + shouldProxyLinks={true} 387 + /> 388 + ) : undefined} 389 + {post.embed && ( 390 + <View style={[a.py_xs]}> 391 + <PostEmbeds 392 + embed={post.embed} 393 + moderation={moderation} 394 + viewContext={PostEmbedViewContext.ThreadHighlighted} 395 + onOpen={onOpenEmbed} 396 + /> 397 + </View> 398 + )} 399 + </ContentHider> 400 + <ExpandedPostDetails 401 + post={item.value.post} 402 + isThreadAuthor={isThreadAuthor} 403 + /> 404 + {post.repostCount !== 0 || 405 + post.likeCount !== 0 || 406 + post.quoteCount !== 0 ? ( 407 + // Show this section unless we're *sure* it has no engagement. 408 + <View 409 + style={[ 410 + a.flex_row, 411 + a.align_center, 412 + a.gap_lg, 413 + a.border_t, 414 + a.border_b, 415 + a.mt_md, 416 + a.py_md, 417 + t.atoms.border_contrast_low, 418 + ]}> 419 + {post.repostCount != null && post.repostCount !== 0 ? ( 420 + <Link href={repostsHref} title={_(msg`Reposts of this post`)}> 421 + <Text 422 + testID="repostCount-expanded" 423 + style={[a.text_md, t.atoms.text_contrast_medium]}> 424 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 425 + {formatCount(i18n, post.repostCount)} 426 + </Text>{' '} 427 + <Plural 428 + value={post.repostCount} 429 + one="repost" 430 + other="reposts" 431 + /> 432 + </Text> 433 + </Link> 434 + ) : null} 435 + {post.quoteCount != null && 436 + post.quoteCount !== 0 && 437 + !post.viewer?.embeddingDisabled ? ( 438 + <Link href={quotesHref} title={_(msg`Quotes of this post`)}> 439 + <Text 440 + testID="quoteCount-expanded" 441 + style={[a.text_md, t.atoms.text_contrast_medium]}> 442 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 443 + {formatCount(i18n, post.quoteCount)} 444 + </Text>{' '} 445 + <Plural 446 + value={post.quoteCount} 447 + one="quote" 448 + other="quotes" 449 + /> 450 + </Text> 451 + </Link> 452 + ) : null} 453 + {post.likeCount != null && post.likeCount !== 0 ? ( 454 + <Link href={likesHref} title={_(msg`Likes on this post`)}> 455 + <Text 456 + testID="likeCount-expanded" 457 + style={[a.text_md, t.atoms.text_contrast_medium]}> 458 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 459 + {formatCount(i18n, post.likeCount)} 460 + </Text>{' '} 461 + <Plural value={post.likeCount} one="like" other="likes" /> 462 + </Text> 463 + </Link> 464 + ) : null} 465 + </View> 466 + ) : null} 467 + <View 468 + style={[ 469 + a.pt_sm, 470 + a.pb_2xs, 471 + { 472 + marginLeft: -5, 473 + }, 474 + ]}> 475 + <FeedFeedbackProvider value={feedFeedback}> 476 + <PostControls 477 + big 478 + post={postShadow} 479 + record={record} 480 + richText={richText} 481 + onPressReply={onPressReply} 482 + logContext="PostThreadItem" 483 + threadgateRecord={threadgateRecord} 484 + feedContext={postSource?.post?.feedContext} 485 + reqId={postSource?.post?.reqId} 486 + viaRepost={viaRepost} 487 + /> 488 + </FeedFeedbackProvider> 489 + </View> 490 + </View> 491 + </View> 492 + </> 493 + ) 494 + }) 495 + 496 + function ExpandedPostDetails({ 497 + post, 498 + isThreadAuthor, 499 + }: { 500 + post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] 501 + isThreadAuthor: boolean 502 + }) { 503 + const t = useTheme() 504 + const {_, i18n} = useLingui() 505 + const openLink = useOpenLink() 506 + const langPrefs = useLanguagePrefs() 507 + 508 + const translatorUrl = getTranslatorLink( 509 + post.record?.text || '', 510 + langPrefs.primaryLanguage, 511 + ) 512 + const needsTranslation = useMemo( 513 + () => 514 + Boolean( 515 + langPrefs.primaryLanguage && 516 + !isPostInLanguage(post, [langPrefs.primaryLanguage]), 517 + ), 518 + [post, langPrefs.primaryLanguage], 519 + ) 520 + 521 + const onTranslatePress = useCallback( 522 + (e: GestureResponderEvent) => { 523 + e.preventDefault() 524 + openLink(translatorUrl, true) 525 + 526 + if ( 527 + bsky.dangerousIsType<AppBskyFeedPost.Record>( 528 + post.record, 529 + AppBskyFeedPost.isRecord, 530 + ) 531 + ) { 532 + logger.metric('translate', { 533 + sourceLanguages: post.record.langs ?? [], 534 + targetLanguage: langPrefs.primaryLanguage, 535 + textLength: post.record.text.length, 536 + }) 537 + } 538 + 539 + return false 540 + }, 541 + [openLink, translatorUrl, langPrefs, post], 542 + ) 543 + 544 + return ( 545 + <View style={[a.gap_md, a.pt_md, a.align_start]}> 546 + <BackdatedPostIndicator post={post} /> 547 + <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 548 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 549 + {niceDate(i18n, post.indexedAt)} 550 + </Text> 551 + <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> 552 + {needsTranslation && ( 553 + <> 554 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 555 + &middot; 556 + </Text> 557 + 558 + <InlineLinkText 559 + to={translatorUrl} 560 + label={_(msg`Translate`)} 561 + style={[a.text_sm]} 562 + onPress={onTranslatePress}> 563 + <Trans>Translate</Trans> 564 + </InlineLinkText> 565 + </> 566 + )} 567 + </View> 568 + </View> 569 + ) 570 + } 571 + 572 + function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { 573 + const t = useTheme() 574 + const {_, i18n} = useLingui() 575 + const control = Prompt.usePromptControl() 576 + 577 + const indexedAt = new Date(post.indexedAt) 578 + const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>( 579 + post.record, 580 + AppBskyFeedPost.isRecord, 581 + ) 582 + ? new Date(post.record.createdAt) 583 + : new Date(post.indexedAt) 584 + 585 + // backdated if createdAt is 24 hours or more before indexedAt 586 + const isBackdated = 587 + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 588 + 589 + if (!isBackdated) return null 590 + 591 + const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light 592 + 593 + return ( 594 + <> 595 + <Button 596 + label={_(msg`Archived post`)} 597 + accessibilityHint={_( 598 + msg`Shows information about when this post was created`, 599 + )} 600 + onPress={e => { 601 + e.preventDefault() 602 + e.stopPropagation() 603 + control.open() 604 + }}> 605 + {({hovered, pressed}) => ( 606 + <View 607 + style={[ 608 + a.flex_row, 609 + a.align_center, 610 + a.rounded_full, 611 + t.atoms.bg_contrast_25, 612 + (hovered || pressed) && t.atoms.bg_contrast_50, 613 + { 614 + gap: 3, 615 + paddingHorizontal: 6, 616 + paddingVertical: 3, 617 + }, 618 + ]}> 619 + <CalendarClockIcon fill={orange} size="sm" aria-hidden /> 620 + <Text 621 + style={[ 622 + a.text_xs, 623 + a.font_bold, 624 + a.leading_tight, 625 + t.atoms.text_contrast_medium, 626 + ]}> 627 + <Trans>Archived from {niceDate(i18n, createdAt)}</Trans> 628 + </Text> 629 + </View> 630 + )} 631 + </Button> 632 + 633 + <Prompt.Outer control={control}> 634 + <Prompt.TitleText> 635 + <Trans>Archived post</Trans> 636 + </Prompt.TitleText> 637 + <Prompt.DescriptionText> 638 + <Trans> 639 + This post claims to have been created on{' '} 640 + <RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>, 641 + but was first seen by Bluesky on{' '} 642 + <RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>. 643 + </Trans> 644 + </Prompt.DescriptionText> 645 + <Text 646 + style={[ 647 + a.text_md, 648 + a.leading_snug, 649 + t.atoms.text_contrast_high, 650 + a.pb_xl, 651 + ]}> 652 + <Trans> 653 + Bluesky cannot confirm the authenticity of the claimed date. 654 + </Trans> 655 + </Text> 656 + <Prompt.Actions> 657 + <Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} /> 658 + </Prompt.Actions> 659 + </Prompt.Outer> 660 + </> 661 + ) 662 + } 663 + 664 + function getThreadAuthor( 665 + post: AppBskyFeedDefs.PostView, 666 + record: AppBskyFeedPost.Record, 667 + ): string { 668 + if (!record.reply) { 669 + return post.author.did 670 + } 671 + try { 672 + return new AtUri(record.reply.root.uri).host 673 + } catch { 674 + return '' 675 + } 676 + } 677 + 678 + export function ThreadItemAnchorSkeleton() { 679 + return ( 680 + <View style={[a.p_lg, a.gap_md]}> 681 + <Skele.Row style={[a.align_center, a.gap_md]}> 682 + <Skele.Circle size={42} /> 683 + 684 + <Skele.Col> 685 + <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 686 + <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 687 + </Skele.Col> 688 + </Skele.Row> 689 + 690 + <View> 691 + <Skele.Text style={[a.text_xl, {width: '100%'}]} /> 692 + <Skele.Text style={[a.text_xl, {width: '60%'}]} /> 693 + </View> 694 + 695 + <Skele.Text style={[a.text_sm, {width: '50%'}]} /> 696 + 697 + <Skele.Row style={[a.justify_between]}> 698 + <Skele.Pill blend size={24} /> 699 + <Skele.Pill blend size={24} /> 700 + <Skele.Pill blend size={24} /> 701 + <Skele.Circle blend size={24} /> 702 + <Skele.Circle blend size={24} /> 703 + </Skele.Row> 704 + </View> 705 + ) 706 + }
+32
src/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 6 + import * as Skele from '#/components/Skeleton' 7 + import {Text} from '#/components/Typography' 8 + 9 + export function ThreadItemAnchorNoUnauthenticated() { 10 + const t = useTheme() 11 + 12 + return ( 13 + <View style={[a.p_lg, a.gap_md]}> 14 + <Skele.Row style={[a.align_center, a.gap_md]}> 15 + <Skele.Circle size={42}> 16 + <LockIcon size="md" fill={t.atoms.text_contrast_medium.color} /> 17 + </Skele.Circle> 18 + 19 + <Skele.Col> 20 + <Skele.Text style={[a.text_lg, {width: '20%'}]} /> 21 + <Skele.Text blend style={[a.text_md, {width: '40%'}]} /> 22 + </Skele.Col> 23 + </Skele.Row> 24 + 25 + <View style={[a.py_sm]}> 26 + <Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}> 27 + <Trans>You must sign in to view this post.</Trans> 28 + </Text> 29 + </View> 30 + </View> 31 + ) 32 + }
+405
src/screens/PostThread/components/ThreadItemPost.tsx
··· 1 + import {memo, type ReactNode, useCallback, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type AppBskyFeedDefs, 5 + type AppBskyFeedThreadgate, 6 + AtUri, 7 + RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {useActorStatus} from '#/lib/actor-status' 13 + import {MAX_POST_LINES} from '#/lib/constants' 14 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 15 + import {usePalette} from '#/lib/hooks/usePalette' 16 + import {makeProfileLink} from '#/lib/routes/links' 17 + import {countLines} from '#/lib/strings/helpers' 18 + import { 19 + POST_TOMBSTONE, 20 + type Shadow, 21 + usePostShadow, 22 + } from '#/state/cache/post-shadow' 23 + import {type ThreadItem} from '#/state/queries/usePostThread/types' 24 + import {useSession} from '#/state/session' 25 + import {type OnPostSuccessData} from '#/state/shell/composer' 26 + import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 27 + import {TextLink} from '#/view/com/util/Link' 28 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 29 + import {PostMeta} from '#/view/com/util/PostMeta' 30 + import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 31 + import { 32 + LINEAR_AVI_WIDTH, 33 + OUTER_SPACE, 34 + REPLY_LINE_WIDTH, 35 + } from '#/screens/PostThread/const' 36 + import {atoms as a, useTheme} from '#/alf' 37 + import {useInteractionState} from '#/components/hooks/useInteractionState' 38 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 39 + import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 40 + import {PostAlerts} from '#/components/moderation/PostAlerts' 41 + import {PostHider} from '#/components/moderation/PostHider' 42 + import {type AppModerationCause} from '#/components/Pills' 43 + import {PostControls} from '#/components/PostControls' 44 + import {RichText} from '#/components/RichText' 45 + import * as Skele from '#/components/Skeleton' 46 + import {SubtleWebHover} from '#/components/SubtleWebHover' 47 + import {Text} from '#/components/Typography' 48 + 49 + export type ThreadItemPostProps = { 50 + item: Extract<ThreadItem, {type: 'threadPost'}> 51 + overrides?: { 52 + moderation?: boolean 53 + topBorder?: boolean 54 + } 55 + onPostSuccess?: (data: OnPostSuccessData) => void 56 + threadgateRecord?: AppBskyFeedThreadgate.Record 57 + } 58 + 59 + export function ThreadItemPost({ 60 + item, 61 + overrides, 62 + onPostSuccess, 63 + threadgateRecord, 64 + }: ThreadItemPostProps) { 65 + const postShadow = usePostShadow(item.value.post) 66 + 67 + if (postShadow === POST_TOMBSTONE) { 68 + return <ThreadItemPostDeleted item={item} overrides={overrides} /> 69 + } 70 + 71 + return ( 72 + <ThreadItemPostInner 73 + item={item} 74 + postShadow={postShadow} 75 + threadgateRecord={threadgateRecord} 76 + overrides={overrides} 77 + onPostSuccess={onPostSuccess} 78 + /> 79 + ) 80 + } 81 + 82 + function ThreadItemPostDeleted({ 83 + item, 84 + overrides, 85 + }: Pick<ThreadItemPostProps, 'item' | 'overrides'>) { 86 + const t = useTheme() 87 + 88 + return ( 89 + <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 90 + <ThreadItemPostParentReplyLine item={item} /> 91 + 92 + <View 93 + style={[ 94 + a.flex_row, 95 + a.align_center, 96 + a.py_md, 97 + a.rounded_sm, 98 + t.atoms.bg_contrast_25, 99 + ]}> 100 + <View 101 + style={[ 102 + a.flex_row, 103 + a.align_center, 104 + a.justify_center, 105 + { 106 + width: LINEAR_AVI_WIDTH, 107 + }, 108 + ]}> 109 + <TrashIcon style={[t.atoms.text_contrast_medium]} /> 110 + </View> 111 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 112 + <Trans>Post has been deleted</Trans> 113 + </Text> 114 + </View> 115 + 116 + <View style={[{height: 4}]} /> 117 + </ThreadItemPostOuterWrapper> 118 + ) 119 + } 120 + 121 + const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ 122 + item, 123 + overrides, 124 + children, 125 + }: Pick<ThreadItemPostProps, 'item' | 'overrides'> & { 126 + children: ReactNode 127 + }) { 128 + const t = useTheme() 129 + const showTopBorder = 130 + !item.ui.showParentReplyLine && overrides?.topBorder !== true 131 + 132 + return ( 133 + <View 134 + style={[ 135 + showTopBorder && [a.border_t, t.atoms.border_contrast_low], 136 + { 137 + paddingHorizontal: OUTER_SPACE, 138 + }, 139 + // If there's no next child, add a little padding to bottom 140 + !item.ui.showChildReplyLine && 141 + !item.ui.precedesChildReadMore && { 142 + paddingBottom: OUTER_SPACE / 2, 143 + }, 144 + ]}> 145 + {children} 146 + </View> 147 + ) 148 + }) 149 + 150 + /** 151 + * Provides some space between posts as well as contains the reply line 152 + */ 153 + const ThreadItemPostParentReplyLine = memo( 154 + function ThreadItemPostParentReplyLine({ 155 + item, 156 + }: Pick<ThreadItemPostProps, 'item'>) { 157 + const t = useTheme() 158 + return ( 159 + <View style={[a.flex_row, {height: 12}]}> 160 + <View style={{width: LINEAR_AVI_WIDTH}}> 161 + {item.ui.showParentReplyLine && ( 162 + <View 163 + style={[ 164 + a.mx_auto, 165 + a.flex_1, 166 + a.mb_xs, 167 + { 168 + width: REPLY_LINE_WIDTH, 169 + backgroundColor: t.atoms.border_contrast_low.borderColor, 170 + }, 171 + ]} 172 + /> 173 + )} 174 + </View> 175 + </View> 176 + ) 177 + }, 178 + ) 179 + 180 + const ThreadItemPostInner = memo(function ThreadItemPostInner({ 181 + item, 182 + postShadow, 183 + overrides, 184 + onPostSuccess, 185 + threadgateRecord, 186 + }: ThreadItemPostProps & { 187 + postShadow: Shadow<AppBskyFeedDefs.PostView> 188 + }) { 189 + const t = useTheme() 190 + const pal = usePalette('default') 191 + const {_} = useLingui() 192 + const {openComposer} = useOpenComposer() 193 + const {currentAccount} = useSession() 194 + 195 + const post = item.value.post 196 + const record = item.value.post.record 197 + const moderation = item.moderation 198 + const richText = useMemo( 199 + () => 200 + new RichTextAPI({ 201 + text: record.text, 202 + facets: record.facets, 203 + }), 204 + [record], 205 + ) 206 + const [limitLines, setLimitLines] = useState( 207 + () => countLines(richText?.text) >= MAX_POST_LINES, 208 + ) 209 + const threadRootUri = record.reply?.root?.uri || post.uri 210 + const postHref = useMemo(() => { 211 + const urip = new AtUri(post.uri) 212 + return makeProfileLink(post.author, 'post', urip.rkey) 213 + }, [post.uri, post.author]) 214 + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 215 + threadgateRecord, 216 + }) 217 + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 218 + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 219 + const isControlledByViewer = 220 + new AtUri(threadRootUri).host === currentAccount?.did 221 + return isControlledByViewer && isPostHiddenByThreadgate 222 + ? [ 223 + { 224 + type: 'reply-hidden', 225 + source: {type: 'user', did: currentAccount?.did}, 226 + priority: 6, 227 + }, 228 + ] 229 + : [] 230 + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 231 + 232 + const onPressReply = useCallback(() => { 233 + openComposer({ 234 + replyTo: { 235 + uri: post.uri, 236 + cid: post.cid, 237 + text: record.text, 238 + author: post.author, 239 + embed: post.embed, 240 + moderation, 241 + }, 242 + onPostSuccess: onPostSuccess, 243 + }) 244 + }, [openComposer, post, record, onPostSuccess, moderation]) 245 + 246 + const onPressShowMore = useCallback(() => { 247 + setLimitLines(false) 248 + }, [setLimitLines]) 249 + 250 + const {isActive: live} = useActorStatus(post.author) 251 + 252 + return ( 253 + <SubtleHover> 254 + <ThreadItemPostOuterWrapper item={item} overrides={overrides}> 255 + <PostHider 256 + testID={`postThreadItem-by-${post.author.handle}`} 257 + href={postHref} 258 + disabled={overrides?.moderation === true} 259 + modui={moderation.ui('contentList')} 260 + iconSize={LINEAR_AVI_WIDTH} 261 + iconStyles={{marginLeft: 2, marginRight: 2}} 262 + profile={post.author} 263 + interpretFilterAsBlur> 264 + <ThreadItemPostParentReplyLine item={item} /> 265 + 266 + <View style={[a.flex_row, a.gap_md]}> 267 + <View> 268 + <PreviewableUserAvatar 269 + size={LINEAR_AVI_WIDTH} 270 + profile={post.author} 271 + moderation={moderation.ui('avatar')} 272 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 273 + live={live} 274 + /> 275 + 276 + {(item.ui.showChildReplyLine || 277 + item.ui.precedesChildReadMore) && ( 278 + <View 279 + style={[ 280 + a.mx_auto, 281 + a.mt_xs, 282 + a.flex_1, 283 + { 284 + width: REPLY_LINE_WIDTH, 285 + backgroundColor: t.atoms.border_contrast_low.borderColor, 286 + }, 287 + ]} 288 + /> 289 + )} 290 + </View> 291 + 292 + <View style={[a.flex_1]}> 293 + <PostMeta 294 + author={post.author} 295 + moderation={moderation} 296 + timestamp={post.indexedAt} 297 + postHref={postHref} 298 + style={[a.pb_xs]} 299 + /> 300 + <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 301 + <PostAlerts 302 + modui={moderation.ui('contentList')} 303 + style={[a.pb_2xs]} 304 + additionalCauses={additionalPostAlerts} 305 + /> 306 + {richText?.text ? ( 307 + <RichText 308 + enableTags 309 + value={richText} 310 + style={[a.flex_1, a.text_md]} 311 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 312 + authorHandle={post.author.handle} 313 + shouldProxyLinks={true} 314 + /> 315 + ) : undefined} 316 + {limitLines ? ( 317 + <TextLink 318 + text={_(msg`Show More`)} 319 + style={pal.link} 320 + onPress={onPressShowMore} 321 + href="#" 322 + /> 323 + ) : undefined} 324 + {post.embed && ( 325 + <View style={[a.pb_xs]}> 326 + <PostEmbeds 327 + embed={post.embed} 328 + moderation={moderation} 329 + viewContext={PostEmbedViewContext.Feed} 330 + /> 331 + </View> 332 + )} 333 + <PostControls 334 + post={postShadow} 335 + record={record} 336 + richText={richText} 337 + onPressReply={onPressReply} 338 + logContext="PostThreadItem" 339 + threadgateRecord={threadgateRecord} 340 + /> 341 + </View> 342 + </View> 343 + </PostHider> 344 + </ThreadItemPostOuterWrapper> 345 + </SubtleHover> 346 + ) 347 + }) 348 + 349 + function SubtleHover({children}: {children: ReactNode}) { 350 + const { 351 + state: hover, 352 + onIn: onHoverIn, 353 + onOut: onHoverOut, 354 + } = useInteractionState() 355 + return ( 356 + <View 357 + onPointerEnter={onHoverIn} 358 + onPointerLeave={onHoverOut} 359 + style={a.pointer}> 360 + <SubtleWebHover hover={hover} /> 361 + {children} 362 + </View> 363 + ) 364 + } 365 + 366 + export function ThreadItemPostSkeleton({index}: {index: number}) { 367 + const even = index % 2 === 0 368 + return ( 369 + <View 370 + style={[ 371 + {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 372 + a.gap_md, 373 + ]}> 374 + <Skele.Row style={[a.align_start, a.gap_md]}> 375 + <Skele.Circle size={LINEAR_AVI_WIDTH} /> 376 + 377 + <Skele.Col style={[a.gap_xs]}> 378 + <Skele.Row style={[a.gap_sm]}> 379 + <Skele.Text style={[a.text_md, {width: '20%'}]} /> 380 + <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 381 + </Skele.Row> 382 + 383 + <Skele.Col> 384 + {even ? ( 385 + <> 386 + <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 387 + <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 388 + </> 389 + ) : ( 390 + <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 391 + )} 392 + </Skele.Col> 393 + 394 + <Skele.Row style={[a.justify_between, a.pt_xs]}> 395 + <Skele.Pill blend size={16} /> 396 + <Skele.Pill blend size={16} /> 397 + <Skele.Pill blend size={16} /> 398 + <Skele.Circle blend size={16} /> 399 + <View /> 400 + </Skele.Row> 401 + </Skele.Col> 402 + </Skele.Row> 403 + </View> 404 + ) 405 + }
+74
src/screens/PostThread/components/ThreadItemPostNoUnauthenticated.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {type ThreadItem} from '#/state/queries/usePostThread/types' 5 + import { 6 + LINEAR_AVI_WIDTH, 7 + OUTER_SPACE, 8 + REPLY_LINE_WIDTH, 9 + } from '#/screens/PostThread/const' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 12 + import * as Skele from '#/components/Skeleton' 13 + import {Text} from '#/components/Typography' 14 + 15 + export function ThreadItemPostNoUnauthenticated({ 16 + item, 17 + }: { 18 + item: Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> 19 + }) { 20 + const t = useTheme() 21 + 22 + return ( 23 + <View style={[{paddingHorizontal: OUTER_SPACE}]}> 24 + <View style={[a.flex_row, {height: 12}]}> 25 + <View style={{width: LINEAR_AVI_WIDTH}}> 26 + {item.ui.showParentReplyLine && ( 27 + <View 28 + style={[ 29 + a.mx_auto, 30 + a.flex_1, 31 + a.mb_xs, 32 + { 33 + width: REPLY_LINE_WIDTH, 34 + backgroundColor: t.atoms.border_contrast_low.borderColor, 35 + }, 36 + ]} 37 + /> 38 + )} 39 + </View> 40 + </View> 41 + <Skele.Row style={[a.align_center, a.gap_md]}> 42 + <Skele.Circle size={LINEAR_AVI_WIDTH}> 43 + <LockIcon size="md" fill={t.atoms.text_contrast_medium.color} /> 44 + </Skele.Circle> 45 + 46 + <Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}> 47 + <Trans>You must sign in to view this post.</Trans> 48 + </Text> 49 + </Skele.Row> 50 + <View 51 + style={[ 52 + a.flex_row, 53 + a.justify_center, 54 + { 55 + height: OUTER_SPACE / 1.5, 56 + width: LINEAR_AVI_WIDTH, 57 + }, 58 + ]}> 59 + {item.ui.showChildReplyLine && ( 60 + <View 61 + style={[ 62 + a.mt_xs, 63 + a.h_full, 64 + { 65 + width: REPLY_LINE_WIDTH, 66 + backgroundColor: t.atoms.border_contrast_low.borderColor, 67 + }, 68 + ]} 69 + /> 70 + )} 71 + </View> 72 + </View> 73 + ) 74 + }
+55
src/screens/PostThread/components/ThreadItemPostTombstone.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' 9 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 10 + import {Text} from '#/components/Typography' 11 + 12 + export type ThreadItemPostTombstoneProps = { 13 + type: 'not-found' | 'blocked' 14 + } 15 + 16 + export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) { 17 + const t = useTheme() 18 + const {_} = useLingui() 19 + const {copy, Icon} = useMemo(() => { 20 + switch (type) { 21 + case 'blocked': 22 + return {copy: _(msg`Post blocked`), Icon: PersonXIcon} 23 + case 'not-found': 24 + default: 25 + return {copy: _(msg`Post not found`), Icon: TrashIcon} 26 + } 27 + }, [_, type]) 28 + 29 + return ( 30 + <View 31 + style={[ 32 + a.mb_xs, 33 + { 34 + paddingHorizontal: OUTER_SPACE, 35 + paddingTop: OUTER_SPACE / 1.2, 36 + }, 37 + ]}> 38 + <View 39 + style={[ 40 + a.flex_row, 41 + a.align_center, 42 + a.rounded_sm, 43 + t.atoms.bg_contrast_25, 44 + {paddingVertical: OUTER_SPACE / 1.2}, 45 + ]}> 46 + <View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}> 47 + <Icon style={[t.atoms.text_contrast_medium]} /> 48 + </View> 49 + <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}> 50 + {copy} 51 + </Text> 52 + </View> 53 + </View> 54 + ) 55 + }
+107
src/screens/PostThread/components/ThreadItemReadMore.tsx
··· 1 + import {memo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Plural, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import { 7 + type PostThreadParams, 8 + type ThreadItem, 9 + } from '#/state/queries/usePostThread' 10 + import { 11 + LINEAR_AVI_WIDTH, 12 + REPLY_LINE_WIDTH, 13 + TREE_AVI_WIDTH, 14 + TREE_INDENT, 15 + } from '#/screens/PostThread/const' 16 + import {atoms as a, useTheme} from '#/alf' 17 + import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus' 18 + import {Link} from '#/components/Link' 19 + import {Text} from '#/components/Typography' 20 + 21 + export const ThreadItemReadMore = memo(function ThreadItemReadMore({ 22 + item, 23 + view, 24 + }: { 25 + item: Extract<ThreadItem, {type: 'readMore'}> 26 + view: PostThreadParams['view'] 27 + }) { 28 + const t = useTheme() 29 + const {_} = useLingui() 30 + const isTreeView = view === 'tree' 31 + const indent = Math.max(0, item.depth - 1) 32 + 33 + const spacers = isTreeView 34 + ? Array.from(Array(indent)).map((_, n: number) => { 35 + const isSkipped = item.skippedIndentIndices.has(n) 36 + return ( 37 + <View 38 + key={`${item.key}-padding-${n}`} 39 + style={[ 40 + t.atoms.border_contrast_low, 41 + { 42 + borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH, 43 + width: TREE_INDENT + TREE_AVI_WIDTH / 2, 44 + left: 1, 45 + }, 46 + ]} 47 + /> 48 + ) 49 + }) 50 + : null 51 + 52 + return ( 53 + <View style={[a.flex_row]}> 54 + {spacers} 55 + <View 56 + style={[ 57 + t.atoms.border_contrast_low, 58 + { 59 + marginLeft: isTreeView 60 + ? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1 61 + : (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16, 62 + borderLeftWidth: 2, 63 + borderBottomWidth: 2, 64 + borderBottomLeftRadius: a.rounded_sm.borderRadius, 65 + height: 18, // magic, Link below is 38px tall 66 + width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10, 67 + }, 68 + ]} 69 + /> 70 + <Link 71 + label={_(msg`Read more replies`)} 72 + to={item.href} 73 + style={[a.pt_sm, a.pb_md, a.gap_xs]}> 74 + {({hovered, pressed}) => { 75 + const interacted = hovered || pressed 76 + return ( 77 + <> 78 + <CirclePlus 79 + fill={ 80 + interacted 81 + ? t.atoms.text_contrast_high.color 82 + : t.atoms.text_contrast_low.color 83 + } 84 + width={18} 85 + /> 86 + <Text 87 + style={[ 88 + a.text_sm, 89 + t.atoms.text_contrast_medium, 90 + interacted && a.underline, 91 + ]}> 92 + <Trans> 93 + Read {item.moreReplies} more{' '} 94 + <Plural 95 + one="reply" 96 + other="replies" 97 + value={item.moreReplies} 98 + /> 99 + </Trans> 100 + </Text> 101 + </> 102 + ) 103 + }} 104 + </Link> 105 + </View> 106 + ) 107 + })
+89
src/screens/PostThread/components/ThreadItemReadMoreUp.tsx
··· 1 + import {memo} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {type ThreadItem} from '#/state/queries/usePostThread' 7 + import { 8 + LINEAR_AVI_WIDTH, 9 + OUTER_SPACE, 10 + REPLY_LINE_WIDTH, 11 + } from '#/screens/PostThread/const' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle' 14 + import {Link} from '#/components/Link' 15 + import {Text} from '#/components/Typography' 16 + 17 + export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({ 18 + item, 19 + }: { 20 + item: Extract<ThreadItem, {type: 'readMoreUp'}> 21 + }) { 22 + const t = useTheme() 23 + const {_} = useLingui() 24 + 25 + return ( 26 + <Link 27 + label={_(msg`Continue thread`)} 28 + to={item.href} 29 + style={[ 30 + a.gap_xs, 31 + { 32 + paddingTop: OUTER_SPACE, 33 + paddingHorizontal: OUTER_SPACE, 34 + }, 35 + ]}> 36 + {({hovered, pressed}) => { 37 + const interacted = hovered || pressed 38 + return ( 39 + <View> 40 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 41 + <View 42 + style={[ 43 + a.align_center, 44 + { 45 + width: LINEAR_AVI_WIDTH, 46 + }, 47 + ]}> 48 + <UpIcon 49 + fill={ 50 + interacted 51 + ? t.atoms.text_contrast_high.color 52 + : t.atoms.text_contrast_low.color 53 + } 54 + width={24} 55 + /> 56 + </View> 57 + <Text 58 + style={[ 59 + a.text_sm, 60 + t.atoms.text_contrast_medium, 61 + interacted && [a.underline], 62 + ]}> 63 + <Trans>Continue thread...</Trans> 64 + </Text> 65 + </View> 66 + <View 67 + style={[ 68 + a.align_center, 69 + { 70 + width: LINEAR_AVI_WIDTH, 71 + }, 72 + ]}> 73 + <View 74 + style={[ 75 + a.mt_xs, 76 + { 77 + height: OUTER_SPACE / 2, 78 + width: REPLY_LINE_WIDTH, 79 + backgroundColor: t.atoms.border_contrast_low.borderColor, 80 + }, 81 + ]} 82 + /> 83 + </View> 84 + </View> 85 + ) 86 + }} 87 + </Link> 88 + ) 89 + })
+31
src/screens/PostThread/components/ThreadItemReplyComposer.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {OUTER_SPACE} from '#/screens/PostThread/const' 4 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 5 + import * as Skele from '#/components/Skeleton' 6 + 7 + /* 8 + * Wacky padding here is just replicating what we have in the actual 9 + * `PostThreadComposePrompt` component 10 + */ 11 + export function ThreadItemReplyComposerSkeleton() { 12 + const t = useTheme() 13 + const {gtMobile} = useBreakpoints() 14 + 15 + return ( 16 + <View 17 + style={[ 18 + a.border_t, 19 + t.atoms.border_contrast_low, 20 + gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11}, 21 + { 22 + paddingHorizontal: OUTER_SPACE, 23 + }, 24 + ]}> 25 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}> 26 + <Skele.Circle size={gtMobile ? 24 : 22} /> 27 + <Skele.Text style={[a.text_md]} /> 28 + </View> 29 + </View> 30 + ) 31 + }
+59
src/screens/PostThread/components/ThreadItemShowOtherReplies.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {logger} from '#/logger' 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button} from '#/components/Button' 8 + import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) { 12 + const {_} = useLingui() 13 + const t = useTheme() 14 + const label = _(msg`Show more replies`) 15 + 16 + return ( 17 + <Button 18 + onPress={() => { 19 + onPress() 20 + logger.metric('thread:click:showOtherReplies', {}) 21 + }} 22 + label={label}> 23 + {({hovered, pressed}) => ( 24 + <View 25 + style={[ 26 + a.flex_1, 27 + a.flex_row, 28 + a.align_center, 29 + a.gap_sm, 30 + a.py_lg, 31 + a.px_xl, 32 + a.border_t, 33 + t.atoms.border_contrast_low, 34 + hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg, 35 + ]}> 36 + <View 37 + style={[ 38 + t.atoms.bg_contrast_25, 39 + a.align_center, 40 + a.justify_center, 41 + { 42 + width: 26, 43 + height: 26, 44 + borderRadius: 13, 45 + marginRight: 4, 46 + }, 47 + ]}> 48 + <EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} /> 49 + </View> 50 + <Text 51 + style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]} 52 + numberOfLines={1}> 53 + {label} 54 + </Text> 55 + </View> 56 + )} 57 + </Button> 58 + ) 59 + }
+456
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 1 + import React, {memo, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type AppBskyFeedDefs, 5 + type AppBskyFeedThreadgate, 6 + AtUri, 7 + RichText as RichTextAPI, 8 + } from '@atproto/api' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {MAX_POST_LINES} from '#/lib/constants' 13 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 14 + import {usePalette} from '#/lib/hooks/usePalette' 15 + import {makeProfileLink} from '#/lib/routes/links' 16 + import {countLines} from '#/lib/strings/helpers' 17 + import { 18 + POST_TOMBSTONE, 19 + type Shadow, 20 + usePostShadow, 21 + } from '#/state/cache/post-shadow' 22 + import {type ThreadItem} from '#/state/queries/usePostThread/types' 23 + import {useSession} from '#/state/session' 24 + import {type OnPostSuccessData} from '#/state/shell/composer' 25 + import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 26 + import {TextLink} from '#/view/com/util/Link' 27 + import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 28 + import {PostMeta} from '#/view/com/util/PostMeta' 29 + import { 30 + OUTER_SPACE, 31 + REPLY_LINE_WIDTH, 32 + TREE_AVI_WIDTH, 33 + TREE_INDENT, 34 + } from '#/screens/PostThread/const' 35 + import {atoms as a, useTheme} from '#/alf' 36 + import {useInteractionState} from '#/components/hooks/useInteractionState' 37 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 38 + import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 39 + import {PostAlerts} from '#/components/moderation/PostAlerts' 40 + import {PostHider} from '#/components/moderation/PostHider' 41 + import {type AppModerationCause} from '#/components/Pills' 42 + import {PostControls} from '#/components/PostControls' 43 + import {RichText} from '#/components/RichText' 44 + import * as Skele from '#/components/Skeleton' 45 + import {SubtleWebHover} from '#/components/SubtleWebHover' 46 + import {Text} from '#/components/Typography' 47 + 48 + /** 49 + * Mimic the space in PostMeta 50 + */ 51 + const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap 52 + 53 + export function ThreadItemTreePost({ 54 + item, 55 + overrides, 56 + onPostSuccess, 57 + threadgateRecord, 58 + }: { 59 + item: Extract<ThreadItem, {type: 'threadPost'}> 60 + overrides?: { 61 + moderation?: boolean 62 + topBorder?: boolean 63 + } 64 + onPostSuccess?: (data: OnPostSuccessData) => void 65 + threadgateRecord?: AppBskyFeedThreadgate.Record 66 + }) { 67 + const postShadow = usePostShadow(item.value.post) 68 + 69 + if (postShadow === POST_TOMBSTONE) { 70 + return <ThreadItemTreePostDeleted item={item} /> 71 + } 72 + 73 + return ( 74 + <ThreadItemTreePostInner 75 + // Safeguard from clobbering per-post state below: 76 + key={postShadow.uri} 77 + item={item} 78 + postShadow={postShadow} 79 + threadgateRecord={threadgateRecord} 80 + overrides={overrides} 81 + onPostSuccess={onPostSuccess} 82 + /> 83 + ) 84 + } 85 + 86 + function ThreadItemTreePostDeleted({ 87 + item, 88 + }: { 89 + item: Extract<ThreadItem, {type: 'threadPost'}> 90 + }) { 91 + const t = useTheme() 92 + return ( 93 + <ThreadItemTreePostOuterWrapper item={item}> 94 + <ThreadItemTreePostInnerWrapper item={item}> 95 + <View 96 + style={[ 97 + a.flex_row, 98 + a.align_center, 99 + a.rounded_sm, 100 + t.atoms.bg_contrast_25, 101 + { 102 + gap: 6, 103 + paddingHorizontal: OUTER_SPACE / 2, 104 + height: TREE_AVI_WIDTH, 105 + }, 106 + ]}> 107 + <TrashIcon style={[t.atoms.text]} width={14} /> 108 + <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}> 109 + <Trans>Post has been deleted</Trans> 110 + </Text> 111 + </View> 112 + {item.ui.isLastChild && !item.ui.precedesChildReadMore && ( 113 + <View style={{height: OUTER_SPACE / 2}} /> 114 + )} 115 + </ThreadItemTreePostInnerWrapper> 116 + </ThreadItemTreePostOuterWrapper> 117 + ) 118 + } 119 + 120 + const ThreadItemTreePostOuterWrapper = memo( 121 + function ThreadItemTreePostOuterWrapper({ 122 + item, 123 + children, 124 + }: { 125 + item: Extract<ThreadItem, {type: 'threadPost'}> 126 + children: React.ReactNode 127 + }) { 128 + const t = useTheme() 129 + const indents = Math.max(0, item.ui.indent - 1) 130 + 131 + return ( 132 + <View 133 + style={[ 134 + a.flex_row, 135 + item.ui.indent === 1 && 136 + !item.ui.showParentReplyLine && [ 137 + a.border_t, 138 + t.atoms.border_contrast_low, 139 + ], 140 + ]}> 141 + {Array.from(Array(indents)).map((_, n: number) => { 142 + const isSkipped = item.ui.skippedIndentIndices.has(n) 143 + return ( 144 + <View 145 + key={`${item.value.post.uri}-padding-${n}`} 146 + style={[ 147 + t.atoms.border_contrast_low, 148 + { 149 + borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH, 150 + width: TREE_INDENT + TREE_AVI_WIDTH / 2, 151 + left: 1, 152 + }, 153 + ]} 154 + /> 155 + ) 156 + })} 157 + {children} 158 + </View> 159 + ) 160 + }, 161 + ) 162 + 163 + const ThreadItemTreePostInnerWrapper = memo( 164 + function ThreadItemTreePostInnerWrapper({ 165 + item, 166 + children, 167 + }: { 168 + item: Extract<ThreadItem, {type: 'threadPost'}> 169 + children: React.ReactNode 170 + }) { 171 + const t = useTheme() 172 + return ( 173 + <View 174 + style={[ 175 + a.flex_1, // TODO check on ios 176 + { 177 + paddingHorizontal: OUTER_SPACE, 178 + paddingTop: OUTER_SPACE / 2, 179 + }, 180 + item.ui.indent === 1 && [ 181 + !item.ui.showParentReplyLine && a.pt_lg, 182 + !item.ui.showChildReplyLine && a.pb_sm, 183 + ], 184 + item.ui.isLastChild && 185 + !item.ui.precedesChildReadMore && [ 186 + { 187 + paddingBottom: OUTER_SPACE / 2, 188 + }, 189 + ], 190 + ]}> 191 + {item.ui.indent > 1 && ( 192 + <View 193 + style={[ 194 + a.absolute, 195 + t.atoms.border_contrast_low, 196 + { 197 + left: -1, 198 + top: 0, 199 + height: 200 + TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2, 201 + width: OUTER_SPACE, 202 + borderLeftWidth: REPLY_LINE_WIDTH, 203 + borderBottomWidth: REPLY_LINE_WIDTH, 204 + borderBottomLeftRadius: a.rounded_sm.borderRadius, 205 + }, 206 + ]} 207 + /> 208 + )} 209 + {children} 210 + </View> 211 + ) 212 + }, 213 + ) 214 + 215 + const ThreadItemTreeReplyChildReplyLine = memo( 216 + function ThreadItemTreeReplyChildReplyLine({ 217 + item, 218 + }: { 219 + item: Extract<ThreadItem, {type: 'threadPost'}> 220 + }) { 221 + const t = useTheme() 222 + return ( 223 + <View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}> 224 + {item.ui.showChildReplyLine && ( 225 + <View 226 + style={[ 227 + a.flex_1, 228 + t.atoms.border_contrast_low, 229 + { 230 + borderRightWidth: 2, 231 + width: '50%', 232 + left: -1, 233 + }, 234 + ]} 235 + /> 236 + )} 237 + </View> 238 + ) 239 + }, 240 + ) 241 + 242 + const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ 243 + item, 244 + postShadow, 245 + overrides, 246 + onPostSuccess, 247 + threadgateRecord, 248 + }: { 249 + item: Extract<ThreadItem, {type: 'threadPost'}> 250 + postShadow: Shadow<AppBskyFeedDefs.PostView> 251 + overrides?: { 252 + moderation?: boolean 253 + topBorder?: boolean 254 + } 255 + onPostSuccess?: (data: OnPostSuccessData) => void 256 + threadgateRecord?: AppBskyFeedThreadgate.Record 257 + }): React.ReactNode { 258 + const pal = usePalette('default') 259 + const {_} = useLingui() 260 + const {openComposer} = useOpenComposer() 261 + const {currentAccount} = useSession() 262 + 263 + const post = item.value.post 264 + const record = item.value.post.record 265 + const moderation = item.moderation 266 + const richText = useMemo( 267 + () => 268 + new RichTextAPI({ 269 + text: record.text, 270 + facets: record.facets, 271 + }), 272 + [record], 273 + ) 274 + const [limitLines, setLimitLines] = React.useState( 275 + () => countLines(richText?.text) >= MAX_POST_LINES, 276 + ) 277 + const threadRootUri = record.reply?.root?.uri || post.uri 278 + const postHref = React.useMemo(() => { 279 + const urip = new AtUri(post.uri) 280 + return makeProfileLink(post.author, 'post', urip.rkey) 281 + }, [post.uri, post.author]) 282 + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 283 + threadgateRecord, 284 + }) 285 + const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { 286 + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 287 + const isControlledByViewer = 288 + new AtUri(threadRootUri).host === currentAccount?.did 289 + return isControlledByViewer && isPostHiddenByThreadgate 290 + ? [ 291 + { 292 + type: 'reply-hidden', 293 + source: {type: 'user', did: currentAccount?.did}, 294 + priority: 6, 295 + }, 296 + ] 297 + : [] 298 + }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri]) 299 + 300 + const onPressReply = React.useCallback(() => { 301 + openComposer({ 302 + replyTo: { 303 + uri: post.uri, 304 + cid: post.cid, 305 + text: record.text, 306 + author: post.author, 307 + embed: post.embed, 308 + moderation, 309 + }, 310 + onPostSuccess: onPostSuccess, 311 + }) 312 + }, [openComposer, post, record, onPostSuccess, moderation]) 313 + 314 + const onPressShowMore = React.useCallback(() => { 315 + setLimitLines(false) 316 + }, [setLimitLines]) 317 + 318 + return ( 319 + <ThreadItemTreePostOuterWrapper item={item}> 320 + <SubtleHover> 321 + <PostHider 322 + testID={`postThreadItem-by-${post.author.handle}`} 323 + href={postHref} 324 + disabled={overrides?.moderation === true} 325 + modui={moderation.ui('contentList')} 326 + iconSize={42} 327 + iconStyles={{marginLeft: 2, marginRight: 2}} 328 + profile={post.author} 329 + interpretFilterAsBlur> 330 + <ThreadItemTreePostInnerWrapper item={item}> 331 + <View style={[a.flex_1]}> 332 + <PostMeta 333 + author={post.author} 334 + moderation={moderation} 335 + timestamp={post.indexedAt} 336 + postHref={postHref} 337 + avatarSize={TREE_AVI_WIDTH} 338 + style={[a.pb_2xs]} 339 + showAvatar 340 + /> 341 + <View style={[a.flex_row]}> 342 + <ThreadItemTreeReplyChildReplyLine item={item} /> 343 + <View style={[a.flex_1]}> 344 + <LabelsOnMyPost post={post} style={[a.pb_2xs]} /> 345 + <PostAlerts 346 + modui={moderation.ui('contentList')} 347 + style={[a.pb_2xs]} 348 + additionalCauses={additionalPostAlerts} 349 + /> 350 + {richText?.text ? ( 351 + <View> 352 + <RichText 353 + enableTags 354 + value={richText} 355 + style={[a.flex_1, a.text_md]} 356 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 357 + authorHandle={post.author.handle} 358 + shouldProxyLinks={true} 359 + /> 360 + </View> 361 + ) : undefined} 362 + {limitLines ? ( 363 + <TextLink 364 + text={_(msg`Show More`)} 365 + style={pal.link} 366 + onPress={onPressShowMore} 367 + href="#" 368 + /> 369 + ) : undefined} 370 + {post.embed && ( 371 + <View style={[a.pb_xs]}> 372 + <PostEmbeds 373 + embed={post.embed} 374 + moderation={moderation} 375 + viewContext={PostEmbedViewContext.Feed} 376 + /> 377 + </View> 378 + )} 379 + <PostControls 380 + post={postShadow} 381 + record={record} 382 + richText={richText} 383 + onPressReply={onPressReply} 384 + logContext="PostThreadItem" 385 + threadgateRecord={threadgateRecord} 386 + /> 387 + </View> 388 + </View> 389 + </View> 390 + </ThreadItemTreePostInnerWrapper> 391 + </PostHider> 392 + </SubtleHover> 393 + </ThreadItemTreePostOuterWrapper> 394 + ) 395 + }) 396 + 397 + function SubtleHover({children}: {children: React.ReactNode}) { 398 + const { 399 + state: hover, 400 + onIn: onHoverIn, 401 + onOut: onHoverOut, 402 + } = useInteractionState() 403 + return ( 404 + <View 405 + onPointerEnter={onHoverIn} 406 + onPointerLeave={onHoverOut} 407 + style={[a.flex_1, a.pointer]}> 408 + <SubtleWebHover hover={hover} /> 409 + {children} 410 + </View> 411 + ) 412 + } 413 + 414 + export function ThreadItemTreePostSkeleton({index}: {index: number}) { 415 + const t = useTheme() 416 + const even = index % 2 === 0 417 + return ( 418 + <View 419 + style={[ 420 + {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5}, 421 + a.gap_md, 422 + a.border_t, 423 + t.atoms.border_contrast_low, 424 + ]}> 425 + <Skele.Row style={[a.align_start, a.gap_md]}> 426 + <Skele.Circle size={TREE_AVI_WIDTH} /> 427 + 428 + <Skele.Col style={[a.gap_xs]}> 429 + <Skele.Row style={[a.gap_sm]}> 430 + <Skele.Text style={[a.text_md, {width: '20%'}]} /> 431 + <Skele.Text blend style={[a.text_md, {width: '30%'}]} /> 432 + </Skele.Row> 433 + 434 + <Skele.Col> 435 + {even ? ( 436 + <> 437 + <Skele.Text blend style={[a.text_md, {width: '100%'}]} /> 438 + <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 439 + </> 440 + ) : ( 441 + <Skele.Text blend style={[a.text_md, {width: '60%'}]} /> 442 + )} 443 + </Skele.Col> 444 + 445 + <Skele.Row style={[a.justify_between, a.pt_xs]}> 446 + <Skele.Pill blend size={16} /> 447 + <Skele.Pill blend size={16} /> 448 + <Skele.Pill blend size={16} /> 449 + <Skele.Circle blend size={16} /> 450 + <View /> 451 + </Skele.Row> 452 + </Skele.Col> 453 + </Skele.Row> 454 + </View> 455 + ) 456 + }
+7
src/screens/PostThread/const.ts
··· 1 + import {tokens} from '#/alf' 2 + 3 + export const TREE_INDENT = tokens.space.lg 4 + export const TREE_AVI_WIDTH = 24 5 + export const LINEAR_AVI_WIDTH = 42 6 + export const REPLY_LINE_WIDTH = 2 7 + export const OUTER_SPACE = tokens.space.lg
+577
src/screens/PostThread/index.tsx
··· 1 + import {useCallback, useMemo, useRef, useState} from 'react' 2 + import {useWindowDimensions, View} from 'react-native' 3 + import Animated, {useAnimatedStyle} from 'react-native-reanimated' 4 + import {Trans} from '@lingui/macro' 5 + 6 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 7 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 8 + import {useFeedFeedback} from '#/state/feed-feedback' 9 + import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 10 + import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' 11 + import {useSession} from '#/state/session' 12 + import {type OnPostSuccessData} from '#/state/shell/composer' 13 + import {useShellLayout} from '#/state/shell/shell-layout' 14 + import {useUnstablePostSource} from '#/state/unstable-post-source' 15 + import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' 16 + import {List, type ListMethods} from '#/view/com/util/List' 17 + import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' 18 + import {ThreadError} from '#/screens/PostThread/components/ThreadError' 19 + import { 20 + ThreadItemAnchor, 21 + ThreadItemAnchorSkeleton, 22 + } from '#/screens/PostThread/components/ThreadItemAnchor' 23 + import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' 24 + import { 25 + ThreadItemPost, 26 + ThreadItemPostSkeleton, 27 + } from '#/screens/PostThread/components/ThreadItemPost' 28 + import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' 29 + import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' 30 + import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' 31 + import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' 32 + import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' 33 + import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' 34 + import { 35 + ThreadItemTreePost, 36 + ThreadItemTreePostSkeleton, 37 + } from '#/screens/PostThread/components/ThreadItemTreePost' 38 + import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 39 + import * as Layout from '#/components/Layout' 40 + import {ListFooter} from '#/components/Lists' 41 + 42 + const PARENT_CHUNK_SIZE = 5 43 + const CHILDREN_CHUNK_SIZE = 50 44 + 45 + export function PostThread({uri}: {uri: string}) { 46 + const {gtMobile} = useBreakpoints() 47 + const {hasSession} = useSession() 48 + const initialNumToRender = useInitialNumToRender() // TODO 49 + const {height: windowHeight} = useWindowDimensions() 50 + const anchorPostSource = useUnstablePostSource(uri) 51 + const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) 52 + 53 + /* 54 + * One query to rule them all 55 + */ 56 + const thread = usePostThread({anchor: uri}) 57 + const anchor = useMemo(() => { 58 + for (const item of thread.data.items) { 59 + if (item.type === 'threadPost' && item.depth === 0) { 60 + return item 61 + } 62 + } 63 + return 64 + }, [thread.data.items]) 65 + 66 + const {openComposer} = useOpenComposer() 67 + const optimisticOnPostReply = useCallback( 68 + (payload: OnPostSuccessData) => { 69 + if (payload) { 70 + const {replyToUri, posts} = payload 71 + if (replyToUri && posts.length) { 72 + thread.actions.insertReplies(replyToUri, posts) 73 + } 74 + } 75 + }, 76 + [thread], 77 + ) 78 + const onReplyToAnchor = useCallback(() => { 79 + if (anchor?.type !== 'threadPost') { 80 + return 81 + } 82 + const post = anchor.value.post 83 + openComposer({ 84 + replyTo: { 85 + uri: anchor.uri, 86 + cid: post.cid, 87 + text: post.record.text, 88 + author: post.author, 89 + embed: post.embed, 90 + moderation: anchor.moderation, 91 + }, 92 + onPostSuccess: optimisticOnPostReply, 93 + }) 94 + 95 + if (anchorPostSource) { 96 + feedFeedback.sendInteraction({ 97 + item: post.uri, 98 + event: 'app.bsky.feed.defs#interactionReply', 99 + feedContext: anchorPostSource.post.feedContext, 100 + reqId: anchorPostSource.post.reqId, 101 + }) 102 + } 103 + }, [ 104 + anchor, 105 + openComposer, 106 + optimisticOnPostReply, 107 + anchorPostSource, 108 + feedFeedback, 109 + ]) 110 + 111 + const isRoot = !!anchor && anchor.value.post.record.reply === undefined 112 + const canReply = !anchor?.value.post?.viewer?.replyDisabled 113 + const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) 114 + const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) 115 + const totalParentCount = useRef(0) // recomputed below 116 + const totalChildrenCount = useRef(thread.data.items.length) // recomputed below 117 + const listRef = useRef<ListMethods>(null) 118 + const anchorRef = useRef<View | null>(null) 119 + const headerRef = useRef<View | null>(null) 120 + 121 + /* 122 + * On a cold load, parents are not prepended until the anchor post has 123 + * rendered as the first item in the list. This gives us a consistent 124 + * reference point for which to pin the anchor post to the top of the screen. 125 + * 126 + * We simulate a cold load any time the user changes the view or sort params 127 + * so that this handling is consistent. 128 + * 129 + * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives 130 + * us this for free, since the anchor post is the first item in the list. 131 + * 132 + * On web, `onContentSizeChange` is used to get ahead of next paint and handle 133 + * this scrolling. 134 + */ 135 + const [deferParents, setDeferParents] = useState(true) 136 + /** 137 + * Used to flag whether we should scroll to the anchor post. On a cold load, 138 + * this is always true. And when a user changes thread parameters, we also 139 + * manually set this to true. 140 + */ 141 + const shouldHandleScroll = useRef(true) 142 + /** 143 + * Called any time the content size of the list changes, _just_ before paint. 144 + * 145 + * We want this to fire every time we change params (which will reset 146 + * `deferParents` via `onLayout` on the anchor post, due to the key change), 147 + * or click into a new post (which will result in a fresh `deferParents` 148 + * hook). 149 + * 150 + * The result being: any intentional change in view by the user will result 151 + * in the anchor being pinned as the first item. 152 + */ 153 + const onContentSizeChangeWebOnly = web(() => { 154 + const list = listRef.current 155 + const anchor = anchorRef.current as any as Element 156 + const header = headerRef.current as any as Element 157 + 158 + if (list && anchor && header && shouldHandleScroll.current) { 159 + const anchorOffsetTop = anchor.getBoundingClientRect().top 160 + const headerHeight = header.getBoundingClientRect().height 161 + 162 + /* 163 + * `deferParents` is `true` on a cold load, and always reset to 164 + * `true` when params change via `prepareForParamsUpdate`. 165 + * 166 + * On a cold load or a push to a new post, on the first pass of this 167 + * logic, the anchor post is the first item in the list. Therefore 168 + * `anchorOffsetTop - headerHeight` will be 0. 169 + * 170 + * When a user changes thread params, on the first pass of this logic, 171 + * the anchor post may not move (if there are no parents above it), or it 172 + * may have gone off the screen above, because of the sudden lack of 173 + * parents due to `deferParents === true`. This negative value (minus 174 + * `headerHeight`) will result in a _negative_ `offset` value, which will 175 + * scroll the anchor post _down_ to the top of the screen. 176 + * 177 + * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user 178 + * changes params, the anchor post's offset will actually be equivalent 179 + * to the `headerHeight` because of how the DOM is stacked on web. 180 + * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, 181 + * which means the first pass in this case will result in no scroll. 182 + * 183 + * Then, once parents are prepended, this will fire again. Now, the 184 + * `anchorOffsetTop` will be positive, which minus the header height, 185 + * will give us a _positive_ offset, which will scroll the anchor post 186 + * back _up_ to the top of the screen. 187 + */ 188 + list.scrollToOffset({ 189 + offset: anchorOffsetTop - headerHeight, 190 + }) 191 + 192 + /* 193 + * After the second pass, `deferParents` will be `false`, and we need 194 + * to ensure this doesn't run again until scroll handling is requested 195 + * again via `shouldHandleScroll.current === true` and a params 196 + * change via `prepareForParamsUpdate`. 197 + * 198 + * The `isRoot` here is needed because if we're looking at the anchor 199 + * post, this handler will not fire after `deferParents` is set to 200 + * `false`, since there are no parents to render above it. In this case, 201 + * we want to make sure `shouldHandleScroll` is set to `false` so that 202 + * subsequent size changes unrelated to a params change (like pagination) 203 + * do not affect scroll. 204 + */ 205 + if (!deferParents || isRoot) shouldHandleScroll.current = false 206 + } 207 + }) 208 + 209 + /** 210 + * Ditto the above, but for native. 211 + */ 212 + const onContentSizeChangeNativeOnly = native(() => { 213 + const list = listRef.current 214 + const anchor = anchorRef.current 215 + 216 + if (list && anchor && shouldHandleScroll.current) { 217 + /* 218 + * `prepareForParamsUpdate` is called any time the user changes thread params like 219 + * `view` or `sort`, which sets `deferParents(true)` and resets the 220 + * scroll to the top of the list. However, there is a split second 221 + * where the top of the list is wherever the parents _just were_. So if 222 + * there were parents, the anchor is not at the top of the list just 223 + * prior to this handler being called. 224 + * 225 + * Once this handler is called, the anchor post is the first item in 226 + * the list (because of `deferParents` being `true`), and so we can 227 + * synchronously scroll the list back to the top of the list (which is 228 + * 0 on native, no need to handle `headerHeight`). 229 + */ 230 + list.scrollToOffset({ 231 + animated: false, 232 + offset: 0, 233 + }) 234 + 235 + /* 236 + * After this first pass, `deferParents` will be `false`, and those 237 + * will render in. However, the anchor post will retain its position 238 + * because of `maintainVisibleContentPosition` handling on native. So we 239 + * don't need to let this handler run again, like we do on web. 240 + */ 241 + shouldHandleScroll.current = false 242 + } 243 + }) 244 + 245 + /** 246 + * Called any time the user changes thread params, such as `view` or `sort`. 247 + * Prepares the UI for repositioning of the scroll so that the anchor post is 248 + * always at the top after a params change. 249 + * 250 + * No need to handle max parents here, deferParents will handle that and we 251 + * want it to re-render with the same items above the anchor. 252 + */ 253 + const prepareForParamsUpdate = useCallback(() => { 254 + /** 255 + * Truncate list so that anchor post is the first item in the list. Manual 256 + * scroll handling on web is predicated on this, and on native, this allows 257 + * `maintainVisibleContentPosition` to do its thing. 258 + */ 259 + setDeferParents(true) 260 + // reset this to a lower value for faster re-render 261 + setMaxChildrenCount(CHILDREN_CHUNK_SIZE) 262 + // set flag 263 + shouldHandleScroll.current = true 264 + }, [setDeferParents, setMaxChildrenCount]) 265 + 266 + const setSortWrapped = useCallback( 267 + (sort: string) => { 268 + prepareForParamsUpdate() 269 + thread.actions.setSort(sort) 270 + }, 271 + [thread, prepareForParamsUpdate], 272 + ) 273 + 274 + const setViewWrapped = useCallback( 275 + (view: ThreadViewOption) => { 276 + prepareForParamsUpdate() 277 + thread.actions.setView(view) 278 + }, 279 + [thread, prepareForParamsUpdate], 280 + ) 281 + 282 + const onStartReached = () => { 283 + if (thread.state.isFetching) return 284 + // can be true after `prepareForParamsUpdate` is called 285 + if (deferParents) return 286 + // prevent any state mutations if we know we're done 287 + if (maxParentCount >= totalParentCount.current) return 288 + setMaxParentCount(n => n + PARENT_CHUNK_SIZE) 289 + } 290 + 291 + const onEndReached = () => { 292 + if (thread.state.isFetching) return 293 + // can be true after `prepareForParamsUpdate` is called 294 + if (deferParents) return 295 + // prevent any state mutations if we know we're done 296 + if (maxChildrenCount >= totalChildrenCount.current) return 297 + setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) 298 + } 299 + 300 + const slices = useMemo(() => { 301 + const results: ThreadItem[] = [] 302 + 303 + if (!thread.data.items.length) return results 304 + 305 + /* 306 + * Pagination hack, tracks the # of items below the anchor post. 307 + */ 308 + let childrenCount = 0 309 + 310 + for (let i = 0; i < thread.data.items.length; i++) { 311 + const item = thread.data.items[i] 312 + /* 313 + * Need to check `depth`, since not found or blocked posts are not 314 + * `threadPost`s, but still have `depth`. 315 + */ 316 + const hasDepth = 'depth' in item 317 + 318 + /* 319 + * Handle anchor post. 320 + */ 321 + if (hasDepth && item.depth === 0) { 322 + results.push(item) 323 + 324 + // Recalculate total parents current index. 325 + totalParentCount.current = i 326 + // Recalculate total children using (length - 1) - current index. 327 + totalChildrenCount.current = thread.data.items.length - 1 - i 328 + 329 + /* 330 + * Walk up the parents, limiting by `maxParentCount` 331 + */ 332 + if (!deferParents) { 333 + const start = i - 1 334 + if (start >= 0) { 335 + const limit = Math.max(0, start - maxParentCount) 336 + for (let pi = start; pi >= limit; pi--) { 337 + results.unshift(thread.data.items[pi]) 338 + } 339 + } 340 + } 341 + } else { 342 + // ignore any parent items 343 + if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue 344 + // can exit early if we've reached the max children count 345 + if (childrenCount > maxChildrenCount) break 346 + 347 + results.push(item) 348 + childrenCount++ 349 + } 350 + } 351 + 352 + return results 353 + }, [thread, deferParents, maxParentCount, maxChildrenCount]) 354 + 355 + const isTombstoneView = useMemo(() => { 356 + if (slices.length > 1) return false 357 + return slices.every( 358 + s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', 359 + ) 360 + }, [slices]) 361 + 362 + const renderItem = useCallback( 363 + ({item, index}: {item: ThreadItem; index: number}) => { 364 + if (item.type === 'threadPost') { 365 + if (item.depth < 0) { 366 + return ( 367 + <ThreadItemPost 368 + item={item} 369 + threadgateRecord={thread.data.threadgate?.record ?? undefined} 370 + overrides={{ 371 + topBorder: index === 0, 372 + }} 373 + onPostSuccess={optimisticOnPostReply} 374 + /> 375 + ) 376 + } else if (item.depth === 0) { 377 + return ( 378 + /* 379 + * Keep this view wrapped so that the anchor post is always index 0 380 + * in the list and `maintainVisibleContentPosition` can do its 381 + * thing. 382 + */ 383 + <View collapsable={false}> 384 + <View 385 + /* 386 + * IMPORTANT: this is a load-bearing key on all platforms. We 387 + * want to force `onLayout` to fire any time the thread params 388 + * change so that `deferParents` is always reset to `false` once 389 + * the anchor post is rendered. 390 + * 391 + * If we ever add additional thread params to this screen, they 392 + * will need to be added here. 393 + */ 394 + key={item.uri + thread.state.view + thread.state.sort} 395 + ref={anchorRef} 396 + onLayout={() => setDeferParents(false)} 397 + /> 398 + <ThreadItemAnchor 399 + item={item} 400 + threadgateRecord={thread.data.threadgate?.record ?? undefined} 401 + onPostSuccess={optimisticOnPostReply} 402 + postSource={anchorPostSource} 403 + /> 404 + </View> 405 + ) 406 + } else { 407 + if (thread.state.view === 'tree') { 408 + return ( 409 + <ThreadItemTreePost 410 + item={item} 411 + threadgateRecord={thread.data.threadgate?.record ?? undefined} 412 + overrides={{ 413 + moderation: thread.state.otherItemsVisible && item.depth > 0, 414 + }} 415 + onPostSuccess={optimisticOnPostReply} 416 + /> 417 + ) 418 + } else { 419 + return ( 420 + <ThreadItemPost 421 + item={item} 422 + threadgateRecord={thread.data.threadgate?.record ?? undefined} 423 + overrides={{ 424 + moderation: thread.state.otherItemsVisible && item.depth > 0, 425 + }} 426 + onPostSuccess={optimisticOnPostReply} 427 + /> 428 + ) 429 + } 430 + } 431 + } else if (item.type === 'threadPostNoUnauthenticated') { 432 + if (item.depth < 0) { 433 + return <ThreadItemPostNoUnauthenticated item={item} /> 434 + } else if (item.depth === 0) { 435 + return <ThreadItemAnchorNoUnauthenticated /> 436 + } 437 + } else if (item.type === 'readMore') { 438 + return ( 439 + <ThreadItemReadMore 440 + item={item} 441 + view={thread.state.view === 'tree' ? 'tree' : 'linear'} 442 + /> 443 + ) 444 + } else if (item.type === 'readMoreUp') { 445 + return <ThreadItemReadMoreUp item={item} /> 446 + } else if (item.type === 'threadPostBlocked') { 447 + return <ThreadItemPostTombstone type="blocked" /> 448 + } else if (item.type === 'threadPostNotFound') { 449 + return <ThreadItemPostTombstone type="not-found" /> 450 + } else if (item.type === 'replyComposer') { 451 + return ( 452 + <View> 453 + {gtMobile && ( 454 + <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> 455 + )} 456 + </View> 457 + ) 458 + } else if (item.type === 'showOtherReplies') { 459 + return <ThreadItemShowOtherReplies onPress={item.onPress} /> 460 + } else if (item.type === 'skeleton') { 461 + if (item.item === 'anchor') { 462 + return <ThreadItemAnchorSkeleton /> 463 + } else if (item.item === 'reply') { 464 + if (thread.state.view === 'linear') { 465 + return <ThreadItemPostSkeleton index={index} /> 466 + } else { 467 + return <ThreadItemTreePostSkeleton index={index} /> 468 + } 469 + } else if (item.item === 'replyComposer') { 470 + return <ThreadItemReplyComposerSkeleton /> 471 + } 472 + } 473 + return null 474 + }, 475 + [ 476 + thread, 477 + optimisticOnPostReply, 478 + onReplyToAnchor, 479 + gtMobile, 480 + anchorPostSource, 481 + ], 482 + ) 483 + 484 + return ( 485 + <> 486 + <Layout.Header.Outer headerRef={headerRef}> 487 + <Layout.Header.BackButton /> 488 + <Layout.Header.Content> 489 + <Layout.Header.TitleText> 490 + <Trans context="description">Post</Trans> 491 + </Layout.Header.TitleText> 492 + </Layout.Header.Content> 493 + <Layout.Header.Slot> 494 + <HeaderDropdown 495 + sort={thread.state.sort} 496 + setSort={setSortWrapped} 497 + view={thread.state.view} 498 + setView={setViewWrapped} 499 + /> 500 + </Layout.Header.Slot> 501 + </Layout.Header.Outer> 502 + 503 + {thread.state.error ? ( 504 + <ThreadError 505 + error={thread.state.error} 506 + onRetry={thread.actions.refetch} 507 + /> 508 + ) : ( 509 + <List 510 + ref={listRef} 511 + data={slices} 512 + renderItem={renderItem} 513 + keyExtractor={keyExtractor} 514 + onContentSizeChange={platform({ 515 + web: onContentSizeChangeWebOnly, 516 + default: onContentSizeChangeNativeOnly, 517 + })} 518 + onStartReached={onStartReached} 519 + onEndReached={onEndReached} 520 + onEndReachedThreshold={2} 521 + onStartReachedThreshold={1} 522 + /** 523 + * NATIVE ONLY 524 + * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} 525 + */ 526 + maintainVisibleContentPosition={{minIndexForVisible: 0}} 527 + desktopFixedHeight 528 + ListFooterComponent={ 529 + <ListFooter 530 + /* 531 + * On native, if `deferParents` is true, we need some extra buffer to 532 + * account for the `on*ReachedThreshold` values. 533 + * 534 + * Otherwise, and on web, this value needs to be the height of 535 + * the viewport _minus_ a sensible min-post height e.g. 200, so 536 + * that there's enough scroll remaining to get the anchor post 537 + * back to the top of the screen when handling scroll. 538 + */ 539 + height={platform({ 540 + web: windowHeight - 200, 541 + default: deferParents ? windowHeight * 2 : windowHeight - 200, 542 + })} 543 + style={isTombstoneView ? {borderTopWidth: 0} : undefined} 544 + /> 545 + } 546 + initialNumToRender={initialNumToRender} 547 + windowSize={11} 548 + sideBorders={false} 549 + /> 550 + )} 551 + 552 + {!gtMobile && canReply && hasSession && ( 553 + <MobileComposePrompt onPressReply={onReplyToAnchor} /> 554 + )} 555 + </> 556 + ) 557 + } 558 + 559 + function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 560 + const {footerHeight} = useShellLayout() 561 + 562 + const animatedStyle = useAnimatedStyle(() => { 563 + return { 564 + bottom: footerHeight.get(), 565 + } 566 + }) 567 + 568 + return ( 569 + <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 570 + <PostThreadComposePrompt onPressCompose={onPressReply} /> 571 + </Animated.View> 572 + ) 573 + } 574 + 575 + const keyExtractor = (item: ThreadItem) => { 576 + return item.key 577 + }
+135 -1
src/screens/Settings/ThreadPreferences.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 5 + import { 6 + type CommonNavigatorParams, 7 + type NativeStackScreenProps, 8 + } from '#/lib/routes/types' 9 + import {useGate} from '#/lib/statsig/statsig' 6 10 import { 7 11 usePreferencesQuery, 8 12 useSetThreadViewPreferencesMutation, 9 13 } from '#/state/queries/preferences' 14 + import { 15 + normalizeSort, 16 + normalizeView, 17 + useThreadPreferences, 18 + } from '#/state/queries/preferences/useThreadPreferences' 10 19 import {atoms as a, useTheme} from '#/alf' 11 20 import * as Toggle from '#/components/forms/Toggle' 12 21 import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 13 22 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 14 23 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 24 + import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree' 15 25 import * as Layout from '#/components/Layout' 16 26 import {Text} from '#/components/Typography' 17 27 import * as SettingsList from './components/SettingsList' 18 28 19 29 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> 20 30 export function ThreadPreferencesScreen({}: Props) { 31 + const gate = useGate() 32 + 33 + return gate('post_threads_v2_unspecced') ? ( 34 + <ThreadPreferencesV2 /> 35 + ) : ( 36 + <ThreadPreferencesV1 /> 37 + ) 38 + } 39 + 40 + export function ThreadPreferencesV2() { 41 + const t = useTheme() 42 + const {_} = useLingui() 43 + const { 44 + sort, 45 + setSort, 46 + view, 47 + setView, 48 + prioritizeFollowedUsers, 49 + setPrioritizeFollowedUsers, 50 + } = useThreadPreferences({save: true}) 51 + 52 + return ( 53 + <Layout.Screen testID="threadPreferencesScreen"> 54 + <Layout.Header.Outer> 55 + <Layout.Header.BackButton /> 56 + <Layout.Header.Content> 57 + <Layout.Header.TitleText> 58 + <Trans>Thread Preferences</Trans> 59 + </Layout.Header.TitleText> 60 + </Layout.Header.Content> 61 + <Layout.Header.Slot /> 62 + </Layout.Header.Outer> 63 + <Layout.Content> 64 + <SettingsList.Container> 65 + <SettingsList.Group> 66 + <SettingsList.ItemIcon icon={BubblesIcon} /> 67 + <SettingsList.ItemText> 68 + <Trans>Sort replies</Trans> 69 + </SettingsList.ItemText> 70 + <View style={[a.w_full, a.gap_md]}> 71 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 72 + <Trans>Sort replies to the same post by:</Trans> 73 + </Text> 74 + <Toggle.Group 75 + label={_(msg`Sort replies by`)} 76 + type="radio" 77 + values={sort ? [sort] : []} 78 + onChange={values => setSort(normalizeSort(values[0]))}> 79 + <View style={[a.gap_sm, a.flex_1]}> 80 + <Toggle.Item name="top" label={_(msg`Top replies first`)}> 81 + <Toggle.Radio /> 82 + <Toggle.LabelText> 83 + <Trans>Top replies first</Trans> 84 + </Toggle.LabelText> 85 + </Toggle.Item> 86 + <Toggle.Item 87 + name="oldest" 88 + label={_(msg`Oldest replies first`)}> 89 + <Toggle.Radio /> 90 + <Toggle.LabelText> 91 + <Trans>Oldest replies first</Trans> 92 + </Toggle.LabelText> 93 + </Toggle.Item> 94 + <Toggle.Item 95 + name="newest" 96 + label={_(msg`Newest replies first`)}> 97 + <Toggle.Radio /> 98 + <Toggle.LabelText> 99 + <Trans>Newest replies first</Trans> 100 + </Toggle.LabelText> 101 + </Toggle.Item> 102 + </View> 103 + </Toggle.Group> 104 + </View> 105 + </SettingsList.Group> 106 + 107 + <SettingsList.Group contentContainerStyle={{minHeight: 0}}> 108 + <SettingsList.ItemIcon icon={PersonGroupIcon} /> 109 + <SettingsList.ItemText> 110 + <Trans>Prioritize your Follows</Trans> 111 + </SettingsList.ItemText> 112 + <Toggle.Item 113 + type="checkbox" 114 + name="prioritize-follows" 115 + label={_(msg`Prioritize your Follows`)} 116 + value={prioritizeFollowedUsers} 117 + onChange={value => setPrioritizeFollowedUsers(value)} 118 + style={[a.w_full, a.gap_md]}> 119 + <Toggle.LabelText style={[a.flex_1]}> 120 + <Trans> 121 + Show replies by people you follow before all other replies 122 + </Trans> 123 + </Toggle.LabelText> 124 + <Toggle.Platform /> 125 + </Toggle.Item> 126 + </SettingsList.Group> 127 + 128 + <SettingsList.Group> 129 + <SettingsList.ItemIcon icon={TreeIcon} /> 130 + <SettingsList.ItemText> 131 + <Trans>Tree view</Trans> 132 + </SettingsList.ItemText> 133 + <Toggle.Item 134 + type="checkbox" 135 + name="threaded-mode" 136 + label={_(msg`Tree view`)} 137 + value={view === 'tree'} 138 + onChange={value => 139 + setView(normalizeView({treeViewEnabled: value})) 140 + } 141 + style={[a.w_full, a.gap_md]}> 142 + <Toggle.LabelText style={[a.flex_1]}> 143 + <Trans>Show post replies in a threaded tree view</Trans> 144 + </Toggle.LabelText> 145 + <Toggle.Platform /> 146 + </Toggle.Item> 147 + </SettingsList.Group> 148 + </SettingsList.Container> 149 + </Layout.Content> 150 + </Layout.Screen> 151 + ) 152 + } 153 + 154 + export function ThreadPreferencesV1() { 21 155 const {_} = useLingui() 22 156 const t = useTheme() 23 157
+4 -1
src/screens/VideoFeed/index.tsx
··· 882 882 player={player} 883 883 seekingAnimationSV={seekingAnimationSV} 884 884 scrollGesture={scrollGesture}> 885 - <PostThreadComposePrompt onPressCompose={onPressReply} /> 885 + <PostThreadComposePrompt 886 + onPressCompose={onPressReply} 887 + style={[a.pt_md, a.pb_sm]} 888 + /> 886 889 </Scrubber> 887 890 </LinearGradient> 888 891 </View>
+4
src/state/cache/post-shadow.ts
··· 14 14 import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 15 15 import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' 16 16 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 17 + import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' 17 18 import {useProfileShadow} from './profile-shadow' 18 19 import {castAsShadow, type Shadow} from './types' 19 20 export type {Shadow} from './types' ··· 156 157 if (node.type === 'post') { 157 158 yield node.post 158 159 } 160 + } 161 + for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { 162 + yield post 159 163 } 160 164 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 161 165 yield post
+2
src/state/cache/profile-shadow.ts
··· 21 21 import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' 22 22 import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' 23 23 import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' 24 + import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' 24 25 import type * as bsky from '#/types/bsky' 25 26 import {castAsShadow, type Shadow} from './types' 26 27 ··· 167 168 yield* findAllProfilesInListConvosQueryData(queryClient, did) 168 169 yield* findAllProfilesInFeedsQueryData(queryClient, did) 169 170 yield* findAllProfilesInPostThreadQueryData(queryClient, did) 171 + yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) 170 172 yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) 171 173 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) 172 174 }
+179
src/state/queries/preferences/useThreadPreferences.ts
··· 1 + import {useCallback, useMemo, useRef, useState} from 'react' 2 + import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' 3 + import debounce from 'lodash.debounce' 4 + 5 + import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce' 6 + import {logger} from '#/logger' 7 + import { 8 + usePreferencesQuery, 9 + useSetThreadViewPreferencesMutation, 10 + } from '#/state/queries/preferences' 11 + import {type ThreadViewPreferences} from '#/state/queries/preferences/types' 12 + import {type Literal} from '#/types/utils' 13 + 14 + export type ThreadSortOption = Literal< 15 + AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'], 16 + string 17 + > 18 + export type ThreadViewOption = 'linear' | 'tree' 19 + export type ThreadPreferences = { 20 + isLoaded: boolean 21 + isSaving: boolean 22 + sort: ThreadSortOption 23 + setSort: (sort: string) => void 24 + view: ThreadViewOption 25 + setView: (view: ThreadViewOption) => void 26 + prioritizeFollowedUsers: boolean 27 + setPrioritizeFollowedUsers: (prioritize: boolean) => void 28 + } 29 + 30 + export function useThreadPreferences({ 31 + save, 32 + }: {save?: boolean} = {}): ThreadPreferences { 33 + const {data: preferences} = usePreferencesQuery() 34 + const serverPrefs = preferences?.threadViewPrefs 35 + const once = useCallOnce(OnceKey.PreferencesThread) 36 + 37 + /* 38 + * Create local state representations of server state 39 + */ 40 + const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top')) 41 + const [view, setView] = useState( 42 + normalizeView({ 43 + treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled, 44 + }), 45 + ) 46 + const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( 47 + !!serverPrefs?.prioritizeFollowedUsers, 48 + ) 49 + 50 + /** 51 + * If we get a server update, update local state 52 + */ 53 + const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) 54 + const isLoaded = !!prevServerPrefs 55 + if (serverPrefs && prevServerPrefs !== serverPrefs) { 56 + setPrevServerPrefs(serverPrefs) 57 + 58 + /* 59 + * Update 60 + */ 61 + setSort(normalizeSort(serverPrefs.sort)) 62 + setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers) 63 + setView( 64 + normalizeView({ 65 + treeViewEnabled: !!serverPrefs.lab_treeViewEnabled, 66 + }), 67 + ) 68 + 69 + once(() => { 70 + logger.metric('thread:preferences:load', { 71 + sort: serverPrefs.sort, 72 + view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear', 73 + prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers, 74 + }) 75 + }) 76 + } 77 + 78 + const userUpdatedPrefs = useRef(false) 79 + const [isSaving, setIsSaving] = useState(false) 80 + const {mutateAsync} = useSetThreadViewPreferencesMutation() 81 + const savePrefs = useMemo(() => { 82 + return debounce(async (prefs: ThreadViewPreferences) => { 83 + try { 84 + setIsSaving(true) 85 + await mutateAsync(prefs) 86 + logger.metric('thread:preferences:update', { 87 + sort: prefs.sort, 88 + view: prefs.lab_treeViewEnabled ? 'tree' : 'linear', 89 + prioritizeFollowedUsers: prefs.prioritizeFollowedUsers, 90 + }) 91 + } catch (e) { 92 + logger.error('useThreadPreferences failed to save', { 93 + safeMessage: e, 94 + }) 95 + } finally { 96 + setIsSaving(false) 97 + } 98 + }, 4e3) 99 + }, [mutateAsync]) 100 + 101 + if (save && userUpdatedPrefs.current) { 102 + savePrefs({ 103 + sort, 104 + prioritizeFollowedUsers, 105 + lab_treeViewEnabled: view === 'tree', 106 + }) 107 + userUpdatedPrefs.current = false 108 + } 109 + 110 + const setSortWrapped = useCallback( 111 + (next: string) => { 112 + userUpdatedPrefs.current = true 113 + setSort(normalizeSort(next)) 114 + }, 115 + [setSort], 116 + ) 117 + const setViewWrapped = useCallback( 118 + (next: ThreadViewOption) => { 119 + userUpdatedPrefs.current = true 120 + setView(next) 121 + }, 122 + [setView], 123 + ) 124 + const setPrioritizeFollowedUsersWrapped = useCallback( 125 + (next: boolean) => { 126 + userUpdatedPrefs.current = true 127 + setPrioritizeFollowedUsers(next) 128 + }, 129 + [setPrioritizeFollowedUsers], 130 + ) 131 + 132 + return useMemo( 133 + () => ({ 134 + isLoaded, 135 + isSaving, 136 + sort, 137 + setSort: setSortWrapped, 138 + view, 139 + setView: setViewWrapped, 140 + prioritizeFollowedUsers, 141 + setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped, 142 + }), 143 + [ 144 + isLoaded, 145 + isSaving, 146 + sort, 147 + setSortWrapped, 148 + view, 149 + setViewWrapped, 150 + prioritizeFollowedUsers, 151 + setPrioritizeFollowedUsersWrapped, 152 + ], 153 + ) 154 + } 155 + 156 + /** 157 + * Migrates user thread preferences from the old sort values to V2 158 + */ 159 + export function normalizeSort(sort: string): ThreadSortOption { 160 + switch (sort) { 161 + case 'oldest': 162 + return 'oldest' 163 + case 'newest': 164 + return 'newest' 165 + default: 166 + return 'top' 167 + } 168 + } 169 + 170 + /** 171 + * Transforms existing treeViewEnabled preference into a ThreadViewOption 172 + */ 173 + export function normalizeView({ 174 + treeViewEnabled, 175 + }: { 176 + treeViewEnabled: boolean 177 + }): ThreadViewOption { 178 + return treeViewEnabled ? 'tree' : 'linear' 179 + }
+27
src/state/queries/usePostThread/const.ts
··· 1 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 + import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api' 3 + 4 + /** 5 + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} 6 + */ 7 + export const LINEAR_VIEW_BELOW = 10 8 + 9 + /** 10 + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} 11 + */ 12 + export const LINEAR_VIEW_BF = 1 13 + 14 + /** 15 + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} 16 + */ 17 + export const TREE_VIEW_BELOW = 4 18 + 19 + /** 20 + * See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} 21 + */ 22 + export const TREE_VIEW_BF = undefined 23 + 24 + /** 25 + * See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams} 26 + */ 27 + export const TREE_VIEW_BELOW_DESKTOP = 6
+325
src/state/queries/usePostThread/index.ts
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {useQuery, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {isWeb} from '#/platform/detection' 5 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 6 + import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' 7 + import { 8 + LINEAR_VIEW_BELOW, 9 + LINEAR_VIEW_BF, 10 + TREE_VIEW_BELOW, 11 + TREE_VIEW_BELOW_DESKTOP, 12 + TREE_VIEW_BF, 13 + } from '#/state/queries/usePostThread/const' 14 + import { 15 + createCacheMutator, 16 + getThreadPlaceholder, 17 + } from '#/state/queries/usePostThread/queryCache' 18 + import { 19 + buildThread, 20 + sortAndAnnotateThreadItems, 21 + } from '#/state/queries/usePostThread/traversal' 22 + import { 23 + createPostThreadOtherQueryKey, 24 + createPostThreadQueryKey, 25 + type ThreadItem, 26 + type UsePostThreadQueryResult, 27 + } from '#/state/queries/usePostThread/types' 28 + import {getThreadgateRecord} from '#/state/queries/usePostThread/utils' 29 + import * as views from '#/state/queries/usePostThread/views' 30 + import {useAgent, useSession} from '#/state/session' 31 + import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 32 + import {useBreakpoints} from '#/alf' 33 + 34 + export * from '#/state/queries/usePostThread/types' 35 + 36 + export function usePostThread({anchor}: {anchor?: string}) { 37 + const qc = useQueryClient() 38 + const agent = useAgent() 39 + const {hasSession} = useSession() 40 + const {gtPhone} = useBreakpoints() 41 + const moderationOpts = useModerationOpts() 42 + const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() 43 + const { 44 + isLoaded: isThreadPreferencesLoaded, 45 + sort, 46 + setSort: baseSetSort, 47 + view, 48 + setView: baseSetView, 49 + prioritizeFollowedUsers, 50 + } = useThreadPreferences() 51 + const below = useMemo(() => { 52 + return view === 'linear' 53 + ? LINEAR_VIEW_BELOW 54 + : isWeb && gtPhone 55 + ? TREE_VIEW_BELOW_DESKTOP 56 + : TREE_VIEW_BELOW 57 + }, [view, gtPhone]) 58 + 59 + const postThreadQueryKey = createPostThreadQueryKey({ 60 + anchor, 61 + sort, 62 + view, 63 + prioritizeFollowedUsers, 64 + }) 65 + const postThreadOtherQueryKey = createPostThreadOtherQueryKey({ 66 + anchor, 67 + prioritizeFollowedUsers, 68 + }) 69 + 70 + const query = useQuery<UsePostThreadQueryResult>({ 71 + enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, 72 + queryKey: postThreadQueryKey, 73 + async queryFn(ctx) { 74 + const {data} = await agent.app.bsky.unspecced.getPostThreadV2({ 75 + anchor: anchor!, 76 + branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF, 77 + below, 78 + sort: sort, 79 + prioritizeFollowedUsers: prioritizeFollowedUsers, 80 + }) 81 + 82 + /* 83 + * Initialize `ctx.meta` to track if we know we have additional replies 84 + * we could fetch once we hit the end. 85 + */ 86 + ctx.meta = ctx.meta || { 87 + hasOtherReplies: false, 88 + } 89 + 90 + /* 91 + * If we know we have additional replies, we'll set this to true. 92 + */ 93 + if (data.hasOtherReplies) { 94 + ctx.meta.hasOtherReplies = true 95 + } 96 + 97 + const result = { 98 + thread: data.thread || [], 99 + threadgate: data.threadgate, 100 + hasOtherReplies: !!ctx.meta.hasOtherReplies, 101 + } 102 + 103 + const record = getThreadgateRecord(result.threadgate) 104 + if (result.threadgate && record) { 105 + result.threadgate.record = record 106 + } 107 + 108 + return result as UsePostThreadQueryResult 109 + }, 110 + placeholderData() { 111 + if (!anchor) return 112 + const placeholder = getThreadPlaceholder(qc, anchor) 113 + /* 114 + * Always return something here, even empty data, so that 115 + * `isPlaceholderData` is always true, which we'll use to insert 116 + * skeletons. 117 + */ 118 + const thread = placeholder ? [placeholder] : [] 119 + return {thread, threadgate: undefined, hasOtherReplies: false} 120 + }, 121 + select(data) { 122 + const record = getThreadgateRecord(data.threadgate) 123 + if (data.threadgate && record) { 124 + data.threadgate.record = record 125 + } 126 + return data 127 + }, 128 + }) 129 + 130 + const thread = useMemo(() => query.data?.thread || [], [query.data?.thread]) 131 + const threadgate = useMemo( 132 + () => query.data?.threadgate, 133 + [query.data?.threadgate], 134 + ) 135 + const hasOtherThreadItems = useMemo( 136 + () => !!query.data?.hasOtherReplies, 137 + [query.data?.hasOtherReplies], 138 + ) 139 + const [otherItemsVisible, setOtherItemsVisible] = useState(false) 140 + 141 + /** 142 + * Creates a mutator for the post thread cache. This is used to insert 143 + * replies into the thread cache after posting. 144 + */ 145 + const mutator = useMemo( 146 + () => 147 + createCacheMutator({ 148 + params: {view, below}, 149 + postThreadQueryKey, 150 + postThreadOtherQueryKey, 151 + queryClient: qc, 152 + }), 153 + [qc, view, below, postThreadQueryKey, postThreadOtherQueryKey], 154 + ) 155 + 156 + /** 157 + * If we have additional items available from the server and the user has 158 + * chosen to view them, start loading data 159 + */ 160 + const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible 161 + const additionalItemsQuery = useQuery({ 162 + enabled: additionalQueryEnabled, 163 + queryKey: postThreadOtherQueryKey, 164 + async queryFn() { 165 + const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({ 166 + anchor: anchor!, 167 + prioritizeFollowedUsers, 168 + }) 169 + return data 170 + }, 171 + }) 172 + const serverOtherThreadItems: ThreadItem[] = useMemo(() => { 173 + if (!additionalQueryEnabled) return [] 174 + if (additionalItemsQuery.isLoading) { 175 + return Array.from({length: 2}).map((_, i) => 176 + views.skeleton({ 177 + key: `other-reply-${i}`, 178 + item: 'reply', 179 + }), 180 + ) 181 + } else if (additionalItemsQuery.isError) { 182 + /* 183 + * We could insert an special error component in here, but since these 184 + * are optional additional replies, it's not critical that they're shown 185 + * atm. 186 + */ 187 + return [] 188 + } else if (additionalItemsQuery.data?.thread) { 189 + const {threadItems} = sortAndAnnotateThreadItems( 190 + additionalItemsQuery.data.thread, 191 + { 192 + view, 193 + skipModerationHandling: true, 194 + threadgateHiddenReplies: mergeThreadgateHiddenReplies( 195 + threadgate?.record, 196 + ), 197 + moderationOpts: moderationOpts!, 198 + }, 199 + ) 200 + return threadItems 201 + } else { 202 + return [] 203 + } 204 + }, [ 205 + view, 206 + additionalQueryEnabled, 207 + additionalItemsQuery, 208 + mergeThreadgateHiddenReplies, 209 + moderationOpts, 210 + threadgate?.record, 211 + ]) 212 + 213 + /** 214 + * Sets the sort order for the thread and resets the additional thread items 215 + */ 216 + const setSort: typeof baseSetSort = useCallback( 217 + nextSort => { 218 + setOtherItemsVisible(false) 219 + baseSetSort(nextSort) 220 + }, 221 + [baseSetSort, setOtherItemsVisible], 222 + ) 223 + 224 + /** 225 + * Sets the view variant for the thread and resets the additional thread items 226 + */ 227 + const setView: typeof baseSetView = useCallback( 228 + nextView => { 229 + setOtherItemsVisible(false) 230 + baseSetView(nextView) 231 + }, 232 + [baseSetView, setOtherItemsVisible], 233 + ) 234 + 235 + /* 236 + * This is the main thread response, sorted into separate buckets based on 237 + * moderation, and annotated with all UI state needed for rendering. 238 + */ 239 + const {threadItems, otherThreadItems} = useMemo(() => { 240 + return sortAndAnnotateThreadItems(thread, { 241 + view: view, 242 + threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), 243 + moderationOpts: moderationOpts!, 244 + }) 245 + }, [ 246 + thread, 247 + threadgate?.record, 248 + mergeThreadgateHiddenReplies, 249 + moderationOpts, 250 + view, 251 + ]) 252 + 253 + /* 254 + * Take all three sets of thread items and combine them into a single thread, 255 + * along with any other thread items required for rendering e.g. "Show more 256 + * replies" or the reply composer. 257 + */ 258 + const items = useMemo(() => { 259 + return buildThread({ 260 + threadItems, 261 + otherThreadItems, 262 + serverOtherThreadItems, 263 + isLoading: query.isPlaceholderData, 264 + hasSession, 265 + hasOtherThreadItems, 266 + otherItemsVisible, 267 + showOtherItems: () => setOtherItemsVisible(true), 268 + }) 269 + }, [ 270 + threadItems, 271 + otherThreadItems, 272 + serverOtherThreadItems, 273 + query.isPlaceholderData, 274 + hasSession, 275 + hasOtherThreadItems, 276 + otherItemsVisible, 277 + setOtherItemsVisible, 278 + ]) 279 + 280 + return useMemo( 281 + () => ({ 282 + state: { 283 + /* 284 + * Copy in any query state that is useful 285 + */ 286 + isFetching: query.isFetching, 287 + isPlaceholderData: query.isPlaceholderData, 288 + error: query.error, 289 + /* 290 + * Other state 291 + */ 292 + sort, 293 + view, 294 + otherItemsVisible, 295 + }, 296 + data: { 297 + items, 298 + threadgate, 299 + }, 300 + actions: { 301 + /* 302 + * Copy in any query actions that are useful 303 + */ 304 + insertReplies: mutator.insertReplies, 305 + refetch: query.refetch, 306 + /* 307 + * Other actions 308 + */ 309 + setSort, 310 + setView, 311 + }, 312 + }), 313 + [ 314 + query, 315 + mutator.insertReplies, 316 + otherItemsVisible, 317 + sort, 318 + view, 319 + setSort, 320 + setView, 321 + threadgate, 322 + items, 323 + ], 324 + ) 325 + }
+300
src/state/queries/usePostThread/queryCache.ts
··· 1 + import { 2 + type $Typed, 3 + type AppBskyActorDefs, 4 + type AppBskyFeedDefs, 5 + AppBskyUnspeccedDefs, 6 + type AppBskyUnspeccedGetPostThreadOtherV2, 7 + type AppBskyUnspeccedGetPostThreadV2, 8 + AtUri, 9 + } from '@atproto/api' 10 + import {type QueryClient} from '@tanstack/react-query' 11 + 12 + import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 13 + import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' 14 + import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' 15 + import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 16 + import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 17 + import {getBranch} from '#/state/queries/usePostThread/traversal' 18 + import { 19 + type ApiThreadItem, 20 + type createPostThreadOtherQueryKey, 21 + type createPostThreadQueryKey, 22 + type PostThreadParams, 23 + postThreadQueryKeyRoot, 24 + } from '#/state/queries/usePostThread/types' 25 + import {getRootPostAtUri} from '#/state/queries/usePostThread/utils' 26 + import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views' 27 + import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util' 28 + import {embedViewRecordToPostView} from '#/state/queries/util' 29 + 30 + export function createCacheMutator({ 31 + queryClient, 32 + postThreadQueryKey, 33 + postThreadOtherQueryKey, 34 + params, 35 + }: { 36 + queryClient: QueryClient 37 + postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey> 38 + postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey> 39 + params: Pick<PostThreadParams, 'view'> & {below: number} 40 + }) { 41 + return { 42 + insertReplies( 43 + parentUri: string, 44 + replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], 45 + ) { 46 + /* 47 + * Main thread query mutator. 48 + */ 49 + queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( 50 + postThreadQueryKey, 51 + data => { 52 + if (!data) return 53 + return { 54 + ...data, 55 + thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([ 56 + ...data.thread, 57 + ]), 58 + } 59 + }, 60 + ) 61 + 62 + /* 63 + * Additional replies query mutator. 64 + */ 65 + queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>( 66 + postThreadOtherQueryKey, 67 + data => { 68 + if (!data) return 69 + return { 70 + ...data, 71 + thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([ 72 + ...data.thread, 73 + ]), 74 + } 75 + }, 76 + ) 77 + 78 + function mutator<T>(thread: ApiThreadItem[]): T[] { 79 + for (let i = 0; i < thread.length; i++) { 80 + const existingParent = thread[i] 81 + if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) 82 + continue 83 + if (existingParent.uri !== parentUri) continue 84 + 85 + /* 86 + * Update parent data 87 + */ 88 + existingParent.value.post = { 89 + ...existingParent.value.post, 90 + replyCount: (existingParent.value.post.replyCount || 0) + 1, 91 + } 92 + 93 + const opDid = getRootPostAtUri(existingParent.value.post)?.host 94 + const nextItem = thread.at(i + 1) 95 + const isReplyToRoot = existingParent.depth === 0 96 + const isEndOfReplyChain = 97 + !nextItem || nextItem.depth <= existingParent.depth 98 + const firstReply = replies.at(0) 99 + const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( 100 + firstReply?.value, 101 + ) 102 + ? opDid === firstReply.value.post.author.did 103 + : false 104 + 105 + /* 106 + * Always insert replies if the following conditions are met. 107 + */ 108 + const shouldAlwaysInsertReplies = 109 + isReplyToRoot || 110 + params.view === 'tree' || 111 + (params.view === 'linear' && isEndOfReplyChain) 112 + /* 113 + * Maybe insert replies if the replier is the OP and certain conditions are met 114 + */ 115 + const shouldReplaceWithOPReplies = 116 + !isReplyToRoot && params.view === 'linear' && opIsReplier 117 + 118 + if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) { 119 + const branch = getBranch(thread, i, existingParent.depth) 120 + /* 121 + * OP insertions replace other replies _in linear view_. 122 + */ 123 + const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0 124 + const itemsToInsert = replies 125 + .map((r, ri) => { 126 + r.depth = existingParent.depth + 1 + ri 127 + return r 128 + }) 129 + .filter(r => { 130 + // Filter out replies that are too deep for our UI 131 + return r.depth <= params.below 132 + }) 133 + 134 + thread.splice(i + 1, itemsToRemove, ...itemsToInsert) 135 + } 136 + } 137 + 138 + return thread as T[] 139 + } 140 + }, 141 + /** 142 + * Unused atm, post shadow does the trick, but it would be nice to clean up 143 + * the whole sub-tree on deletes. 144 + */ 145 + deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { 146 + queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( 147 + postThreadQueryKey, 148 + queryData => { 149 + if (!queryData) return 150 + 151 + const thread = [...queryData.thread] 152 + 153 + for (let i = 0; i < thread.length; i++) { 154 + const existingPost = thread[i] 155 + if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue 156 + 157 + if (existingPost.uri === post.uri) { 158 + const branch = getBranch(thread, i, existingPost.depth) 159 + thread.splice(branch.start, branch.length) 160 + break 161 + } 162 + } 163 + 164 + return { 165 + ...queryData, 166 + thread, 167 + } 168 + }, 169 + ) 170 + }, 171 + } 172 + } 173 + 174 + export function getThreadPlaceholder( 175 + queryClient: QueryClient, 176 + uri: string, 177 + ): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void { 178 + let partial 179 + for (let item of getThreadPlaceholderCandidates(queryClient, uri)) { 180 + /* 181 + * Currently, the backend doesn't send full post info in some cases (for 182 + * example, for quoted posts). We use missing `likeCount` as a way to 183 + * detect that. In the future, we should fix this on the backend, which 184 + * will let us always stop on the first result. 185 + * 186 + * TODO can we send in feeds and quotes? 187 + */ 188 + const hasAllInfo = item.value.post.likeCount != null 189 + if (hasAllInfo) { 190 + return item 191 + } else { 192 + // Keep searching, we might still find a full post in the cache. 193 + partial = item 194 + } 195 + } 196 + return partial 197 + } 198 + 199 + export function* getThreadPlaceholderCandidates( 200 + queryClient: QueryClient, 201 + uri: string, 202 + ): Generator< 203 + $Typed< 204 + Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & { 205 + value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost> 206 + } 207 + >, 208 + void 209 + > { 210 + /* 211 + * Check post thread queries first 212 + */ 213 + for (const post of findAllPostsInQueryData(queryClient, uri)) { 214 + yield postViewToThreadPlaceholder(post) 215 + } 216 + 217 + /* 218 + * Check notifications first. If you have a post in notifications, it's 219 + * often due to a like or a repost, and we want to prioritize a post object 220 + * with >0 likes/reposts over a stale version with no metrics in order to 221 + * avoid a notification->post scroll jump. 222 + */ 223 + for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 224 + yield postViewToThreadPlaceholder(post) 225 + } 226 + for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { 227 + yield postViewToThreadPlaceholder(post) 228 + } 229 + for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 230 + yield postViewToThreadPlaceholder(post) 231 + } 232 + for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 233 + yield postViewToThreadPlaceholder(post) 234 + } 235 + for (let post of findAllPostsInExploreFeedPreviewsQueryData( 236 + queryClient, 237 + uri, 238 + )) { 239 + yield postViewToThreadPlaceholder(post) 240 + } 241 + } 242 + 243 + export function* findAllPostsInQueryData( 244 + queryClient: QueryClient, 245 + uri: string, 246 + ): Generator<AppBskyFeedDefs.PostView, void> { 247 + const atUri = new AtUri(uri) 248 + const queryDatas = 249 + queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({ 250 + queryKey: [postThreadQueryKeyRoot], 251 + }) 252 + 253 + for (const [_queryKey, queryData] of queryDatas) { 254 + if (!queryData) continue 255 + 256 + const {thread} = queryData 257 + 258 + for (const item of thread) { 259 + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 260 + if (didOrHandleUriMatches(atUri, item.value.post)) { 261 + yield item.value.post 262 + } 263 + 264 + const qp = getEmbeddedPost(item.value.post.embed) 265 + if (qp && didOrHandleUriMatches(atUri, qp)) { 266 + yield embedViewRecordToPostView(qp) 267 + } 268 + } 269 + } 270 + } 271 + } 272 + 273 + export function* findAllProfilesInQueryData( 274 + queryClient: QueryClient, 275 + did: string, 276 + ): Generator<AppBskyActorDefs.ProfileViewBasic, void> { 277 + const queryDatas = 278 + queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({ 279 + queryKey: [postThreadQueryKeyRoot], 280 + }) 281 + 282 + for (const [_queryKey, queryData] of queryDatas) { 283 + if (!queryData) continue 284 + 285 + const {thread} = queryData 286 + 287 + for (const item of thread) { 288 + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 289 + if (item.value.post.author.did === did) { 290 + yield item.value.post.author 291 + } 292 + 293 + const qp = getEmbeddedPost(item.value.post.embed) 294 + if (qp && qp.author.did === did) { 295 + yield qp.author 296 + } 297 + } 298 + } 299 + } 300 + }
+539
src/state/queries/usePostThread/traversal.ts
··· 1 + /* eslint-disable no-labels */ 2 + import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api' 3 + 4 + import { 5 + type ApiThreadItem, 6 + type PostThreadParams, 7 + type ThreadItem, 8 + type TraversalMetadata, 9 + } from '#/state/queries/usePostThread/types' 10 + import { 11 + getPostRecord, 12 + getThreadPostNoUnauthenticatedUI, 13 + getThreadPostUI, 14 + getTraversalMetadata, 15 + storeTraversalMetadata, 16 + } from '#/state/queries/usePostThread/utils' 17 + import * as views from '#/state/queries/usePostThread/views' 18 + 19 + export function sortAndAnnotateThreadItems( 20 + thread: ApiThreadItem[], 21 + { 22 + threadgateHiddenReplies, 23 + moderationOpts, 24 + view, 25 + skipModerationHandling, 26 + }: { 27 + threadgateHiddenReplies: Set<string> 28 + moderationOpts: ModerationOpts 29 + view: PostThreadParams['view'] 30 + /** 31 + * Set to `true` in cases where we already know the moderation state of the 32 + * post e.g. when fetching additional replies from the server. This will 33 + * prevent additional sorting or nested-branch truncation, and all replies, 34 + * regardless of moderation state, will be included in the resulting 35 + * `threadItems` array. 36 + */ 37 + skipModerationHandling?: boolean 38 + }, 39 + ) { 40 + const threadItems: ThreadItem[] = [] 41 + const otherThreadItems: ThreadItem[] = [] 42 + const metadatas = new Map<string, TraversalMetadata>() 43 + 44 + traversal: for (let i = 0; i < thread.length; i++) { 45 + const item = thread[i] 46 + let parentMetadata: TraversalMetadata | undefined 47 + let metadata: TraversalMetadata | undefined 48 + 49 + if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 50 + parentMetadata = metadatas.get( 51 + getPostRecord(item.value.post).reply?.parent?.uri || '', 52 + ) 53 + metadata = getTraversalMetadata({ 54 + item, 55 + parentMetadata, 56 + prevItem: thread.at(i - 1), 57 + nextItem: thread.at(i + 1), 58 + }) 59 + storeTraversalMetadata(metadatas, metadata) 60 + } 61 + 62 + if (item.depth < 0) { 63 + /* 64 + * Parents are ignored until we find the anchor post, then we walk 65 + * _up_ from there. 66 + */ 67 + } else if (item.depth === 0) { 68 + if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) { 69 + threadItems.push(views.threadPostNoUnauthenticated(item)) 70 + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) { 71 + threadItems.push(views.threadPostNotFound(item)) 72 + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) { 73 + threadItems.push(views.threadPostBlocked(item)) 74 + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 75 + const post = views.threadPost({ 76 + uri: item.uri, 77 + depth: item.depth, 78 + value: item.value, 79 + moderationOpts, 80 + threadgateHiddenReplies, 81 + }) 82 + threadItems.push(post) 83 + 84 + parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { 85 + const parent = thread[pi] 86 + 87 + if ( 88 + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value) 89 + ) { 90 + const post = views.threadPostNoUnauthenticated(parent) 91 + post.ui = getThreadPostNoUnauthenticatedUI({ 92 + depth: parent.depth, 93 + // ignore for now 94 + // prevItemDepth: thread[pi - 1]?.depth, 95 + nextItemDepth: thread[pi + 1]?.depth, 96 + }) 97 + threadItems.unshift(post) 98 + // for now, break parent traversal at first no-unauthed 99 + break parentTraversal 100 + } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) { 101 + threadItems.unshift(views.threadPostNotFound(parent)) 102 + break parentTraversal 103 + } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) { 104 + threadItems.unshift(views.threadPostBlocked(parent)) 105 + break parentTraversal 106 + } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) { 107 + threadItems.unshift( 108 + views.threadPost({ 109 + uri: parent.uri, 110 + depth: parent.depth, 111 + value: parent.value, 112 + moderationOpts, 113 + threadgateHiddenReplies, 114 + }), 115 + ) 116 + } 117 + } 118 + } 119 + } else if (item.depth > 0) { 120 + /* 121 + * The API does not send down any unavailable replies, so this will 122 + * always be false (for now). If we ever wanted to tombstone them here, 123 + * we could. 124 + */ 125 + const shouldBreak = 126 + AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) || 127 + AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) || 128 + AppBskyUnspeccedDefs.isThreadItemBlocked(item.value) 129 + 130 + if (shouldBreak) { 131 + const branch = getBranch(thread, i, item.depth) 132 + // could insert tombstone 133 + i = branch.end 134 + continue traversal 135 + } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 136 + if (parentMetadata) { 137 + /* 138 + * Set this value before incrementing the parent's repliesSeenCounter 139 + */ 140 + metadata!.replyIndex = parentMetadata.repliesIndexCounter 141 + // Increment the parent's repliesIndexCounter 142 + parentMetadata.repliesIndexCounter += 1 143 + } 144 + 145 + const post = views.threadPost({ 146 + uri: item.uri, 147 + depth: item.depth, 148 + value: item.value, 149 + moderationOpts, 150 + threadgateHiddenReplies, 151 + }) 152 + 153 + if (!post.isBlurred || skipModerationHandling) { 154 + /* 155 + * Not moderated, need to insert it 156 + */ 157 + threadItems.push(post) 158 + 159 + /* 160 + * Update seen reply count of parent 161 + */ 162 + if (parentMetadata) { 163 + parentMetadata.repliesSeenCounter += 1 164 + } 165 + } else { 166 + /* 167 + * Moderated in some way, we're going to walk children 168 + */ 169 + const parent = post 170 + const parentIsTopLevelReply = parent.depth === 1 171 + // get sub tree 172 + const branch = getBranch(thread, i, item.depth) 173 + 174 + if (parentIsTopLevelReply) { 175 + // push branch anchor into sorted array 176 + otherThreadItems.push(parent) 177 + // skip branch anchor in branch traversal 178 + const startIndex = branch.start + 1 179 + 180 + for (let ci = startIndex; ci <= branch.end; ci++) { 181 + const child = thread[ci] 182 + 183 + if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) { 184 + const childParentMetadata = metadatas.get( 185 + getPostRecord(child.value.post).reply?.parent?.uri || '', 186 + ) 187 + const childMetadata = getTraversalMetadata({ 188 + item: child, 189 + prevItem: thread[ci - 1], 190 + nextItem: thread[ci + 1], 191 + parentMetadata: childParentMetadata, 192 + }) 193 + storeTraversalMetadata(metadatas, childMetadata) 194 + if (childParentMetadata) { 195 + /* 196 + * Set this value before incrementing the parent's repliesIndexCounter 197 + */ 198 + childMetadata!.replyIndex = 199 + childParentMetadata.repliesIndexCounter 200 + childParentMetadata.repliesIndexCounter += 1 201 + } 202 + 203 + const childPost = views.threadPost({ 204 + uri: child.uri, 205 + depth: child.depth, 206 + value: child.value, 207 + moderationOpts, 208 + threadgateHiddenReplies, 209 + }) 210 + 211 + /* 212 + * If a child is moderated in any way, drop it an its sub-branch 213 + * entirely. To reveal these, the user must navigate to the 214 + * parent post directly. 215 + */ 216 + if (childPost.isBlurred) { 217 + ci = getBranch(thread, ci, child.depth).end 218 + } else { 219 + otherThreadItems.push(childPost) 220 + 221 + if (childParentMetadata) { 222 + childParentMetadata.repliesSeenCounter += 1 223 + } 224 + } 225 + } else { 226 + /* 227 + * Drop the rest of the branch if we hit anything unexpected 228 + */ 229 + break 230 + } 231 + } 232 + } 233 + 234 + /* 235 + * Skip to next branch 236 + */ 237 + i = branch.end 238 + continue traversal 239 + } 240 + } 241 + } 242 + } 243 + 244 + /* 245 + * Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute 246 + * UI state based on collected metadata. These arrays will be muted in situ. 247 + */ 248 + for (const subset of [threadItems, otherThreadItems]) { 249 + for (let i = 0; i < subset.length; i++) { 250 + const item = subset[i] 251 + const prevItem = subset.at(i - 1) 252 + const nextItem = subset.at(i + 1) 253 + 254 + if (item.type === 'threadPost') { 255 + const metadata = metadatas.get(item.uri) 256 + 257 + if (metadata) { 258 + if (metadata.parentMetadata) { 259 + /* 260 + * Track what's before/after now that we've applied moderation 261 + */ 262 + if (prevItem?.type === 'threadPost') 263 + metadata.prevItemDepth = prevItem?.depth 264 + if (nextItem?.type === 'threadPost') 265 + metadata.nextItemDepth = nextItem?.depth 266 + 267 + /* 268 + * We can now officially calculate `isLastSibling` and `isLastChild` 269 + * based on the actual data that we've seen. 270 + */ 271 + metadata.isLastSibling = 272 + metadata.replyIndex === 273 + metadata.parentMetadata.repliesSeenCounter - 1 274 + metadata.isLastChild = 275 + metadata.nextItemDepth === undefined || 276 + metadata.nextItemDepth <= metadata.depth 277 + 278 + /* 279 + * If this is the last sibling, it's implicitly part of the last 280 + * branch of this sub-tree. 281 + */ 282 + if (metadata.isLastSibling) { 283 + metadata.isPartOfLastBranchFromDepth = metadata.depth 284 + 285 + /** 286 + * If the parent is part of the last branch of the sub-tree, so is the child. 287 + */ 288 + if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { 289 + metadata.isPartOfLastBranchFromDepth = 290 + metadata.parentMetadata.isPartOfLastBranchFromDepth 291 + } 292 + } 293 + 294 + /* 295 + * If this is the last sibling, and the parent has unhydrated replies, 296 + * at some point down the line we will need to show a "read more". 297 + */ 298 + if ( 299 + metadata.parentMetadata.repliesUnhydrated > 0 && 300 + metadata.isLastSibling 301 + ) { 302 + metadata.upcomingParentReadMore = metadata.parentMetadata 303 + } 304 + 305 + /* 306 + * Copy in the parent's upcoming read more, if it exists. Once we 307 + * reach the bottom, we'll insert a "read more" 308 + */ 309 + if (metadata.parentMetadata.upcomingParentReadMore) { 310 + metadata.upcomingParentReadMore = 311 + metadata.parentMetadata.upcomingParentReadMore 312 + } 313 + 314 + /* 315 + * Copy in the parent's skipped indents 316 + */ 317 + metadata.skippedIndentIndices = new Set([ 318 + ...metadata.parentMetadata.skippedIndentIndices, 319 + ]) 320 + 321 + /** 322 + * If this is the last sibling, and the parent has no unhydrated 323 + * replies, then we know we can skip an indent line. 324 + */ 325 + if ( 326 + metadata.parentMetadata.repliesUnhydrated <= 0 && 327 + metadata.isLastSibling 328 + ) { 329 + /** 330 + * Depth is 2 more than the 0-index of the indent calculation 331 + * bc of how we render these. So instead of handling that in the 332 + * component, we just adjust that back to 0-index here. 333 + */ 334 + metadata.skippedIndentIndices.add(item.depth - 2) 335 + } 336 + } 337 + 338 + /* 339 + * If this post has unhydrated replies, and it is the last child, then 340 + * it itself needs a "read more" 341 + */ 342 + if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) { 343 + metadata.precedesChildReadMore = true 344 + subset.splice(i + 1, 0, views.readMore(metadata)) 345 + i++ // skip next iteration 346 + } 347 + 348 + /* 349 + * Tree-view only. 350 + * 351 + * If there's an upcoming parent read more, this branch is part of the 352 + * last branch of the sub-tree, and the item itself is the last child, 353 + * insert the parent "read more". 354 + */ 355 + if ( 356 + view === 'tree' && 357 + metadata.upcomingParentReadMore && 358 + metadata.isPartOfLastBranchFromDepth === 359 + metadata.upcomingParentReadMore.depth && 360 + metadata.isLastChild 361 + ) { 362 + subset.splice( 363 + i + 1, 364 + 0, 365 + views.readMore(metadata.upcomingParentReadMore), 366 + ) 367 + i++ 368 + } 369 + 370 + /** 371 + * Only occurs for the first item in the thread, which may have 372 + * additional parents not included in this request. 373 + */ 374 + if (item.value.moreParents) { 375 + metadata.followsReadMoreUp = true 376 + subset.splice(i, 0, views.readMoreUp(metadata)) 377 + i++ 378 + } 379 + 380 + /* 381 + * Calculate the final UI state for the thread item. 382 + */ 383 + item.ui = getThreadPostUI(metadata) 384 + } 385 + } 386 + } 387 + } 388 + 389 + return { 390 + threadItems, 391 + otherThreadItems, 392 + } 393 + } 394 + 395 + export function buildThread({ 396 + threadItems, 397 + otherThreadItems, 398 + serverOtherThreadItems, 399 + isLoading, 400 + hasSession, 401 + otherItemsVisible, 402 + hasOtherThreadItems, 403 + showOtherItems, 404 + }: { 405 + threadItems: ThreadItem[] 406 + otherThreadItems: ThreadItem[] 407 + serverOtherThreadItems: ThreadItem[] 408 + isLoading: boolean 409 + hasSession: boolean 410 + otherItemsVisible: boolean 411 + hasOtherThreadItems: boolean 412 + showOtherItems: () => void 413 + }) { 414 + /** 415 + * `threadItems` is memoized here, so don't mutate it directly. 416 + */ 417 + const items = [...threadItems] 418 + 419 + if (isLoading) { 420 + const anchorPost = items.at(0) 421 + const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost' 422 + const skeletonReplies = hasAnchorFromCache 423 + ? anchorPost.value.post.replyCount ?? 4 424 + : 4 425 + 426 + if (!items.length) { 427 + items.push( 428 + views.skeleton({ 429 + key: 'anchor-skeleton', 430 + item: 'anchor', 431 + }), 432 + ) 433 + } 434 + 435 + if (hasSession) { 436 + // we might have this from cache 437 + const replyDisabled = 438 + hasAnchorFromCache && 439 + anchorPost.value.post.viewer?.replyDisabled === true 440 + 441 + if (hasAnchorFromCache) { 442 + if (!replyDisabled) { 443 + items.push({ 444 + type: 'replyComposer', 445 + key: 'replyComposer', 446 + }) 447 + } 448 + } else { 449 + items.push( 450 + views.skeleton({ 451 + key: 'replyComposer', 452 + item: 'replyComposer', 453 + }), 454 + ) 455 + } 456 + } 457 + 458 + for (let i = 0; i < skeletonReplies; i++) { 459 + items.push( 460 + views.skeleton({ 461 + key: `anchor-skeleton-reply-${i}`, 462 + item: 'reply', 463 + }), 464 + ) 465 + } 466 + } else { 467 + for (let i = 0; i < items.length; i++) { 468 + const item = items[i] 469 + if ( 470 + item.type === 'threadPost' && 471 + item.depth === 0 && 472 + !item.value.post.viewer?.replyDisabled && 473 + hasSession 474 + ) { 475 + items.splice(i + 1, 0, { 476 + type: 'replyComposer', 477 + key: 'replyComposer', 478 + }) 479 + break 480 + } 481 + } 482 + 483 + if (otherThreadItems.length || hasOtherThreadItems) { 484 + if (otherItemsVisible) { 485 + items.push(...otherThreadItems) 486 + items.push(...serverOtherThreadItems) 487 + } else { 488 + items.push({ 489 + type: 'showOtherReplies', 490 + key: 'showOtherReplies', 491 + onPress: showOtherItems, 492 + }) 493 + } 494 + } 495 + } 496 + 497 + return items 498 + } 499 + 500 + /** 501 + * Get the start and end index of a "branch" of the thread. A "branch" is a 502 + * parent and it's children (not siblings). Returned indices are inclusive of 503 + * the parent and its last child. 504 + * 505 + * items[] (index, depth) 506 + * └─┬ anchor ──────── (0, 0) 507 + * ├─── branch ───── (1, 1) 508 + * ├──┬ branch ───── (2, 1) (start) 509 + * │ ├──┬ leaf ──── (3, 2) 510 + * │ │ └── leaf ── (4, 3) 511 + * │ └─── leaf ──── (5, 2) (end) 512 + * ├─── branch ───── (6, 1) 513 + * └─── branch ───── (7, 1) 514 + * 515 + * const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1) 516 + */ 517 + export function getBranch( 518 + thread: ApiThreadItem[], 519 + branchStartIndex: number, 520 + branchStartDepth: number, 521 + ) { 522 + let end = branchStartIndex 523 + 524 + for (let ci = branchStartIndex + 1; ci < thread.length; ci++) { 525 + const next = thread[ci] 526 + if (next.depth > branchStartDepth) { 527 + end = ci 528 + } else { 529 + end = ci - 1 530 + break 531 + } 532 + } 533 + 534 + return { 535 + start: branchStartIndex, 536 + end, 537 + length: end - branchStartIndex, 538 + } 539 + }
+227
src/state/queries/usePostThread/types.ts
··· 1 + import { 2 + type AppBskyFeedDefs, 3 + type AppBskyFeedPost, 4 + type AppBskyFeedThreadgate, 5 + type AppBskyUnspeccedDefs, 6 + type AppBskyUnspeccedGetPostThreadOtherV2, 7 + type AppBskyUnspeccedGetPostThreadV2, 8 + type ModerationDecision, 9 + } from '@atproto/api' 10 + 11 + export type ApiThreadItem = 12 + | AppBskyUnspeccedGetPostThreadV2.ThreadItem 13 + | AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem 14 + 15 + export const postThreadQueryKeyRoot = 'post-thread-v2' as const 16 + 17 + export const createPostThreadQueryKey = (props: PostThreadParams) => 18 + [postThreadQueryKeyRoot, props] as const 19 + 20 + export const createPostThreadOtherQueryKey = ( 21 + props: Omit<AppBskyUnspeccedGetPostThreadOtherV2.QueryParams, 'anchor'> & { 22 + anchor?: string 23 + }, 24 + ) => [postThreadQueryKeyRoot, 'other', props] as const 25 + 26 + export type PostThreadParams = Pick< 27 + AppBskyUnspeccedGetPostThreadV2.QueryParams, 28 + 'sort' | 'prioritizeFollowedUsers' 29 + > & { 30 + anchor?: string 31 + view: 'tree' | 'linear' 32 + } 33 + 34 + export type UsePostThreadQueryResult = { 35 + hasOtherReplies: boolean 36 + thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] 37 + threadgate?: Omit<AppBskyFeedDefs.ThreadgateView, 'record'> & { 38 + record: AppBskyFeedThreadgate.Record 39 + } 40 + } 41 + 42 + export type ThreadItem = 43 + | { 44 + type: 'threadPost' 45 + key: string 46 + uri: string 47 + depth: number 48 + value: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & { 49 + post: Omit<AppBskyFeedDefs.PostView, 'record'> & { 50 + record: AppBskyFeedPost.Record 51 + } 52 + } 53 + isBlurred: boolean 54 + moderation: ModerationDecision 55 + ui: { 56 + isAnchor: boolean 57 + showParentReplyLine: boolean 58 + showChildReplyLine: boolean 59 + indent: number 60 + isLastChild: boolean 61 + skippedIndentIndices: Set<number> 62 + precedesChildReadMore: boolean 63 + } 64 + } 65 + | { 66 + type: 'threadPostNoUnauthenticated' 67 + key: string 68 + uri: string 69 + depth: number 70 + value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated 71 + ui: { 72 + showParentReplyLine: boolean 73 + showChildReplyLine: boolean 74 + } 75 + } 76 + | { 77 + type: 'threadPostNotFound' 78 + key: string 79 + uri: string 80 + depth: number 81 + value: AppBskyUnspeccedDefs.ThreadItemNotFound 82 + } 83 + | { 84 + type: 'threadPostBlocked' 85 + key: string 86 + uri: string 87 + depth: number 88 + value: AppBskyUnspeccedDefs.ThreadItemBlocked 89 + } 90 + | { 91 + type: 'replyComposer' 92 + key: string 93 + } 94 + | { 95 + type: 'showOtherReplies' 96 + key: string 97 + onPress: () => void 98 + } 99 + | { 100 + /* 101 + * Read more replies, downwards in the thread. 102 + */ 103 + type: 'readMore' 104 + key: string 105 + depth: number 106 + href: string 107 + moreReplies: number 108 + skippedIndentIndices: Set<number> 109 + } 110 + | { 111 + /* 112 + * Read more parents, upwards in the thread. 113 + */ 114 + type: 'readMoreUp' 115 + key: string 116 + href: string 117 + } 118 + | { 119 + type: 'skeleton' 120 + key: string 121 + item: 'anchor' | 'reply' | 'replyComposer' 122 + } 123 + 124 + /** 125 + * Metadata collected while traversing the raw data from the thread response. 126 + * Some values here can be computed immediately, while others need to be 127 + * computed during a second pass over the thread after we know things like 128 + * total number of replies, the reply index, etc. 129 + * 130 + * The idea here is that these values should be objectively true in all cases, 131 + * such that we can use them later — either individually on in composite — to 132 + * drive rendering behaviors. 133 + */ 134 + export type TraversalMetadata = { 135 + /** 136 + * The depth of the post in the reply tree, where 0 is the root post. This is 137 + * calculated on the server. 138 + */ 139 + depth: number 140 + /** 141 + * Indicates if this item is a "read more" link preceding this post that 142 + * continues the thread upwards. 143 + */ 144 + followsReadMoreUp: boolean 145 + /** 146 + * Indicates if the post is the last reply beneath its parent post. 147 + */ 148 + isLastSibling: boolean 149 + /** 150 + * Indicates the post is the end-of-the-line for a given branch of replies. 151 + */ 152 + isLastChild: boolean 153 + /** 154 + * Indicates if the post is the left/lower-most branch of the reply tree. 155 + * Value corresponds to the depth at which this branch started. 156 + */ 157 + isPartOfLastBranchFromDepth?: number 158 + /** 159 + * The depth of the slice immediately following this one, if it exists. 160 + */ 161 + nextItemDepth?: number 162 + /** 163 + * This is a live reference to the parent metadata object. Mutations to this 164 + * are available for later use in children. 165 + */ 166 + parentMetadata?: TraversalMetadata 167 + /** 168 + * Populated during the final traversal of the thread. Denotes whether 169 + * there is a "Read more" link for this item immediately following 170 + * this item. 171 + */ 172 + precedesChildReadMore: boolean 173 + /** 174 + * The depth of the slice immediately preceding this one, if it exists. 175 + */ 176 + prevItemDepth?: number 177 + /** 178 + * Any data needed to be passed along to the "read more" items. Keep this 179 + * trim for better memory usage. 180 + */ 181 + postData: { 182 + uri: string 183 + authorHandle: string 184 + } 185 + /** 186 + * The total number of replies to this post, including those not hydrated 187 + * and returned by the response. 188 + */ 189 + repliesCount: number 190 + /** 191 + * The number of replies to this post not hydrated and returned by the 192 + * response. 193 + */ 194 + repliesUnhydrated: number 195 + /** 196 + * The number of replies that have been seen so far in the traversal. 197 + * Excludes replies that are moderated in some way, since those are not 198 + * "seen" on first load. Use `repliesIndexCounter` for the total number of 199 + * replies that were hydrated in the response. 200 + * 201 + * After traversal, we can use this to calculate if we actually got all the 202 + * replies we expected, or if some were blocked, etc. 203 + */ 204 + repliesSeenCounter: number 205 + /** 206 + * The total number of replies to this post hydrated in this response. Used 207 + * for populating the `replyIndex` of the post by referencing this value on 208 + * the parent. 209 + */ 210 + repliesIndexCounter: number 211 + /** 212 + * The index-0-based index of this reply in the parent post's replies. 213 + */ 214 + replyIndex: number 215 + /** 216 + * Each slice is responsible for rendering reply lines based on its depth. 217 + * This value corresponds to any line indices that can be skipped e.g. 218 + * because there are no further replies below this sub-tree to render. 219 + */ 220 + skippedIndentIndices: Set<number> 221 + /** 222 + * Indicates and stores parent data IF that parent has additional unhydrated 223 + * replies. This value is passed down to children along the left/lower-most 224 + * branch of the tree. When the end is reached, a "read more" is inserted. 225 + */ 226 + upcomingParentReadMore?: TraversalMetadata 227 + }
+170
src/state/queries/usePostThread/utils.ts
··· 1 + import { 2 + type AppBskyFeedDefs, 3 + AppBskyFeedPost, 4 + AppBskyFeedThreadgate, 5 + AppBskyUnspeccedDefs, 6 + type AppBskyUnspeccedGetPostThreadV2, 7 + AtUri, 8 + } from '@atproto/api' 9 + 10 + import { 11 + type ApiThreadItem, 12 + type ThreadItem, 13 + type TraversalMetadata, 14 + } from '#/state/queries/usePostThread/types' 15 + import {isDevMode} from '#/storage/hooks/dev-mode' 16 + import * as bsky from '#/types/bsky' 17 + 18 + export function getThreadgateRecord( 19 + view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'], 20 + ) { 21 + return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>( 22 + view?.record, 23 + AppBskyFeedThreadgate.isRecord, 24 + ) 25 + ? view?.record 26 + : undefined 27 + } 28 + 29 + export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) { 30 + if ( 31 + bsky.dangerousIsType<AppBskyFeedPost.Record>( 32 + post.record, 33 + AppBskyFeedPost.isRecord, 34 + ) 35 + ) { 36 + if (post.record.reply?.root?.uri) { 37 + return new AtUri(post.record.reply.root.uri) 38 + } 39 + } 40 + } 41 + 42 + export function getPostRecord(post: AppBskyFeedDefs.PostView) { 43 + return post.record as AppBskyFeedPost.Record 44 + } 45 + 46 + export function getTraversalMetadata({ 47 + item, 48 + prevItem, 49 + nextItem, 50 + parentMetadata, 51 + }: { 52 + item: ApiThreadItem 53 + prevItem?: ApiThreadItem 54 + nextItem?: ApiThreadItem 55 + parentMetadata?: TraversalMetadata 56 + }): TraversalMetadata { 57 + if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { 58 + throw new Error(`Expected thread item to be a post`) 59 + } 60 + const repliesCount = item.value.post.replyCount || 0 61 + const repliesUnhydrated = item.value.moreReplies || 0 62 + const metadata = { 63 + depth: item.depth, 64 + /* 65 + * Unknown until after traversal 66 + */ 67 + isLastChild: false, 68 + /* 69 + * Unknown until after traversal 70 + */ 71 + isLastSibling: false, 72 + /* 73 + * If it's a top level reply, bc we render each top-level branch as a 74 + * separate tree, it's implicitly part of the last branch. For subsequent 75 + * replies, we'll override this after traversal. 76 + */ 77 + isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined, 78 + nextItemDepth: nextItem?.depth, 79 + parentMetadata, 80 + prevItemDepth: prevItem?.depth, 81 + /* 82 + * Unknown until after traversal 83 + */ 84 + precedesChildReadMore: false, 85 + /* 86 + * Unknown until after traversal 87 + */ 88 + followsReadMoreUp: false, 89 + postData: { 90 + uri: item.uri, 91 + authorHandle: item.value.post.author.handle, 92 + }, 93 + repliesCount, 94 + repliesUnhydrated, 95 + repliesSeenCounter: 0, 96 + repliesIndexCounter: 0, 97 + replyIndex: 0, 98 + skippedIndentIndices: new Set<number>(), 99 + } 100 + 101 + if (isDevMode()) { 102 + // @ts-ignore dev only for debugging 103 + metadata.postData.text = getPostRecord(item.value.post).text 104 + } 105 + 106 + return metadata 107 + } 108 + 109 + export function storeTraversalMetadata( 110 + metadatas: Map<string, TraversalMetadata>, 111 + metadata: TraversalMetadata, 112 + ) { 113 + metadatas.set(metadata.postData.uri, metadata) 114 + 115 + if (isDevMode()) { 116 + // @ts-ignore dev only for debugging 117 + metadatas.set(metadata.postData.text, metadata) 118 + // @ts-ignore 119 + window.__thread = metadatas 120 + } 121 + } 122 + 123 + export function getThreadPostUI({ 124 + depth, 125 + repliesCount, 126 + prevItemDepth, 127 + isLastChild, 128 + skippedIndentIndices, 129 + repliesSeenCounter, 130 + repliesUnhydrated, 131 + precedesChildReadMore, 132 + followsReadMoreUp, 133 + }: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] { 134 + const isReplyAndHasReplies = 135 + depth > 0 && 136 + repliesCount > 0 && 137 + (repliesCount - repliesUnhydrated === repliesSeenCounter || 138 + repliesSeenCounter > 0) 139 + return { 140 + isAnchor: depth === 0, 141 + showParentReplyLine: 142 + followsReadMoreUp || 143 + (!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth), 144 + showChildReplyLine: depth < 0 || isReplyAndHasReplies, 145 + indent: depth, 146 + /* 147 + * If there are no slices below this one, or the next slice has a depth <= 148 + * than the depth of this post, it's the last child of the reply tree. It 149 + * is not necessarily the last leaf in the parent branch, since it could 150 + * have another sibling. 151 + */ 152 + isLastChild, 153 + skippedIndentIndices, 154 + precedesChildReadMore: precedesChildReadMore ?? false, 155 + } 156 + } 157 + 158 + export function getThreadPostNoUnauthenticatedUI({ 159 + depth, 160 + prevItemDepth, 161 + }: { 162 + depth: number 163 + prevItemDepth?: number 164 + nextItemDepth?: number 165 + }): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] { 166 + return { 167 + showChildReplyLine: depth < 0, 168 + showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth), 169 + } 170 + }
+183
src/state/queries/usePostThread/views.ts
··· 1 + import { 2 + type $Typed, 3 + type AppBskyFeedDefs, 4 + type AppBskyFeedPost, 5 + type AppBskyUnspeccedDefs, 6 + type AppBskyUnspeccedGetPostThreadV2, 7 + AtUri, 8 + moderatePost, 9 + type ModerationOpts, 10 + } from '@atproto/api' 11 + 12 + import {makeProfileLink} from '#/lib/routes/links' 13 + import { 14 + type ApiThreadItem, 15 + type ThreadItem, 16 + type TraversalMetadata, 17 + } from '#/state/queries/usePostThread/types' 18 + 19 + export function threadPostNoUnauthenticated({ 20 + uri, 21 + depth, 22 + value, 23 + }: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> { 24 + return { 25 + type: 'threadPostNoUnauthenticated', 26 + key: uri, 27 + uri, 28 + depth, 29 + value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated, 30 + // @ts-ignore populated by the traversal 31 + ui: {}, 32 + } 33 + } 34 + 35 + export function threadPostNotFound({ 36 + uri, 37 + depth, 38 + value, 39 + }: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNotFound'}> { 40 + return { 41 + type: 'threadPostNotFound', 42 + key: uri, 43 + uri, 44 + depth, 45 + value: value as AppBskyUnspeccedDefs.ThreadItemNotFound, 46 + } 47 + } 48 + 49 + export function threadPostBlocked({ 50 + uri, 51 + depth, 52 + value, 53 + }: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostBlocked'}> { 54 + return { 55 + type: 'threadPostBlocked', 56 + key: uri, 57 + uri, 58 + depth, 59 + value: value as AppBskyUnspeccedDefs.ThreadItemBlocked, 60 + } 61 + } 62 + 63 + export function threadPost({ 64 + uri, 65 + depth, 66 + value, 67 + moderationOpts, 68 + threadgateHiddenReplies, 69 + }: { 70 + uri: string 71 + depth: number 72 + value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost> 73 + moderationOpts: ModerationOpts 74 + threadgateHiddenReplies: Set<string> 75 + }): Extract<ThreadItem, {type: 'threadPost'}> { 76 + const moderation = moderatePost(value.post, moderationOpts) 77 + const modui = moderation.ui('contentList') 78 + const blurred = modui.blur || modui.filter 79 + const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' 80 + const hiddenByThreadgate = threadgateHiddenReplies.has(uri) 81 + const isBlurred = hiddenByThreadgate || blurred || muted 82 + return { 83 + type: 'threadPost', 84 + key: uri, 85 + uri, 86 + depth, 87 + value: { 88 + ...value, 89 + /* 90 + * Do not spread anything here, load bearing for post shadow strict 91 + * equality reference checks. 92 + */ 93 + post: value.post as Omit<AppBskyFeedDefs.PostView, 'record'> & { 94 + record: AppBskyFeedPost.Record 95 + }, 96 + }, 97 + isBlurred, 98 + moderation, 99 + // @ts-ignore populated by the traversal 100 + ui: {}, 101 + } 102 + } 103 + 104 + export function readMore({ 105 + depth, 106 + repliesUnhydrated, 107 + skippedIndentIndices, 108 + postData, 109 + }: TraversalMetadata): Extract<ThreadItem, {type: 'readMore'}> { 110 + const urip = new AtUri(postData.uri) 111 + const href = makeProfileLink( 112 + { 113 + did: urip.host, 114 + handle: postData.authorHandle, 115 + }, 116 + 'post', 117 + urip.rkey, 118 + ) 119 + return { 120 + type: 'readMore' as const, 121 + key: `readMore:${postData.uri}`, 122 + href, 123 + moreReplies: repliesUnhydrated, 124 + depth, 125 + skippedIndentIndices, 126 + } 127 + } 128 + 129 + export function readMoreUp({ 130 + postData, 131 + }: TraversalMetadata): Extract<ThreadItem, {type: 'readMoreUp'}> { 132 + const urip = new AtUri(postData.uri) 133 + const href = makeProfileLink( 134 + { 135 + did: urip.host, 136 + handle: postData.authorHandle, 137 + }, 138 + 'post', 139 + urip.rkey, 140 + ) 141 + return { 142 + type: 'readMoreUp' as const, 143 + key: `readMoreUp:${postData.uri}`, 144 + href, 145 + } 146 + } 147 + 148 + export function skeleton({ 149 + key, 150 + item, 151 + }: Omit<Extract<ThreadItem, {type: 'skeleton'}>, 'type'>): Extract< 152 + ThreadItem, 153 + {type: 'skeleton'} 154 + > { 155 + return { 156 + type: 'skeleton', 157 + key, 158 + item, 159 + } 160 + } 161 + 162 + export function postViewToThreadPlaceholder( 163 + post: AppBskyFeedDefs.PostView, 164 + ): $Typed< 165 + Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & { 166 + value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost> 167 + } 168 + > { 169 + return { 170 + $type: 'app.bsky.unspecced.getPostThreadV2#threadItem', 171 + uri: post.uri, 172 + depth: 0, // reset to 0 for highlighted post 173 + value: { 174 + $type: 'app.bsky.unspecced.defs#threadItemPost', 175 + post, 176 + opThread: false, 177 + moreParents: false, 178 + moreReplies: 0, 179 + hiddenByThreadgate: false, 180 + mutedByViewer: false, 181 + }, 182 + } 183 + }
+9
src/state/shell/composer/index.tsx
··· 2 2 import { 3 3 type AppBskyActorDefs, 4 4 type AppBskyFeedDefs, 5 + type AppBskyUnspeccedGetPostThreadV2, 5 6 type ModerationDecision, 6 7 } from '@atproto/api' 7 8 import {msg} from '@lingui/macro' ··· 24 25 moderation?: ModerationDecision 25 26 } 26 27 28 + export type OnPostSuccessData = 29 + | { 30 + replyToUri?: string 31 + posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[] 32 + } 33 + | undefined 34 + 27 35 export interface ComposerOpts { 28 36 replyTo?: ComposerOptsPostRef 29 37 onPost?: (postUri: string | undefined) => void 38 + onPostSuccess?: (data: OnPostSuccessData) => void 30 39 quote?: AppBskyFeedDefs.PostView 31 40 mention?: string // handle of user to mention 32 41 openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void
+14
src/state/threadgate-hidden-replies.tsx
··· 83 83 return set 84 84 }, [uris, recentlyUnhiddenUris, threadgateRecord]) 85 85 } 86 + 87 + export function useMergeThreadgateHiddenReplies() { 88 + const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris() 89 + return React.useCallback( 90 + (threadgate?: AppBskyFeedThreadgate.Record) => { 91 + const set = new Set([...(threadgate?.hiddenReplies || []), ...uris]) 92 + for (const uri of recentlyUnhiddenUris) { 93 + set.delete(uri) 94 + } 95 + return set 96 + }, 97 + [uris, recentlyUnhiddenUris], 98 + ) 99 + }
+14
src/storage/hooks/dev-mode.ts
··· 5 5 6 6 return [devMode, setDevMode] as const 7 7 } 8 + 9 + let cachedIsDevMode: boolean | undefined 10 + /** 11 + * Does not update when toggling dev mode on or off. This util simply retrieves 12 + * the value and caches in memory indefinitely. So after an update, you'll need 13 + * to reload the app so it can pull a fresh value from storage. 14 + */ 15 + export function isDevMode() { 16 + if (__DEV__) return true 17 + if (cachedIsDevMode === undefined) { 18 + cachedIsDevMode = device.get(['devMode']) ?? false 19 + } 20 + return cachedIsDevMode 21 + }
+5
src/types/utils.ts
··· 1 + export type Literal<T, A = string> = T extends A 2 + ? string extends T 3 + ? never 4 + : T 5 + : never
+49 -9
src/view/com/composer/Composer.tsx
··· 45 45 import { 46 46 AppBskyFeedDefs, 47 47 type AppBskyFeedGetPostThread, 48 + AppBskyUnspeccedDefs, 48 49 type BskyAgent, 49 50 type RichText, 50 51 } from '@atproto/api' ··· 55 56 56 57 import * as apilib from '#/lib/api/index' 57 58 import {EmbeddingDisabledError} from '#/lib/api/resolve' 59 + import {retry} from '#/lib/async/retry' 58 60 import {until} from '#/lib/async/until' 59 61 import { 60 62 MAX_GRAPHEME_LENGTH, ··· 87 89 import {type Gif} from '#/state/queries/tenor' 88 90 import {useAgent, useSession} from '#/state/session' 89 91 import {useComposerControls} from '#/state/shell/composer' 90 - import {type ComposerOpts} from '#/state/shell/composer' 92 + import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' 91 93 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 92 94 import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' 93 95 import { ··· 152 154 export const ComposePost = ({ 153 155 replyTo, 154 156 onPost, 157 + onPostSuccess, 155 158 quote: initQuote, 156 159 mention: initMention, 157 160 openEmojiPicker, ··· 388 391 setError('') 389 392 setIsPublishing(true) 390 393 391 - let postUri 394 + let postUri: string | undefined 395 + let postSuccessData: OnPostSuccessData 392 396 try { 397 + logger.info(`composer: posting...`) 393 398 postUri = ( 394 399 await apilib.post(agent, queryClient, { 395 400 thread, ··· 398 403 langs: toPostLanguages(langPrefs.postLanguage), 399 404 }) 400 405 ).uris[0] 406 + 407 + /* 408 + * Wait for app view to have received the post(s). If this fails, it's 409 + * ok, because the post _was_ actually published above. 410 + */ 401 411 try { 402 - await whenAppViewReady(agent, postUri, res => { 403 - const postedThread = res?.data?.thread 404 - return AppBskyFeedDefs.isThreadViewPost(postedThread) 405 - }) 412 + if (postUri) { 413 + logger.info(`composer: waiting for app view`) 414 + 415 + const posts = await retry( 416 + 5, 417 + _e => true, 418 + async () => { 419 + const res = await agent.app.bsky.unspecced.getPostThreadV2({ 420 + anchor: postUri!, 421 + above: false, 422 + below: thread.posts.length - 1, 423 + branchingFactor: 1, 424 + }) 425 + if (res.data.thread.length !== thread.posts.length) { 426 + throw new Error(`composer: app view is not ready`) 427 + } 428 + if ( 429 + !res.data.thread.every(p => 430 + AppBskyUnspeccedDefs.isThreadItemPost(p.value), 431 + ) 432 + ) { 433 + throw new Error(`composer: app view returned non-post items`) 434 + } 435 + return res.data.thread 436 + }, 437 + 1e3, 438 + ) 439 + postSuccessData = { 440 + replyToUri: replyTo?.uri, 441 + posts, 442 + } 443 + } 406 444 } catch (waitErr: any) { 407 - logger.error(waitErr, { 408 - message: `Waiting for app view failed`, 445 + logger.info(`composer: waiting for app view failed`, { 446 + safeMessage: waitErr, 409 447 }) 410 - // Keep going because the post *was* published. 411 448 } 412 449 } catch (e: any) { 413 450 logger.error(e, { ··· 465 502 quotedThread.post.quoteCount !== initQuote.quoteCount 466 503 ) { 467 504 onPost?.(postUri) 505 + onPostSuccess?.(postSuccessData) 468 506 return true 469 507 } 470 508 return false 471 509 }) 472 510 } else { 473 511 onPost?.(postUri) 512 + onPostSuccess?.(postSuccessData) 474 513 } 475 514 onClose() 476 515 Toast.show( ··· 489 528 langPrefs.postLanguage, 490 529 onClose, 491 530 onPost, 531 + onPostSuccess, 492 532 initQuote, 493 533 replyTo, 494 534 setLangPrefs,
+19 -25
src/view/com/post-thread/PostThread.tsx
··· 1 1 import React, {memo, useRef, useState} from 'react' 2 - import {StyleSheet, useWindowDimensions, View} from 'react-native' 3 - import {runOnJS} from 'react-native-reanimated' 2 + import {useWindowDimensions, View} from 'react-native' 3 + import {runOnJS, useAnimatedStyle} from 'react-native-reanimated' 4 4 import Animated from 'react-native-reanimated' 5 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 5 import { 7 6 AppBskyFeedDefs, 8 7 type AppBskyFeedThreadgate, ··· 13 12 14 13 import {HITSLOP_10} from '#/lib/constants' 15 14 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 16 - import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' 17 15 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 18 16 import {useSetTitle} from '#/lib/hooks/useSetTitle' 19 17 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 20 - import {clamp} from '#/lib/numbers' 21 18 import {ScrollProvider} from '#/lib/ScrollContext' 22 19 import {sanitizeDisplayName} from '#/lib/strings/display-names' 23 20 import {cleanError} from '#/lib/strings/errors' ··· 37 34 import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences' 38 35 import {usePreferencesQuery} from '#/state/queries/preferences' 39 36 import {useSession} from '#/state/session' 37 + import {useShellLayout} from '#/state/shell/shell-layout' 40 38 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 41 39 import {useUnstablePostSource} from '#/state/unstable-post-source' 42 40 import {List, type ListMethods} from '#/view/com/util/List' ··· 301 299 // maintainVisibleContentPosition and onContentSizeChange 302 300 // to "hold onto" the correct row instead of the first one. 303 301 302 + /* 303 + * This is basically `!!parents.length`, see notes on `isParentLoading` 304 + */ 304 305 if (!highlightedPost.ctx.isParentLoading && !deferParents) { 305 306 // When progressively revealing parents, rendering a placeholder 306 307 // here will cause scrolling jumps. Don't add it unless you test it. 307 308 // QT'ing this thread is a great way to test all the scrolling hacks: 308 - // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o 309 + // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o 309 310 310 311 // Everything is loaded 311 312 let startIndex = Math.max(0, parents.length - maxParents) ··· 581 582 onEndReached={onEndReached} 582 583 onEndReachedThreshold={2} 583 584 onScrollToTop={onScrollToTop} 585 + /** 586 + * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition 587 + */ 584 588 maintainVisibleContentPosition={ 585 589 isNative && hasParents 586 590 ? MAINTAIN_VISIBLE_CONTENT_POSITION ··· 729 733 ThreadMenu = memo(ThreadMenu) 730 734 731 735 function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 732 - const safeAreaInsets = useSafeAreaInsets() 733 - const fabMinimalShellTransform = useMinimalShellFabTransform() 736 + const {footerHeight} = useShellLayout() 737 + 738 + const animatedStyle = useAnimatedStyle(() => { 739 + return { 740 + bottom: footerHeight.get(), 741 + } 742 + }) 743 + 734 744 return ( 735 - <Animated.View 736 - style={[ 737 - styles.prompt, 738 - fabMinimalShellTransform, 739 - { 740 - bottom: clamp(safeAreaInsets.bottom, 13, 60), 741 - }, 742 - ]}> 745 + <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 743 746 <PostThreadComposePrompt onPressCompose={onPressReply} /> 744 747 </Animated.View> 745 748 ) ··· 904 907 } 905 908 return true 906 909 } 907 - 908 - const styles = StyleSheet.create({ 909 - prompt: { 910 - // @ts-ignore web-only 911 - position: isWeb ? 'fixed' : 'absolute', 912 - left: 0, 913 - right: 0, 914 - }, 915 - })
+51 -25
src/view/com/post-thread/PostThreadComposePrompt.tsx
··· 1 - import {View} from 'react-native' 1 + import {type StyleProp, View, type ViewStyle} from 'react-native' 2 + import {LinearGradient} from 'expo-linear-gradient' 2 3 import {msg, Trans} from '@lingui/macro' 3 4 import {useLingui} from '@lingui/react' 4 5 5 6 import {PressableScale} from '#/lib/custom-animations/PressableScale' 6 7 import {useHaptics} from '#/lib/haptics' 8 + import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' 7 9 import {useProfileQuery} from '#/state/queries/profile' 8 10 import {useSession} from '#/state/session' 9 11 import {UserAvatar} from '#/view/com/util/UserAvatar' 10 - import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf' 12 + import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf' 13 + import {transparentifyColor} from '#/alf/util/colorGeneration' 11 14 import {useInteractionState} from '#/components/hooks/useInteractionState' 12 15 import {Text} from '#/components/Typography' 13 16 14 17 export function PostThreadComposePrompt({ 15 18 onPressCompose, 19 + style, 16 20 }: { 17 21 onPressCompose: () => void 22 + style?: StyleProp<ViewStyle> 18 23 }) { 19 24 const {currentAccount} = useSession() 20 25 const {data: profile} = useProfileQuery({did: currentAccount?.did}) ··· 28 33 onOut: onHoverOut, 29 34 } = useInteractionState() 30 35 36 + useHideBottomBarBorderForScreen() 37 + 31 38 return ( 32 - <PressableScale 33 - accessibilityRole="button" 34 - accessibilityLabel={_(msg`Compose reply`)} 35 - accessibilityHint={_(msg`Opens composer`)} 39 + <View 36 40 style={[ 37 - gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11}, 38 - a.px_sm, 39 - a.border_t, 40 - t.atoms.border_contrast_low, 41 - t.atoms.bg, 42 - ]} 43 - onPress={() => { 44 - onPressCompose() 45 - playHaptic('Light') 46 - }} 47 - onLongPress={ios(() => { 48 - onPressCompose() 49 - playHaptic('Heavy') 50 - })} 51 - onHoverIn={onHoverIn} 52 - onHoverOut={onHoverOut}> 53 - <View 41 + gtMobile 42 + ? [ 43 + a.py_xs, 44 + a.px_sm, 45 + a.border_t, 46 + t.atoms.border_contrast_low, 47 + t.atoms.bg, 48 + ] 49 + : [a.px_md, a.pb_2xs], 50 + style, 51 + ]}> 52 + {!gtMobile && ( 53 + <LinearGradient 54 + key={t.name} // android does not update when you change the colors. sigh. 55 + start={[0.5, 0]} 56 + end={[0.5, 1]} 57 + colors={[ 58 + transparentifyColor(t.atoms.bg.backgroundColor, 0), 59 + t.atoms.bg.backgroundColor, 60 + ]} 61 + locations={[0.15, 0.4]} 62 + style={[a.absolute, a.inset_0]} 63 + /> 64 + )} 65 + <PressableScale 66 + accessibilityRole="button" 67 + accessibilityLabel={_(msg`Compose reply`)} 68 + accessibilityHint={_(msg`Opens composer`)} 69 + onPress={() => { 70 + onPressCompose() 71 + playHaptic('Light') 72 + }} 73 + onLongPress={ios(() => { 74 + onPressCompose() 75 + playHaptic('Heavy') 76 + })} 77 + onHoverIn={onHoverIn} 78 + onHoverOut={onHoverOut} 54 79 style={[ 55 80 a.flex_row, 56 81 a.align_center, ··· 58 83 a.gap_sm, 59 84 a.rounded_full, 60 85 (!gtMobile || hovered) && t.atoms.bg_contrast_25, 86 + native([a.border, t.atoms.border_contrast_low]), 61 87 a.transition_color, 62 88 ]}> 63 89 <UserAvatar ··· 68 94 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 69 95 <Trans>Write your reply</Trans> 70 96 </Text> 71 - </View> 72 - </PressableScale> 97 + </PressableScale> 98 + </View> 73 99 ) 74 100 }
+7
src/view/com/post-thread/PostThreadItem.tsx
··· 39 39 import {useLanguagePrefs} from '#/state/preferences' 40 40 import {type ThreadPost} from '#/state/queries/post-thread' 41 41 import {useSession} from '#/state/session' 42 + import {type OnPostSuccessData} from '#/state/shell/composer' 42 43 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 43 44 import {type PostSource} from '#/state/unstable-post-source' 44 45 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' ··· 85 86 hasPrecedingItem, 86 87 overrideBlur, 87 88 onPostReply, 89 + onPostSuccess, 88 90 hideTopBorder, 89 91 threadgateRecord, 90 92 anchorPostSource, ··· 103 105 hasPrecedingItem: boolean 104 106 overrideBlur: boolean 105 107 onPostReply: (postUri: string | undefined) => void 108 + onPostSuccess?: (data: OnPostSuccessData) => void 106 109 hideTopBorder?: boolean 107 110 threadgateRecord?: AppBskyFeedThreadgate.Record 108 111 anchorPostSource?: PostSource ··· 139 142 hasPrecedingItem={hasPrecedingItem} 140 143 overrideBlur={overrideBlur} 141 144 onPostReply={onPostReply} 145 + onPostSuccess={onPostSuccess} 142 146 hideTopBorder={hideTopBorder} 143 147 threadgateRecord={threadgateRecord} 144 148 anchorPostSource={anchorPostSource} ··· 185 189 hasPrecedingItem, 186 190 overrideBlur, 187 191 onPostReply, 192 + onPostSuccess, 188 193 hideTopBorder, 189 194 threadgateRecord, 190 195 anchorPostSource, ··· 204 209 hasPrecedingItem: boolean 205 210 overrideBlur: boolean 206 211 onPostReply: (postUri: string | undefined) => void 212 + onPostSuccess?: (data: OnPostSuccessData) => void 207 213 hideTopBorder?: boolean 208 214 threadgateRecord?: AppBskyFeedThreadgate.Record 209 215 anchorPostSource?: PostSource ··· 298 304 moderation, 299 305 }, 300 306 onPost: onPostReply, 307 + onPostSuccess: onPostSuccess, 301 308 }) 302 309 } 303 310
+14 -4
src/view/screens/PostThread.tsx
··· 1 - import React from 'react' 1 + import {useCallback} from 'react' 2 2 import {useFocusEffect} from '@react-navigation/native' 3 3 4 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 4 + import { 5 + type CommonNavigatorParams, 6 + type NativeStackScreenProps, 7 + } from '#/lib/routes/types' 8 + import {useGate} from '#/lib/statsig/statsig' 5 9 import {makeRecordUri} from '#/lib/strings/url-helpers' 6 10 import {useSetMinimalShellMode} from '#/state/shell' 7 11 import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' 12 + import {PostThread} from '#/screens/PostThread' 8 13 import * as Layout from '#/components/Layout' 9 14 10 15 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> 11 16 export function PostThreadScreen({route}: Props) { 12 17 const setMinimalShellMode = useSetMinimalShellMode() 18 + const gate = useGate() 13 19 14 20 const {name, rkey} = route.params 15 21 const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) 16 22 17 23 useFocusEffect( 18 - React.useCallback(() => { 24 + useCallback(() => { 19 25 setMinimalShellMode(false) 20 26 }, [setMinimalShellMode]), 21 27 ) 22 28 23 29 return ( 24 30 <Layout.Screen testID="postThreadScreen"> 25 - <PostThreadComponent uri={uri} /> 31 + {gate('post_threads_v2_unspecced') || __DEV__ ? ( 32 + <PostThread uri={uri} /> 33 + ) : ( 34 + <PostThreadComponent uri={uri} /> 35 + )} 26 36 </Layout.Screen> 27 37 ) 28 38 }
+1
src/view/shell/Composer.ios.tsx
··· 37 37 cancelRef={ref} 38 38 replyTo={state?.replyTo} 39 39 onPost={state?.onPost} 40 + onPostSuccess={state?.onPostSuccess} 40 41 quote={state?.quote} 41 42 mention={state?.mention} 42 43 text={state?.text}
+1
src/view/shell/Composer.tsx
··· 49 49 <ComposePost 50 50 replyTo={state.replyTo} 51 51 onPost={state.onPost} 52 + onPostSuccess={state.onPostSuccess} 52 53 quote={state.quote} 53 54 mention={state.mention} 54 55 text={state.text}
+1
src/view/shell/Composer.web.tsx
··· 105 105 replyTo={state.replyTo} 106 106 quote={state.quote} 107 107 onPost={state.onPost} 108 + onPostSuccess={state.onPostSuccess} 108 109 mention={state.mention} 109 110 openEmojiPicker={onOpenPicker} 110 111 text={state.text}
+3 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 12 12 import {BOTTOM_BAR_AVI} from '#/lib/demo' 13 13 import {useHaptics} from '#/lib/haptics' 14 14 import {useDedupe} from '#/lib/hooks/useDedupe' 15 + import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' 15 16 import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' 16 17 import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' 17 18 import {usePalette} from '#/lib/hooks/usePalette' ··· 73 74 const playHaptic = useHaptics() 74 75 const hasHomeBadge = useHomeBadge() 75 76 const gate = useGate() 77 + const hideBorder = useHideBottomBarBorder() 76 78 const iconWidth = 28 77 79 78 80 const showSignIn = useCallback(() => { ··· 146 148 style={[ 147 149 styles.bottomBar, 148 150 pal.view, 149 - pal.border, 151 + hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border, 150 152 {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)}, 151 153 footerMinimalShellTransform, 152 154 ]}
+10 -3
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 import {useNavigationState} from '@react-navigation/native' 7 7 8 + import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' 8 9 import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' 9 10 import {getCurrentRoute, isTab} from '#/lib/routes/helpers' 10 11 import {makeProfileLink} from '#/lib/routes/links' 11 - import {CommonNavigatorParams} from '#/lib/routes/types' 12 + import {type CommonNavigatorParams} from '#/lib/routes/types' 12 13 import {useGate} from '#/lib/statsig/statsig' 13 14 import {useHomeBadge} from '#/state/home-badge' 14 15 import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' 15 16 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 16 17 import {useSession} from '#/state/session' 17 18 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 19 + import {useShellLayout} from '#/state/shell/shell-layout' 18 20 import {useCloseAllActiveElements} from '#/state/util' 19 21 import {Link} from '#/view/com/util/Link' 20 22 import {Logo} from '#/view/icons/Logo' ··· 49 51 const footerMinimalShellTransform = useMinimalShellFooterTransform() 50 52 const {requestSwitchToAccount} = useLoggedOutViewControls() 51 53 const closeAllActiveElements = useCloseAllActiveElements() 54 + const {footerHeight} = useShellLayout() 55 + const hideBorder = useHideBottomBarBorder() 52 56 const iconWidth = 26 53 57 54 58 const unreadMessageCount = useUnreadMessageCount() ··· 74 78 styles.bottomBar, 75 79 styles.bottomBarWeb, 76 80 t.atoms.bg, 77 - t.atoms.border_contrast_low, 81 + hideBorder 82 + ? {borderColor: t.atoms.bg.backgroundColor} 83 + : t.atoms.border_contrast_low, 78 84 footerMinimalShellTransform, 79 - ]}> 85 + ]} 86 + onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}> 80 87 {hasSession ? ( 81 88 <> 82 89 <NavItem
+14
yarn.lock
··· 63 63 "@atproto/xrpc" "^0.7.0" 64 64 "@atproto/xrpc-server" "^0.7.18" 65 65 66 + "@atproto/api@^0.15.14": 67 + version "0.15.14" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.14.tgz#41ff6ce2e7603119a005b7b5ce8e64551ec84879" 69 + integrity sha512-FHEMAdscG+r2OFcZUIzPyTDpwzRAyinRsIIaTcuqe0MgZWF4CEGNAKPos0IbecBzMxTOzUHE18dQDKhoXMdgvg== 70 + dependencies: 71 + "@atproto/common-web" "^0.4.2" 72 + "@atproto/lexicon" "^0.4.11" 73 + "@atproto/syntax" "^0.4.0" 74 + "@atproto/xrpc" "^0.7.0" 75 + await-lock "^2.2.2" 76 + multiformats "^9.9.0" 77 + tlds "^1.234.0" 78 + zod "^3.23.8" 79 + 66 80 "@atproto/api@^0.15.9": 67 81 version "0.15.9" 68 82 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.9.tgz#f8c40afd6e414ab107d63d6f08d9e264bf9a149a"