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

* WIP

* Sorting working

* Rough handling of hidden/muted

* Better muted/hidden sorting and handling

* Clarify some naming

* Fix parents

* Handle first reply under highlighted/composer

* WIP RaW

* WIP optimistic

* Optimistic WIP

* Little cleanup, inserting dupes

* Re-org

* Add in new optimistic insert logic

* Update types

* Sorta working linear view optimistic state

* Simple working version, no pref for OP

* Working optimistic reply insertions, preference for OP

* Ensure deletes are coming through

* WIP scroll handling

* WIP scroll tweaks

* Clean up scrolling

* Clean up onPostSuccess

* Add annotations

* Fix highlighted post calc

* WIP kill me

* Update APIs

* Nvm don't kill me

* Fix optimistic insert

* Handle read more cases in tree view

* Basically working read more

* Handle linear view

* Reorg

* More reorg

* Split up thread post components

* New reply tree layout

* Fix up traversal metadata

* Tighten some spacing

* Use indent ya idiot

* Some linear mode cleanup

* Fix lines on read more items

* Vibe coding to success

* Almost there with read mores

* Update APIs

* Bump sdk

* Update import

* Checkpoint new traversal

* Checkpoint cleanup

* Checkpoint, need to fix blocked posts

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

* Clean it up

* Two passes only

* Set to default params, update comment

* Fix render bug on native

* Checkpoint parent rendering, can opt for slower handling here

* Clean up parent handling, reply handling

* Fix read more extra space

* Fix read more in linear view

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

* Update naming

* Rename Slice to ThreadItem

* Add basic post and anchor skeletons

* Refactor client-side hidden

* WIP hidden fetching

* Update types

* Clean up query a bit

* Scrolling still broken

* Ok maybe fix scrolling

* Checkpoint move state into meta query

* Don't load remote hidden items unless needed

* skeleton view

* Reset hidden items when params change

* Split up traversal and avoid multiple passes

* Clean up

* Checkpoint: handling exhausted replies

* Clean up traversal functions further

* Clean up pagination

* Limit optimistic reply depth

* Handle optimistic insert in hidden replies

* Share root query key for easier cache extraction

* Make blurred posts not look like ass

* Fix double deleted item

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

* Fix parents traversal 4 real

* Rename tree post

* Make optimistic deletions of linear posts not look bad

* Rename linear post components

* Handle tombstone views

* Rename read more component

* Add moreParents handling

* Align interaction states of read more

* Fix read more on FF

* Tree view skeleton

* Reply composer skele

* Remove hack for showing more replies

* Checkpoint: sort change scrolling fixed

* Checkpoint: learned new things, reset to base

* Feature gate

* Rename

* Replace show more

* Update settings screen

* Update pkg and endpoint

* Remove console

* Eureka

* Cleanup last commit

* No tests atm

* Remove scroll provider

* Clean up callbacks, better error state

* Remove todo

* Remove todo

* Remove todos

* Format

* Ok I think scrolling is solid

* Add back mobile compose input

* Ok need to compute headerHeight every time

* Update comments

* Ok button up web too

* Threads v2 tweaks (#8467)

* fix error screen collapsing

* use personx icon for blocked posts

* Remove height/width

* Revert unused Header change

* Clarify code

* Relate consts to theme values

* Remove debug code

* Typo

* Fix debounce of threads prefs

* Update metadata comments, dev mode

* Missed a spot

* Clean up todo

* Fix up no-unauthenticated posts

* Truncate parents if no-unauth

* Update getBranch docs

* Remove debug code

* Expand fetching in some cases

* Clear scroll need for root post to fix jump bug

* Fix reply composer skeleton state

* Remove uneeded initialized value

* Add profile shadow cache

* Some metrics

* prettier tweak

* eslint ignore

* Fix optimistic insertion

* Typo

* Rename, comment

* Remove wait

* Counter naming

* Replies seen counter for moderated sub-trees

* Remove borders on skeleton

* Align tombstone with optimistic deletion state

* Fix optimistic deletion for thread

* Add tree view icon

* Rename

* Cleanup

* Update settings copy

* Header menu open metric

* Bump package

* Better reply prompt (#8474)

* restyle reply prompt

* hide bottom bar border for cleaner look

* use new border hiding hook in DMs

* create `transparentifyColor` function

* adjust padding

* fix padding in immersive lpayer

* Apply suggestions from code review

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

* Integrate post-source

(cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9)

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Eric Bailey 2025-06-11 14:32:14 -05:00 committed by GitHub
parent 143d5f3b81
commit 61004b887b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 5416 additions and 86 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z"/></svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z"/></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a3 3 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.15.9",
"@atproto/api": "^0.15.14",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -72,6 +72,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
@ -150,14 +151,16 @@ function InnerApp() {
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>

View File

@ -61,6 +61,7 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
/**
* Begin geolocation ASAP
@ -131,10 +132,12 @@ function InnerApp() {
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>

View File

@ -1051,4 +1051,8 @@ export const atoms = {
transform: [],
},
}) as {transform: Exclude<ViewStyle['transform'], string | undefined>},
pointer: web({
cursor: 'pointer',
}),
} as const

View File

@ -0,0 +1,48 @@
import {jest} from '@jest/globals'
import {logger} from '#/logger'
import {transparentifyColor} from '../colorGeneration'
jest.mock('#/logger', () => ({
logger: {warn: jest.fn()},
}))
describe('transparentifyColor', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('converts hsl() to hsla()', () => {
const result = transparentifyColor('hsl(120 100% 50%)', 0.5)
expect(result).toBe('hsla(120 100% 50%, 0.5)')
})
it('converts hsl() to hsla() - fully transparent', () => {
const result = transparentifyColor('hsl(120 100% 50%)', 0)
expect(result).toBe('hsla(120 100% 50%, 0)')
})
it('converts rgb() to rgba()', () => {
const result = transparentifyColor('rgb(255 0 0)', 0.75)
expect(result).toBe('rgba(255 0 0, 0.75)')
})
it('expands 3-digit hex and appends alpha channel', () => {
const result = transparentifyColor('#abc', 0.4)
expect(result).toBe('#aabbcc66')
})
it('appends alpha to 6-digit hex', () => {
const result = transparentifyColor('#aabbcc', 0.4)
expect(result).toBe('#aabbcc66')
})
it('returns the original string and warns for unsupported formats', () => {
const unsupported = 'blue'
const result = transparentifyColor(unsupported, 0.5)
expect(result).toBe(unsupported)
expect(logger.warn).toHaveBeenCalledWith(
`Could not make '${unsupported}' transparent`,
)
})
})

View File

@ -1,3 +1,5 @@
import {logger} from '#/logger'
export const BLUE_HUE = 211
export const RED_HUE = 346
export const GREEN_HUE = 152
@ -19,3 +21,29 @@ export function generateScale(start: number, end: number) {
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export function transparentifyColor(color: string, alpha: number) {
if (color.startsWith('hsl(')) {
return 'hsla(' + color.slice('hsl('.length, -1) + `, ${alpha})`
} else if (color.startsWith('rgb(')) {
return 'rgba(' + color.slice('rgb('.length, -1) + `, ${alpha})`
} else if (color.startsWith('#')) {
if (color.length === 7) {
const alphaHex = Math.round(alpha * 255).toString(16)
// Per MDN: If there is only one number, it is duplicated: e means ee
// https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
return color.slice(0, 7) + alphaHex.padStart(2, alphaHex)
} else if (color.length === 4) {
// convert to 6-digit hex before adding alpha
const [r, g, b] = color.slice(1).split('')
const alphaHex = Math.round(alpha * 255).toString(16)
return `#${r.repeat(2)}${g.repeat(2)}${b.repeat(2)}${alphaHex.padStart(
2,
alphaHex,
)}`
}
} else {
logger.warn(`Could not make '${color}' transparent`)
}
return color
}

107
src/components/Skeleton.tsx Normal file
View File

@ -0,0 +1,107 @@
import {type ReactNode} from 'react'
import {View} from 'react-native'
import {
atoms as a,
flatten,
type TextStyleProp,
useAlf,
useTheme,
type ViewStyleProp,
} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
type SkeletonProps = {
blend?: boolean
}
export function Text({blend, style}: TextStyleProp & SkeletonProps) {
const {fonts, flags, theme: t} = useAlf()
const {width, ...flattened} = flatten(style)
const {lineHeight = 14, ...rest} = normalizeTextStyles(
[a.text_sm, a.leading_snug, flattened],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
},
)
return (
<View
style={[a.flex_1, {maxWidth: width, paddingVertical: lineHeight * 0.15}]}>
<View
style={[
a.rounded_md,
t.atoms.bg_contrast_25,
{
height: lineHeight * 0.7,
opacity: blend ? 0.6 : 1,
},
rest,
]}
/>
</View>
)
}
export function Circle({
children,
size,
blend,
style,
}: ViewStyleProp & {children?: ReactNode; size: number} & SkeletonProps) {
const t = useTheme()
return (
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_25,
{
width: size,
height: size,
opacity: blend ? 0.6 : 1,
},
style,
]}>
{children}
</View>
)
}
export function Pill({
size,
blend,
style,
}: ViewStyleProp & {size: number} & SkeletonProps) {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_25,
{
width: size * 1.618,
height: size,
opacity: blend ? 0.6 : 1,
},
style,
]}
/>
)
}
export function Col({
children,
style,
}: ViewStyleProp & {children?: React.ReactNode}) {
return <View style={[a.flex_1, style]}>{children}</View>
}
export function Row({
children,
style,
}: ViewStyleProp & {children?: React.ReactNode}) {
return <View style={[a.flex_row, style]}>{children}</View>
}

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ArrowTopCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm-.63 3.225a1 1 0 0 1 1.337.068l3 3 .068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 10.414V16a1 1 0 1 1-2 0v-5.586l-1.293 1.293a1 1 0 1 1-1.414-1.414l3-3 .076-.068Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CirclePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16Zm0 3a1 1 0 0 1 1 1v3h3l.102.005a1 1 0 0 1 0 1.99L16 13h-3v3a1 1 0 1 1-2 0v-3H8a1 1 0 0 1 0-2h3V8a1 1 0 0 1 1-1Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Tree_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6 2a2.998 2.998 0 0 1 1 5.825V8a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a3.98 3.98 0 0 1-2-.537V16a2 2 0 0 0 2 2h1.174c.412-1.165 1.52-2 2.826-2h5a3 3 0 1 1 0 6h-5a2.998 2.998 0 0 1-2.826-2H9a4 4 0 0 1-4-4V7.825A2.998 2.998 0 0 1 6 2Zm7 16a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Zm0-8a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5ZM6 4a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z',
})

View File

@ -1,17 +1,22 @@
import {timeout} from '#/lib/async/timeout'
import {isNetworkError} from '#/lib/strings/errors'
export async function retry<P>(
retries: number,
cond: (err: any) => boolean,
fn: () => Promise<P>,
shouldRetry: (err: any) => boolean,
action: () => Promise<P>,
delay?: number,
): Promise<P> {
let lastErr
while (retries > 0) {
try {
return await fn()
return await action()
} catch (e: any) {
lastErr = e
if (cond(e)) {
if (shouldRetry(e)) {
if (delay) {
await timeout(delay)
}
retries--
continue
}

View File

@ -0,0 +1,20 @@
import {useCallback} from 'react'
export enum OnceKey {
PreferencesThread = 'preferences:thread',
}
const called: Record<OnceKey, boolean> = {
[OnceKey.PreferencesThread]: false,
}
export function useCallOnce(key: OnceKey) {
return useCallback(
(cb: () => void) => {
if (called[key] === true) return
called[key] = true
cb()
},
[key],
)
}

View File

@ -0,0 +1,50 @@
import {createContext, useCallback, useContext, useState} from 'react'
import {useFocusEffect} from '@react-navigation/native'
type HideBottomBarBorderSetter = () => () => void
const HideBottomBarBorderContext = createContext<boolean>(false)
const HideBottomBarBorderSetterContext =
createContext<HideBottomBarBorderSetter | null>(null)
export function useHideBottomBarBorderSetter() {
const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
if (!hideBottomBarBorder) {
throw new Error(
'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
)
}
return hideBottomBarBorder
}
export function useHideBottomBarBorderForScreen() {
const hideBorder = useHideBottomBarBorderSetter()
useFocusEffect(
useCallback(() => {
const cleanup = hideBorder()
return () => cleanup()
}, [hideBorder]),
)
}
export function useHideBottomBarBorder() {
return useContext(HideBottomBarBorderContext)
}
export function Provider({children}: {children: React.ReactNode}) {
const [refCount, setRefCount] = useState(0)
const setter = useCallback(() => {
setRefCount(prev => prev + 1)
return () => setRefCount(prev => prev - 1)
}, [])
return (
<HideBottomBarBorderSetterContext.Provider value={setter}>
<HideBottomBarBorderContext.Provider value={refCount > 0}>
{children}
</HideBottomBarBorderContext.Provider>
</HideBottomBarBorderSetterContext.Provider>
)
}

View File

@ -6,6 +6,7 @@ export type Gate =
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'

View File

@ -434,4 +434,13 @@ export type MetricEvents = {
'share:press:dmSelected': {}
'share:press:recentDm': {}
'share:press:embed': {}
'thread:click:showOtherReplies': {}
'thread:preferences:load': {
[key: string]: any
}
'thread:preferences:update': {
[key: string]: any
}
'thread:click:headerMenuOpen': {}
}

View File

@ -16,6 +16,7 @@ import {
RichText,
} from '@atproto/api'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
@ -106,6 +107,8 @@ export function MessagesList({
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
useHideBottomBarBorderForScreen()
const flatListRef = useAnimatedRef<ListMethods>()
const [newMessagesPill, setNewMessagesPill] = useState({

View File

@ -0,0 +1,106 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {logger} from '#/logger'
import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
import {Button, ButtonIcon} from '#/components/Button'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import * as Menu from '#/components/Menu'
export function HeaderDropdown({
sort,
view,
setSort,
setView,
}: Pick<
ThreadPreferences,
'sort' | 'setSort' | 'view' | 'setView'
>): React.ReactNode {
const {_} = useLingui()
return (
<Menu.Root>
<Menu.Trigger label={_(msg`Thread options`)}>
{({props: {onPress, ...props}}) => (
<Button
label={_(msg`Thread options`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_10}
onPress={() => {
logger.metric('thread:click:headerMenuOpen', {})
onPress()
}}
{...props}>
<ButtonIcon icon={SettingsSlider} size="md" />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Show replies as</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Linear`)}
onPress={() => {
setView('linear')
}}>
<Menu.ItemText>
<Trans>Linear</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'linear'} />
</Menu.Item>
<Menu.Item
label={_(msg`Threaded`)}
onPress={() => {
setView('tree')
}}>
<Menu.ItemText>
<Trans>Threaded</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={view === 'tree'} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.LabelText>
<Trans>Reply sorting</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`Top replies first`)}
onPress={() => {
setSort('top')
}}>
<Menu.ItemText>
<Trans>Top replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'top'} />
</Menu.Item>
<Menu.Item
label={_(msg`Oldest replies first`)}
onPress={() => {
setSort('oldest')
}}>
<Menu.ItemText>
<Trans>Oldest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'oldest'} />
</Menu.Item>
<Menu.Item
label={_(msg`Newest replies first`)}
onPress={() => {
setSort('newest')
}}>
<Menu.ItemText>
<Trans>Newest replies first</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={sort === 'newest'} />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}

View File

@ -0,0 +1,89 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function ThreadError({
error,
onRetry,
}: {
error: Error
onRetry: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const cleanError = useCleanError()
const {title, message} = useMemo(() => {
let title = _(msg`Error loading post`)
let message = _(msg`Something went wrong. Please try again in a moment.`)
const {raw, clean} = cleanError(error)
if (error.message.startsWith('Post not found')) {
title = _(msg`Post not found`)
message = clean || raw || message
}
return {title, message}
}, [_, error, cleanError])
return (
<Layout.Center>
<View
style={[
a.w_full,
a.align_center,
{
padding: OUTER_SPACE,
paddingTop: OUTER_SPACE * 2,
},
]}>
<View
style={[
a.w_full,
a.align_center,
a.gap_xl,
{
maxWidth: 260,
},
]}>
<View style={[a.gap_xs]}>
<Text
style={[a.text_center, a.text_lg, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{message}
</Text>
</View>
<Button
label={_(msg`Retry`)}
size="small"
variant="solid"
color="secondary_inverted"
onPress={onRetry}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} position="right" />
</Button>
</View>
</View>
</Layout.Center>
)
}

View File

@ -0,0 +1,706 @@
import {memo, useCallback, useMemo} from 'react'
import {type GestureResponderEvent, Text as RNText, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {niceDate} from '#/lib/strings/time'
import {s} from '#/lib/styles'
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
import {logger} from '#/logger'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
import {Link} from '#/view/com/util/Link'
import {formatCount} from '#/view/com/util/numeric/format'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {colors} from '#/components/Admonition'
import {Button} from '#/components/Button'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {InlineLinkText} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {type AppModerationCause} from '#/components/Pills'
import {PostControls} from '#/components/PostControls'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
item,
onPostSuccess,
threadgateRecord,
postSource,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
postSource?: PostSource
}) {
const postShadow = usePostShadow(item.value.post)
const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri
const isRoot = threadRootUri === item.uri
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemAnchorDeleted isRoot={isRoot} />
}
return (
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
)
}
function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
style={[
{
paddingHorizontal: OUTER_SPACE,
paddingBottom: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View
style={[
a.flex_row,
a.align_center,
a.py_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
</View>
</>
)
}
function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) {
const t = useTheme()
return !isRoot ? (
<View style={[a.pl_lg, a.flex_row, a.pb_xs, {height: a.pt_lg.paddingTop}]}>
<View style={{width: 42}}>
<View
style={[
{
width: REPLY_LINE_WIDTH,
marginLeft: 'auto',
marginRight: 'auto',
flexGrow: 1,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
) : null
}
const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
item,
isRoot,
postShadow,
onPostSuccess,
threadgateRecord,
postSource,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
isRoot: boolean
postShadow: Shadow<AppBskyFeedDefs.PostView>
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
postSource?: PostSource
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount, hasSession} = useSession()
const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const authorShadow = useProfileShadow(post.author)
const {isActive: live} = useActorStatus(post.author)
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const threadRootUri = record.reply?.root?.uri || post.uri
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
}, [post.uri, post.author])
const repostsHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const quotesHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onlyFollowersCanReply = !!threadgateRecord?.allow?.find(
rule => rule.$type === 'app.bsky.feed.threadgate#followerRule',
)
const showFollowButton =
currentAccount?.did !== post.author.did && !onlyFollowersCanReply
const viaRepost = useMemo(() => {
const reason = postSource?.post.reason
if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) {
return {
uri: reason.uri,
cid: reason.cid,
}
}
}, [postSource])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}, [
openComposer,
post,
record,
onPostSuccess,
moderation,
postSource,
feedFeedback,
])
const onOpenAuthor = () => {
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughAuthor',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}
const onOpenEmbed = () => {
if (postSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughEmbed',
feedContext: postSource.post.feedContext,
reqId: postSource.post.reqId,
})
}
}
return (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Link
style={[a.flex_shrink]}
href={authorHref}
title={authorTitle}
onBeforePress={onOpenAuthor}>
<Text
emoji
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
<View style={[{paddingLeft: 3, top: -1}]}>
<VerificationCheckButton profile={authorShadow} size="md" />
</View>
</View>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
{showFollowButton && (
<View>
<PostThreadFollowBtn did={post.author.did} />
</View>
)}
</View>
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{post.embed && (
<View style={[a.py_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
/>
</View>
)}
</ContentHider>
<ExpandedPostDetails
post={item.value.post}
isThreadAuthor={isThreadAuthor}
/>
{post.repostCount !== 0 ||
post.likeCount !== 0 ||
post.quoteCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View
style={[
a.flex_row,
a.align_center,
a.gap_lg,
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link href={repostsHref} title={_(msg`Reposts of this post`)}>
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Text>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link href={quotesHref} title={_(msg`Quotes of this post`)}>
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Text>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link href={likesHref} title={_(msg`Likes on this post`)}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Text>
</Link>
) : null}
</View>
) : null}
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<FeedFeedbackProvider value={feedFeedback}>
<PostControls
big
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
feedContext={postSource?.post?.feedContext}
reqId={postSource?.post?.reqId}
viaRepost={viaRepost}
/>
</FeedFeedbackProvider>
</View>
</View>
</View>
</>
)
})
function ExpandedPostDetails({
post,
isThreadAuthor,
}: {
post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post']
isThreadAuthor: boolean
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const langPrefs = useLanguagePrefs()
const translatorUrl = getTranslatorLink(
post.record?.text || '',
langPrefs.primaryLanguage,
)
const needsTranslation = useMemo(
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[post, langPrefs.primaryLanguage],
)
const onTranslatePress = useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
openLink(translatorUrl, true)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
return false
},
[openLink, translatorUrl, langPrefs, post],
)
return (
<View style={[a.gap_md, a.pt_md, a.align_start]}>
<BackdatedPostIndicator post={post} />
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{niceDate(i18n, post.indexedAt)}
</Text>
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
{needsTranslation && (
<>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
&middot;
</Text>
<InlineLinkText
to={translatorUrl}
label={_(msg`Translate`)}
style={[a.text_sm]}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</InlineLinkText>
</>
)}
</View>
</View>
)
}
function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {_, i18n} = useLingui()
const control = Prompt.usePromptControl()
const indexedAt = new Date(post.indexedAt)
const createdAt = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? new Date(post.record.createdAt)
: new Date(post.indexedAt)
// backdated if createdAt is 24 hours or more before indexedAt
const isBackdated =
indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000
if (!isBackdated) return null
const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
return (
<>
<Button
label={_(msg`Archived post`)}
accessibilityHint={_(
msg`Shows information about when this post was created`,
)}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_25,
(hovered || pressed) && t.atoms.bg_contrast_50,
{
gap: 3,
paddingHorizontal: 6,
paddingVertical: 3,
},
]}>
<CalendarClockIcon fill={orange} size="sm" aria-hidden />
<Text
style={[
a.text_xs,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_medium,
]}>
<Trans>Archived from {niceDate(i18n, createdAt)}</Trans>
</Text>
</View>
)}
</Button>
<Prompt.Outer control={control}>
<Prompt.TitleText>
<Trans>Archived post</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
This post claims to have been created on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, createdAt)}</RNText>,
but was first seen by Bluesky on{' '}
<RNText style={[a.font_bold]}>{niceDate(i18n, indexedAt)}</RNText>.
</Trans>
</Prompt.DescriptionText>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_high,
a.pb_xl,
]}>
<Trans>
Bluesky cannot confirm the authenticity of the claimed date.
</Trans>
</Text>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
function getThreadAuthor(
post: AppBskyFeedDefs.PostView,
record: AppBskyFeedPost.Record,
): string {
if (!record.reply) {
return post.author.did
}
try {
return new AtUri(record.reply.root.uri).host
} catch {
return ''
}
}
export function ThreadItemAnchorSkeleton() {
return (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42} />
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View>
<Skele.Text style={[a.text_xl, {width: '100%'}]} />
<Skele.Text style={[a.text_xl, {width: '60%'}]} />
</View>
<Skele.Text style={[a.text_sm, {width: '50%'}]} />
<Skele.Row style={[a.justify_between]}>
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Pill blend size={24} />
<Skele.Circle blend size={24} />
<Skele.Circle blend size={24} />
</Skele.Row>
</View>
)
}

View File

@ -0,0 +1,32 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
export function ThreadItemAnchorNoUnauthenticated() {
const t = useTheme()
return (
<View style={[a.p_lg, a.gap_md]}>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={42}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Skele.Col>
<Skele.Text style={[a.text_lg, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '40%'}]} />
</Skele.Col>
</Skele.Row>
<View style={[a.py_sm]}>
<Text style={[a.text_xl, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</View>
</View>
)
}

View File

@ -0,0 +1,405 @@
import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {TextLink} from '#/view/com/util/Link'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}
export function ThreadItemPost({
item,
overrides,
onPostSuccess,
threadgateRecord,
}: ThreadItemPostProps) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemPostDeleted item={item} overrides={overrides} />
}
return (
<ThreadItemPostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemPostDeleted({
item,
overrides,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
const t = useTheme()
return (
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<ThreadItemPostParentReplyLine item={item} />
<View
style={[
a.flex_row,
a.align_center,
a.py_md,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
<View style={[{height: 4}]} />
</ThreadItemPostOuterWrapper>
)
}
const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
item,
overrides,
children,
}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
children: ReactNode
}) {
const t = useTheme()
const showTopBorder =
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{
paddingHorizontal: OUTER_SPACE,
},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
)
})
/**
* Provides some space between posts as well as contains the reply line
*/
const ThreadItemPostParentReplyLine = memo(
function ThreadItemPostParentReplyLine({
item,
}: Pick<ThreadItemPostProps, 'item'>) {
const t = useTheme()
return (
<View style={[a.flex_row, {height: 12}]}>
<View style={{width: LINEAR_AVI_WIDTH}}>
{item.ui.showParentReplyLine && (
<View
style={[
a.mx_auto,
a.flex_1,
a.mb_xs,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
},
)
const ThreadItemPostInner = memo(function ThreadItemPostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: ThreadItemPostProps & {
postShadow: Shadow<AppBskyFeedDefs.PostView>
}) {
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const [limitLines, setLimitLines] = useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const threadRootUri = record.reply?.root?.uri || post.uri
const postHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
}, [openComposer, post, record, onPostSuccess, moderation])
const onPressShowMore = useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
const {isActive: live} = useActorStatus(post.author)
return (
<SubtleHover>
<ThreadItemPostOuterWrapper item={item} overrides={overrides}>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={LINEAR_AVI_WIDTH}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemPostParentReplyLine item={item} />
<View style={[a.flex_row, a.gap_md]}>
<View>
<PreviewableUserAvatar
size={LINEAR_AVI_WIDTH}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
/>
{(item.ui.showChildReplyLine ||
item.ui.precedesChildReadMore) && (
<View
style={[
a.mx_auto,
a.mt_xs,
a.flex_1,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
style={[a.pb_xs]}
/>
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostControls
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
</PostHider>
</ThreadItemPostOuterWrapper>
</SubtleHover>
)
})
function SubtleHover({children}: {children: ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemPostSkeleton({index}: {index: number}) {
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<Skele.Row style={[a.justify_between, a.pt_xs]}>
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Circle blend size={16} />
<View />
</Skele.Row>
</Skele.Col>
</Skele.Row>
</View>
)
}

View File

@ -0,0 +1,74 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import * as Skele from '#/components/Skeleton'
import {Text} from '#/components/Typography'
export function ThreadItemPostNoUnauthenticated({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>
}) {
const t = useTheme()
return (
<View style={[{paddingHorizontal: OUTER_SPACE}]}>
<View style={[a.flex_row, {height: 12}]}>
<View style={{width: LINEAR_AVI_WIDTH}}>
{item.ui.showParentReplyLine && (
<View
style={[
a.mx_auto,
a.flex_1,
a.mb_xs,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
<Skele.Row style={[a.align_center, a.gap_md]}>
<Skele.Circle size={LINEAR_AVI_WIDTH}>
<LockIcon size="md" fill={t.atoms.text_contrast_medium.color} />
</Skele.Circle>
<Text style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
<Trans>You must sign in to view this post.</Trans>
</Text>
</Skele.Row>
<View
style={[
a.flex_row,
a.justify_center,
{
height: OUTER_SPACE / 1.5,
width: LINEAR_AVI_WIDTH,
},
]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.mt_xs,
a.h_full,
{
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
)}
</View>
</View>
)
}

View File

@ -0,0 +1,55 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {LINEAR_AVI_WIDTH, OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Text} from '#/components/Typography'
export type ThreadItemPostTombstoneProps = {
type: 'not-found' | 'blocked'
}
export function ThreadItemPostTombstone({type}: ThreadItemPostTombstoneProps) {
const t = useTheme()
const {_} = useLingui()
const {copy, Icon} = useMemo(() => {
switch (type) {
case 'blocked':
return {copy: _(msg`Post blocked`), Icon: PersonXIcon}
case 'not-found':
default:
return {copy: _(msg`Post not found`), Icon: TrashIcon}
}
}, [_, type])
return (
<View
style={[
a.mb_xs,
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 1.2,
},
]}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{paddingVertical: OUTER_SPACE / 1.2},
]}>
<View style={[a.flex_row, a.justify_center, {width: LINEAR_AVI_WIDTH}]}>
<Icon style={[t.atoms.text_contrast_medium]} />
</View>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
{copy}
</Text>
</View>
</View>
)
}

View File

@ -0,0 +1,107 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type PostThreadParams,
type ThreadItem,
} from '#/state/queries/usePostThread'
import {
LINEAR_AVI_WIDTH,
REPLY_LINE_WIDTH,
TREE_AVI_WIDTH,
TREE_INDENT,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export const ThreadItemReadMore = memo(function ThreadItemReadMore({
item,
view,
}: {
item: Extract<ThreadItem, {type: 'readMore'}>
view: PostThreadParams['view']
}) {
const t = useTheme()
const {_} = useLingui()
const isTreeView = view === 'tree'
const indent = Math.max(0, item.depth - 1)
const spacers = isTreeView
? Array.from(Array(indent)).map((_, n: number) => {
const isSkipped = item.skippedIndentIndices.has(n)
return (
<View
key={`${item.key}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})
: null
return (
<View style={[a.flex_row]}>
{spacers}
<View
style={[
t.atoms.border_contrast_low,
{
marginLeft: isTreeView
? TREE_INDENT + TREE_AVI_WIDTH / 2 - 1
: (LINEAR_AVI_WIDTH - REPLY_LINE_WIDTH) / 2 + 16,
borderLeftWidth: 2,
borderBottomWidth: 2,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
height: 18, // magic, Link below is 38px tall
width: isTreeView ? TREE_INDENT : LINEAR_AVI_WIDTH / 2 + 10,
},
]}
/>
<Link
label={_(msg`Read more replies`)}
to={item.href}
style={[a.pt_sm, a.pb_md, a.gap_xs]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<>
<CirclePlus
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={18}
/>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && a.underline,
]}>
<Trans>
Read {item.moreReplies} more{' '}
<Plural
one="reply"
other="replies"
value={item.moreReplies}
/>
</Trans>
</Text>
</>
)
}}
</Link>
</View>
)
})

View File

@ -0,0 +1,89 @@
import {memo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ThreadItem} from '#/state/queries/usePostThread'
import {
LINEAR_AVI_WIDTH,
OUTER_SPACE,
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {ArrowTopCircle_Stroke2_Corner0_Rounded as UpIcon} from '#/components/icons/ArrowTopCircle'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
export const ThreadItemReadMoreUp = memo(function ThreadItemReadMoreUp({
item,
}: {
item: Extract<ThreadItem, {type: 'readMoreUp'}>
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Link
label={_(msg`Continue thread`)}
to={item.href}
style={[
a.gap_xs,
{
paddingTop: OUTER_SPACE,
paddingHorizontal: OUTER_SPACE,
},
]}>
{({hovered, pressed}) => {
const interacted = hovered || pressed
return (
<View>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<UpIcon
fill={
interacted
? t.atoms.text_contrast_high.color
: t.atoms.text_contrast_low.color
}
width={24}
/>
</View>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
interacted && [a.underline],
]}>
<Trans>Continue thread...</Trans>
</Text>
</View>
<View
style={[
a.align_center,
{
width: LINEAR_AVI_WIDTH,
},
]}>
<View
style={[
a.mt_xs,
{
height: OUTER_SPACE / 2,
width: REPLY_LINE_WIDTH,
backgroundColor: t.atoms.border_contrast_low.borderColor,
},
]}
/>
</View>
</View>
)
}}
</Link>
)
})

View File

@ -0,0 +1,31 @@
import {View} from 'react-native'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Skele from '#/components/Skeleton'
/*
* Wacky padding here is just replicating what we have in the actual
* `PostThreadComposePrompt` component
*/
export function ThreadItemReplyComposerSkeleton() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
{
paddingHorizontal: OUTER_SPACE,
},
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs, a.py_sm]}>
<Skele.Circle size={gtMobile ? 24 : 22} />
<Skele.Text style={[a.text_md]} />
</View>
</View>
)
}

View File

@ -0,0 +1,59 @@
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Text} from '#/components/Typography'
export function ThreadItemShowOtherReplies({onPress}: {onPress: () => void}) {
const {_} = useLingui()
const t = useTheme()
const label = _(msg`Show more replies`)
return (
<Button
onPress={() => {
onPress()
logger.metric('thread:click:showOtherReplies', {})
}}
label={label}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
a.py_lg,
a.px_xl,
a.border_t,
t.atoms.border_contrast_low,
hovered || pressed ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<View
style={[
t.atoms.bg_contrast_25,
a.align_center,
a.justify_center,
{
width: 26,
height: 26,
borderRadius: 13,
marginRight: 4,
},
]}>
<EyeSlash size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
<Text
style={[t.atoms.text_contrast_medium, a.flex_1, a.leading_snug]}
numberOfLines={1}>
{label}
</Text>
</View>
)}
</Button>
)
}

View File

@ -0,0 +1,456 @@
import React, {memo, useMemo} from 'react'
import {View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {TextLink} from '#/view/com/util/Link'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {PostMeta} from '#/view/com/util/PostMeta'
import {
OUTER_SPACE,
REPLY_LINE_WIDTH,
TREE_AVI_WIDTH,
TREE_INDENT,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleWebHover} from '#/components/SubtleWebHover'
import {Text} from '#/components/Typography'
/**
* Mimic the space in PostMeta
*/
const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
export function ThreadItemTreePost({
item,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemTreePostDeleted item={item} />
}
return (
<ThreadItemTreePostInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemTreePostDeleted({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<ThreadItemTreePostOuterWrapper item={item}>
<ThreadItemTreePostInnerWrapper item={item}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
gap: 6,
paddingHorizontal: OUTER_SPACE / 2,
height: TREE_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text]} width={14} />
<Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
{item.ui.isLastChild && !item.ui.precedesChildReadMore && (
<View style={{height: OUTER_SPACE / 2}} />
)}
</ThreadItemTreePostInnerWrapper>
</ThreadItemTreePostOuterWrapper>
)
}
const ThreadItemTreePostOuterWrapper = memo(
function ThreadItemTreePostOuterWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
const indents = Math.max(0, item.ui.indent - 1)
return (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
)
},
)
const ThreadItemTreePostInnerWrapper = memo(
function ThreadItemTreePostInnerWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.flex_1, // TODO check on ios
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 2,
},
item.ui.indent === 1 && [
!item.ui.showParentReplyLine && a.pt_lg,
!item.ui.showChildReplyLine && a.pb_sm,
],
item.ui.isLastChild &&
!item.ui.precedesChildReadMore && [
{
paddingBottom: OUTER_SPACE / 2,
},
],
]}>
{item.ui.indent > 1 && (
<View
style={[
a.absolute,
t.atoms.border_contrast_low,
{
left: -1,
top: 0,
height:
TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
width: OUTER_SPACE,
borderLeftWidth: REPLY_LINE_WIDTH,
borderBottomWidth: REPLY_LINE_WIDTH,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
},
]}
/>
)}
{children}
</View>
)
},
)
const ThreadItemTreeReplyChildReplyLine = memo(
function ThreadItemTreeReplyChildReplyLine({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<View style={[a.relative, {width: TREE_AVI_PLUS_SPACE}]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.flex_1,
t.atoms.border_contrast_low,
{
borderRightWidth: 2,
width: '50%',
left: -1,
},
]}
/>
)}
</View>
)
},
)
const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
postShadow: Shadow<AppBskyFeedDefs.PostView>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode {
const pal = usePalette('default')
const {_} = useLingui()
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const threadRootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onPressReply = React.useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
},
onPostSuccess: onPostSuccess,
})
}, [openComposer, post, record, onPostSuccess, moderation])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
return (
<ThreadItemTreePostOuterWrapper item={item}>
<SubtleHover>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={42}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemTreePostInnerWrapper item={item}>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
avatarSize={TREE_AVI_WIDTH}
style={[a.pb_2xs]}
showAvatar
/>
<View style={[a.flex_row]}>
<ThreadItemTreeReplyChildReplyLine item={item} />
<View style={[a.flex_1]}>
<LabelsOnMyPost post={post} style={[a.pb_2xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
</View>
) : undefined}
{limitLines ? (
<TextLink
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
/>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
)}
<PostControls
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
</View>
</ThreadItemTreePostInnerWrapper>
</PostHider>
</SubtleHover>
</ThreadItemTreePostOuterWrapper>
)
})
function SubtleHover({children}: {children: React.ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={[a.flex_1, a.pointer]}>
<SubtleWebHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemTreePostSkeleton({index}: {index: number}) {
const t = useTheme()
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.gap_md,
a.border_t,
t.atoms.border_contrast_low,
]}>
<Skele.Row style={[a.align_start, a.gap_md]}>
<Skele.Circle size={TREE_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<Skele.Row style={[a.justify_between, a.pt_xs]}>
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Pill blend size={16} />
<Skele.Circle blend size={16} />
<View />
</Skele.Row>
</Skele.Col>
</Skele.Row>
</View>
)
}

View File

@ -0,0 +1,7 @@
import {tokens} from '#/alf'
export const TREE_INDENT = tokens.space.lg
export const TREE_AVI_WIDTH = 24
export const LINEAR_AVI_WIDTH = 42
export const REPLY_LINE_WIDTH = 2
export const OUTER_SPACE = tokens.space.lg

View File

@ -0,0 +1,577 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {Trans} from '@lingui/macro'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useFeedFeedback} from '#/state/feed-feedback'
import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List, type ListMethods} from '#/view/com/util/List'
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
import {ThreadError} from '#/screens/PostThread/components/ThreadError'
import {
ThreadItemAnchor,
ThreadItemAnchorSkeleton,
} from '#/screens/PostThread/components/ThreadItemAnchor'
import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated'
import {
ThreadItemPost,
ThreadItemPostSkeleton,
} from '#/screens/PostThread/components/ThreadItemPost'
import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated'
import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone'
import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore'
import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp'
import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer'
import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies'
import {
ThreadItemTreePost,
ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost'
import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
const PARENT_CHUNK_SIZE = 5
const CHILDREN_CHUNK_SIZE = 50
export function PostThread({uri}: {uri: string}) {
const {gtMobile} = useBreakpoints()
const {hasSession} = useSession()
const initialNumToRender = useInitialNumToRender() // TODO
const {height: windowHeight} = useWindowDimensions()
const anchorPostSource = useUnstablePostSource(uri)
const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
/*
* One query to rule them all
*/
const thread = usePostThread({anchor: uri})
const anchor = useMemo(() => {
for (const item of thread.data.items) {
if (item.type === 'threadPost' && item.depth === 0) {
return item
}
}
return
}, [thread.data.items])
const {openComposer} = useOpenComposer()
const optimisticOnPostReply = useCallback(
(payload: OnPostSuccessData) => {
if (payload) {
const {replyToUri, posts} = payload
if (replyToUri && posts.length) {
thread.actions.insertReplies(replyToUri, posts)
}
}
},
[thread],
)
const onReplyToAnchor = useCallback(() => {
if (anchor?.type !== 'threadPost') {
return
}
const post = anchor.value.post
openComposer({
replyTo: {
uri: anchor.uri,
cid: post.cid,
text: post.record.text,
author: post.author,
embed: post.embed,
moderation: anchor.moderation,
},
onPostSuccess: optimisticOnPostReply,
})
if (anchorPostSource) {
feedFeedback.sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext: anchorPostSource.post.feedContext,
reqId: anchorPostSource.post.reqId,
})
}
}, [
anchor,
openComposer,
optimisticOnPostReply,
anchorPostSource,
feedFeedback,
])
const isRoot = !!anchor && anchor.value.post.record.reply === undefined
const canReply = !anchor?.value.post?.viewer?.replyDisabled
const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE)
const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE)
const totalParentCount = useRef(0) // recomputed below
const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
const listRef = useRef<ListMethods>(null)
const anchorRef = useRef<View | null>(null)
const headerRef = useRef<View | null>(null)
/*
* On a cold load, parents are not prepended until the anchor post has
* rendered as the first item in the list. This gives us a consistent
* reference point for which to pin the anchor post to the top of the screen.
*
* We simulate a cold load any time the user changes the view or sort params
* so that this handling is consistent.
*
* On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives
* us this for free, since the anchor post is the first item in the list.
*
* On web, `onContentSizeChange` is used to get ahead of next paint and handle
* this scrolling.
*/
const [deferParents, setDeferParents] = useState(true)
/**
* Used to flag whether we should scroll to the anchor post. On a cold load,
* this is always true. And when a user changes thread parameters, we also
* manually set this to true.
*/
const shouldHandleScroll = useRef(true)
/**
* Called any time the content size of the list changes, _just_ before paint.
*
* We want this to fire every time we change params (which will reset
* `deferParents` via `onLayout` on the anchor post, due to the key change),
* or click into a new post (which will result in a fresh `deferParents`
* hook).
*
* The result being: any intentional change in view by the user will result
* in the anchor being pinned as the first item.
*/
const onContentSizeChangeWebOnly = web(() => {
const list = listRef.current
const anchor = anchorRef.current as any as Element
const header = headerRef.current as any as Element
if (list && anchor && header && shouldHandleScroll.current) {
const anchorOffsetTop = anchor.getBoundingClientRect().top
const headerHeight = header.getBoundingClientRect().height
/*
* `deferParents` is `true` on a cold load, and always reset to
* `true` when params change via `prepareForParamsUpdate`.
*
* On a cold load or a push to a new post, on the first pass of this
* logic, the anchor post is the first item in the list. Therefore
* `anchorOffsetTop - headerHeight` will be 0.
*
* When a user changes thread params, on the first pass of this logic,
* the anchor post may not move (if there are no parents above it), or it
* may have gone off the screen above, because of the sudden lack of
* parents due to `deferParents === true`. This negative value (minus
* `headerHeight`) will result in a _negative_ `offset` value, which will
* scroll the anchor post _down_ to the top of the screen.
*
* However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
* changes params, the anchor post's offset will actually be equivalent
* to the `headerHeight` because of how the DOM is stacked on web.
* Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
* which means the first pass in this case will result in no scroll.
*
* Then, once parents are prepended, this will fire again. Now, the
* `anchorOffsetTop` will be positive, which minus the header height,
* will give us a _positive_ offset, which will scroll the anchor post
* back _up_ to the top of the screen.
*/
list.scrollToOffset({
offset: anchorOffsetTop - headerHeight,
})
/*
* After the second pass, `deferParents` will be `false`, and we need
* to ensure this doesn't run again until scroll handling is requested
* again via `shouldHandleScroll.current === true` and a params
* change via `prepareForParamsUpdate`.
*
* The `isRoot` here is needed because if we're looking at the anchor
* post, this handler will not fire after `deferParents` is set to
* `false`, since there are no parents to render above it. In this case,
* we want to make sure `shouldHandleScroll` is set to `false` so that
* subsequent size changes unrelated to a params change (like pagination)
* do not affect scroll.
*/
if (!deferParents || isRoot) shouldHandleScroll.current = false
}
})
/**
* Ditto the above, but for native.
*/
const onContentSizeChangeNativeOnly = native(() => {
const list = listRef.current
const anchor = anchorRef.current
if (list && anchor && shouldHandleScroll.current) {
/*
* `prepareForParamsUpdate` is called any time the user changes thread params like
* `view` or `sort`, which sets `deferParents(true)` and resets the
* scroll to the top of the list. However, there is a split second
* where the top of the list is wherever the parents _just were_. So if
* there were parents, the anchor is not at the top of the list just
* prior to this handler being called.
*
* Once this handler is called, the anchor post is the first item in
* the list (because of `deferParents` being `true`), and so we can
* synchronously scroll the list back to the top of the list (which is
* 0 on native, no need to handle `headerHeight`).
*/
list.scrollToOffset({
animated: false,
offset: 0,
})
/*
* After this first pass, `deferParents` will be `false`, and those
* will render in. However, the anchor post will retain its position
* because of `maintainVisibleContentPosition` handling on native. So we
* don't need to let this handler run again, like we do on web.
*/
shouldHandleScroll.current = false
}
})
/**
* Called any time the user changes thread params, such as `view` or `sort`.
* Prepares the UI for repositioning of the scroll so that the anchor post is
* always at the top after a params change.
*
* No need to handle max parents here, deferParents will handle that and we
* want it to re-render with the same items above the anchor.
*/
const prepareForParamsUpdate = useCallback(() => {
/**
* Truncate list so that anchor post is the first item in the list. Manual
* scroll handling on web is predicated on this, and on native, this allows
* `maintainVisibleContentPosition` to do its thing.
*/
setDeferParents(true)
// reset this to a lower value for faster re-render
setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
// set flag
shouldHandleScroll.current = true
}, [setDeferParents, setMaxChildrenCount])
const setSortWrapped = useCallback(
(sort: string) => {
prepareForParamsUpdate()
thread.actions.setSort(sort)
},
[thread, prepareForParamsUpdate],
)
const setViewWrapped = useCallback(
(view: ThreadViewOption) => {
prepareForParamsUpdate()
thread.actions.setView(view)
},
[thread, prepareForParamsUpdate],
)
const onStartReached = () => {
if (thread.state.isFetching) return
// can be true after `prepareForParamsUpdate` is called
if (deferParents) return
// prevent any state mutations if we know we're done
if (maxParentCount >= totalParentCount.current) return
setMaxParentCount(n => n + PARENT_CHUNK_SIZE)
}
const onEndReached = () => {
if (thread.state.isFetching) return
// can be true after `prepareForParamsUpdate` is called
if (deferParents) return
// prevent any state mutations if we know we're done
if (maxChildrenCount >= totalChildrenCount.current) return
setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE)
}
const slices = useMemo(() => {
const results: ThreadItem[] = []
if (!thread.data.items.length) return results
/*
* Pagination hack, tracks the # of items below the anchor post.
*/
let childrenCount = 0
for (let i = 0; i < thread.data.items.length; i++) {
const item = thread.data.items[i]
/*
* Need to check `depth`, since not found or blocked posts are not
* `threadPost`s, but still have `depth`.
*/
const hasDepth = 'depth' in item
/*
* Handle anchor post.
*/
if (hasDepth && item.depth === 0) {
results.push(item)
// Recalculate total parents current index.
totalParentCount.current = i
// Recalculate total children using (length - 1) - current index.
totalChildrenCount.current = thread.data.items.length - 1 - i
/*
* Walk up the parents, limiting by `maxParentCount`
*/
if (!deferParents) {
const start = i - 1
if (start >= 0) {
const limit = Math.max(0, start - maxParentCount)
for (let pi = start; pi >= limit; pi--) {
results.unshift(thread.data.items[pi])
}
}
}
} else {
// ignore any parent items
if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue
// can exit early if we've reached the max children count
if (childrenCount > maxChildrenCount) break
results.push(item)
childrenCount++
}
}
return results
}, [thread, deferParents, maxParentCount, maxChildrenCount])
const isTombstoneView = useMemo(() => {
if (slices.length > 1) return false
return slices.every(
s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound',
)
}, [slices])
const renderItem = useCallback(
({item, index}: {item: ThreadItem; index: number}) => {
if (item.type === 'threadPost') {
if (item.depth < 0) {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
topBorder: index === 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else if (item.depth === 0) {
return (
/*
* Keep this view wrapped so that the anchor post is always index 0
* in the list and `maintainVisibleContentPosition` can do its
* thing.
*/
<View collapsable={false}>
<View
/*
* IMPORTANT: this is a load-bearing key on all platforms. We
* want to force `onLayout` to fire any time the thread params
* change so that `deferParents` is always reset to `false` once
* the anchor post is rendered.
*
* If we ever add additional thread params to this screen, they
* will need to be added here.
*/
key={item.uri + thread.state.view + thread.state.sort}
ref={anchorRef}
onLayout={() => setDeferParents(false)}
/>
<ThreadItemAnchor
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
onPostSuccess={optimisticOnPostReply}
postSource={anchorPostSource}
/>
</View>
)
} else {
if (thread.state.view === 'tree') {
return (
<ThreadItemTreePost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
} else {
return (
<ThreadItemPost
item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{
moderation: thread.state.otherItemsVisible && item.depth > 0,
}}
onPostSuccess={optimisticOnPostReply}
/>
)
}
}
} else if (item.type === 'threadPostNoUnauthenticated') {
if (item.depth < 0) {
return <ThreadItemPostNoUnauthenticated item={item} />
} else if (item.depth === 0) {
return <ThreadItemAnchorNoUnauthenticated />
}
} else if (item.type === 'readMore') {
return (
<ThreadItemReadMore
item={item}
view={thread.state.view === 'tree' ? 'tree' : 'linear'}
/>
)
} else if (item.type === 'readMoreUp') {
return <ThreadItemReadMoreUp item={item} />
} else if (item.type === 'threadPostBlocked') {
return <ThreadItemPostTombstone type="blocked" />
} else if (item.type === 'threadPostNotFound') {
return <ThreadItemPostTombstone type="not-found" />
} else if (item.type === 'replyComposer') {
return (
<View>
{gtMobile && (
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
} else if (item.type === 'showOtherReplies') {
return <ThreadItemShowOtherReplies onPress={item.onPress} />
} else if (item.type === 'skeleton') {
if (item.item === 'anchor') {
return <ThreadItemAnchorSkeleton />
} else if (item.item === 'reply') {
if (thread.state.view === 'linear') {
return <ThreadItemPostSkeleton index={index} />
} else {
return <ThreadItemTreePostSkeleton index={index} />
}
} else if (item.item === 'replyComposer') {
return <ThreadItemReplyComposerSkeleton />
}
}
return null
},
[
thread,
optimisticOnPostReply,
onReplyToAnchor,
gtMobile,
anchorPostSource,
],
)
return (
<>
<Layout.Header.Outer headerRef={headerRef}>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans context="description">Post</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot>
<HeaderDropdown
sort={thread.state.sort}
setSort={setSortWrapped}
view={thread.state.view}
setView={setViewWrapped}
/>
</Layout.Header.Slot>
</Layout.Header.Outer>
{thread.state.error ? (
<ThreadError
error={thread.state.error}
onRetry={thread.actions.refetch}
/>
) : (
<List
ref={listRef}
data={slices}
renderItem={renderItem}
keyExtractor={keyExtractor}
onContentSizeChange={platform({
web: onContentSizeChangeWebOnly,
default: onContentSizeChangeNativeOnly,
})}
onStartReached={onStartReached}
onEndReached={onEndReached}
onEndReachedThreshold={2}
onStartReachedThreshold={1}
/**
* NATIVE ONLY
* {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition}
*/
maintainVisibleContentPosition={{minIndexForVisible: 0}}
desktopFixedHeight
ListFooterComponent={
<ListFooter
/*
* On native, if `deferParents` is true, we need some extra buffer to
* account for the `on*ReachedThreshold` values.
*
* Otherwise, and on web, this value needs to be the height of
* the viewport _minus_ a sensible min-post height e.g. 200, so
* that there's enough scroll remaining to get the anchor post
* back to the top of the screen when handling scroll.
*/
height={platform({
web: windowHeight - 200,
default: deferParents ? windowHeight * 2 : windowHeight - 200,
})}
style={isTombstoneView ? {borderTopWidth: 0} : undefined}
/>
}
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
/>
)}
{!gtMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onReplyToAnchor} />
)}
</>
)
}
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
const keyExtractor = (item: ThreadItem) => {
return item.key
}

View File

@ -2,22 +2,156 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
import {
normalizeSort,
normalizeView,
useThreadPreferences,
} from '#/state/queries/preferences/useThreadPreferences'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export function ThreadPreferencesScreen({}: Props) {
const gate = useGate()
return gate('post_threads_v2_unspecced') ? (
<ThreadPreferencesV2 />
) : (
<ThreadPreferencesV1 />
)
}
export function ThreadPreferencesV2() {
const t = useTheme()
const {_} = useLingui()
const {
sort,
setSort,
view,
setView,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers,
} = useThreadPreferences({save: true})
return (
<Layout.Screen testID="threadPreferencesScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Thread Preferences</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<SettingsList.Container>
<SettingsList.Group>
<SettingsList.ItemIcon icon={BubblesIcon} />
<SettingsList.ItemText>
<Trans>Sort replies</Trans>
</SettingsList.ItemText>
<View style={[a.w_full, a.gap_md]}>
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
<Trans>Sort replies to the same post by:</Trans>
</Text>
<Toggle.Group
label={_(msg`Sort replies by`)}
type="radio"
values={sort ? [sort] : []}
onChange={values => setSort(normalizeSort(values[0]))}>
<View style={[a.gap_sm, a.flex_1]}>
<Toggle.Item name="top" label={_(msg`Top replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Top replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="oldest"
label={_(msg`Oldest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Oldest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="newest"
label={_(msg`Newest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Newest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</SettingsList.Group>
<SettingsList.Group contentContainerStyle={{minHeight: 0}}>
<SettingsList.ItemIcon icon={PersonGroupIcon} />
<SettingsList.ItemText>
<Trans>Prioritize your Follows</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="prioritize-follows"
label={_(msg`Prioritize your Follows`)}
value={prioritizeFollowedUsers}
onChange={value => setPrioritizeFollowedUsers(value)}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Show replies by people you follow before all other replies
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
<SettingsList.Group>
<SettingsList.ItemIcon icon={TreeIcon} />
<SettingsList.ItemText>
<Trans>Tree view</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="threaded-mode"
label={_(msg`Tree view`)}
value={view === 'tree'}
onChange={value =>
setView(normalizeView({treeViewEnabled: value}))
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>Show post replies in a threaded tree view</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
export function ThreadPreferencesV1() {
const {_} = useLingui()
const t = useTheme()

View File

@ -882,7 +882,10 @@ function Overlay({
player={player}
seekingAnimationSV={seekingAnimationSV}
scrollGesture={scrollGesture}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
<PostThreadComposePrompt
onPressCompose={onPressReply}
style={[a.pt_md, a.pb_sm]}
/>
</Scrubber>
</LinearGradient>
</View>

View File

@ -14,6 +14,7 @@ import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/qu
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
import {useProfileShadow} from './profile-shadow'
import {castAsShadow, type Shadow} from './types'
export type {Shadow} from './types'
@ -157,6 +158,9 @@ function* findPostsInCache(
yield node.post
}
}
for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
yield post
}
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield post
}

View File

@ -21,6 +21,7 @@ import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows'
import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
import type * as bsky from '#/types/bsky'
import {castAsShadow, type Shadow} from './types'
@ -167,6 +168,7 @@ function* findProfilesInCache(
yield* findAllProfilesInListConvosQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
}

View File

@ -0,0 +1,179 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
import debounce from 'lodash.debounce'
import {OnceKey, useCallOnce} from '#/lib/hooks/useCallOnce'
import {logger} from '#/logger'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
import {type ThreadViewPreferences} from '#/state/queries/preferences/types'
import {type Literal} from '#/types/utils'
export type ThreadSortOption = Literal<
AppBskyUnspeccedGetPostThreadV2.QueryParams['sort'],
string
>
export type ThreadViewOption = 'linear' | 'tree'
export type ThreadPreferences = {
isLoaded: boolean
isSaving: boolean
sort: ThreadSortOption
setSort: (sort: string) => void
view: ThreadViewOption
setView: (view: ThreadViewOption) => void
prioritizeFollowedUsers: boolean
setPrioritizeFollowedUsers: (prioritize: boolean) => void
}
export function useThreadPreferences({
save,
}: {save?: boolean} = {}): ThreadPreferences {
const {data: preferences} = usePreferencesQuery()
const serverPrefs = preferences?.threadViewPrefs
const once = useCallOnce(OnceKey.PreferencesThread)
/*
* Create local state representations of server state
*/
const [sort, setSort] = useState(normalizeSort(serverPrefs?.sort || 'top'))
const [view, setView] = useState(
normalizeView({
treeViewEnabled: !!serverPrefs?.lab_treeViewEnabled,
}),
)
const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
!!serverPrefs?.prioritizeFollowedUsers,
)
/**
* If we get a server update, update local state
*/
const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
const isLoaded = !!prevServerPrefs
if (serverPrefs && prevServerPrefs !== serverPrefs) {
setPrevServerPrefs(serverPrefs)
/*
* Update
*/
setSort(normalizeSort(serverPrefs.sort))
setPrioritizeFollowedUsers(serverPrefs.prioritizeFollowedUsers)
setView(
normalizeView({
treeViewEnabled: !!serverPrefs.lab_treeViewEnabled,
}),
)
once(() => {
logger.metric('thread:preferences:load', {
sort: serverPrefs.sort,
view: serverPrefs.lab_treeViewEnabled ? 'tree' : 'linear',
prioritizeFollowedUsers: serverPrefs.prioritizeFollowedUsers,
})
})
}
const userUpdatedPrefs = useRef(false)
const [isSaving, setIsSaving] = useState(false)
const {mutateAsync} = useSetThreadViewPreferencesMutation()
const savePrefs = useMemo(() => {
return debounce(async (prefs: ThreadViewPreferences) => {
try {
setIsSaving(true)
await mutateAsync(prefs)
logger.metric('thread:preferences:update', {
sort: prefs.sort,
view: prefs.lab_treeViewEnabled ? 'tree' : 'linear',
prioritizeFollowedUsers: prefs.prioritizeFollowedUsers,
})
} catch (e) {
logger.error('useThreadPreferences failed to save', {
safeMessage: e,
})
} finally {
setIsSaving(false)
}
}, 4e3)
}, [mutateAsync])
if (save && userUpdatedPrefs.current) {
savePrefs({
sort,
prioritizeFollowedUsers,
lab_treeViewEnabled: view === 'tree',
})
userUpdatedPrefs.current = false
}
const setSortWrapped = useCallback(
(next: string) => {
userUpdatedPrefs.current = true
setSort(normalizeSort(next))
},
[setSort],
)
const setViewWrapped = useCallback(
(next: ThreadViewOption) => {
userUpdatedPrefs.current = true
setView(next)
},
[setView],
)
const setPrioritizeFollowedUsersWrapped = useCallback(
(next: boolean) => {
userUpdatedPrefs.current = true
setPrioritizeFollowedUsers(next)
},
[setPrioritizeFollowedUsers],
)
return useMemo(
() => ({
isLoaded,
isSaving,
sort,
setSort: setSortWrapped,
view,
setView: setViewWrapped,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers: setPrioritizeFollowedUsersWrapped,
}),
[
isLoaded,
isSaving,
sort,
setSortWrapped,
view,
setViewWrapped,
prioritizeFollowedUsers,
setPrioritizeFollowedUsersWrapped,
],
)
}
/**
* Migrates user thread preferences from the old sort values to V2
*/
export function normalizeSort(sort: string): ThreadSortOption {
switch (sort) {
case 'oldest':
return 'oldest'
case 'newest':
return 'newest'
default:
return 'top'
}
}
/**
* Transforms existing treeViewEnabled preference into a ThreadViewOption
*/
export function normalizeView({
treeViewEnabled,
}: {
treeViewEnabled: boolean
}): ThreadViewOption {
return treeViewEnabled ? 'tree' : 'linear'
}

View File

@ -0,0 +1,27 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
/**
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
*/
export const LINEAR_VIEW_BELOW = 10
/**
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
*/
export const LINEAR_VIEW_BF = 1
/**
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
*/
export const TREE_VIEW_BELOW = 4
/**
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
*/
export const TREE_VIEW_BF = undefined
/**
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
*/
export const TREE_VIEW_BELOW_DESKTOP = 6

View File

@ -0,0 +1,325 @@
import {useCallback, useMemo, useState} from 'react'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
import {
LINEAR_VIEW_BELOW,
LINEAR_VIEW_BF,
TREE_VIEW_BELOW,
TREE_VIEW_BELOW_DESKTOP,
TREE_VIEW_BF,
} from '#/state/queries/usePostThread/const'
import {
createCacheMutator,
getThreadPlaceholder,
} from '#/state/queries/usePostThread/queryCache'
import {
buildThread,
sortAndAnnotateThreadItems,
} from '#/state/queries/usePostThread/traversal'
import {
createPostThreadOtherQueryKey,
createPostThreadQueryKey,
type ThreadItem,
type UsePostThreadQueryResult,
} from '#/state/queries/usePostThread/types'
import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
import * as views from '#/state/queries/usePostThread/views'
import {useAgent, useSession} from '#/state/session'
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useBreakpoints} from '#/alf'
export * from '#/state/queries/usePostThread/types'
export function usePostThread({anchor}: {anchor?: string}) {
const qc = useQueryClient()
const agent = useAgent()
const {hasSession} = useSession()
const {gtPhone} = useBreakpoints()
const moderationOpts = useModerationOpts()
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
const {
isLoaded: isThreadPreferencesLoaded,
sort,
setSort: baseSetSort,
view,
setView: baseSetView,
prioritizeFollowedUsers,
} = useThreadPreferences()
const below = useMemo(() => {
return view === 'linear'
? LINEAR_VIEW_BELOW
: isWeb && gtPhone
? TREE_VIEW_BELOW_DESKTOP
: TREE_VIEW_BELOW
}, [view, gtPhone])
const postThreadQueryKey = createPostThreadQueryKey({
anchor,
sort,
view,
prioritizeFollowedUsers,
})
const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
anchor,
prioritizeFollowedUsers,
})
const query = useQuery<UsePostThreadQueryResult>({
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
queryKey: postThreadQueryKey,
async queryFn(ctx) {
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: anchor!,
branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
below,
sort: sort,
prioritizeFollowedUsers: prioritizeFollowedUsers,
})
/*
* Initialize `ctx.meta` to track if we know we have additional replies
* we could fetch once we hit the end.
*/
ctx.meta = ctx.meta || {
hasOtherReplies: false,
}
/*
* If we know we have additional replies, we'll set this to true.
*/
if (data.hasOtherReplies) {
ctx.meta.hasOtherReplies = true
}
const result = {
thread: data.thread || [],
threadgate: data.threadgate,
hasOtherReplies: !!ctx.meta.hasOtherReplies,
}
const record = getThreadgateRecord(result.threadgate)
if (result.threadgate && record) {
result.threadgate.record = record
}
return result as UsePostThreadQueryResult
},
placeholderData() {
if (!anchor) return
const placeholder = getThreadPlaceholder(qc, anchor)
/*
* Always return something here, even empty data, so that
* `isPlaceholderData` is always true, which we'll use to insert
* skeletons.
*/
const thread = placeholder ? [placeholder] : []
return {thread, threadgate: undefined, hasOtherReplies: false}
},
select(data) {
const record = getThreadgateRecord(data.threadgate)
if (data.threadgate && record) {
data.threadgate.record = record
}
return data
},
})
const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
const threadgate = useMemo(
() => query.data?.threadgate,
[query.data?.threadgate],
)
const hasOtherThreadItems = useMemo(
() => !!query.data?.hasOtherReplies,
[query.data?.hasOtherReplies],
)
const [otherItemsVisible, setOtherItemsVisible] = useState(false)
/**
* Creates a mutator for the post thread cache. This is used to insert
* replies into the thread cache after posting.
*/
const mutator = useMemo(
() =>
createCacheMutator({
params: {view, below},
postThreadQueryKey,
postThreadOtherQueryKey,
queryClient: qc,
}),
[qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
)
/**
* If we have additional items available from the server and the user has
* chosen to view them, start loading data
*/
const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
const additionalItemsQuery = useQuery({
enabled: additionalQueryEnabled,
queryKey: postThreadOtherQueryKey,
async queryFn() {
const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
anchor: anchor!,
prioritizeFollowedUsers,
})
return data
},
})
const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
if (!additionalQueryEnabled) return []
if (additionalItemsQuery.isLoading) {
return Array.from({length: 2}).map((_, i) =>
views.skeleton({
key: `other-reply-${i}`,
item: 'reply',
}),
)
} else if (additionalItemsQuery.isError) {
/*
* We could insert an special error component in here, but since these
* are optional additional replies, it's not critical that they're shown
* atm.
*/
return []
} else if (additionalItemsQuery.data?.thread) {
const {threadItems} = sortAndAnnotateThreadItems(
additionalItemsQuery.data.thread,
{
view,
skipModerationHandling: true,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
threadgate?.record,
),
moderationOpts: moderationOpts!,
},
)
return threadItems
} else {
return []
}
}, [
view,
additionalQueryEnabled,
additionalItemsQuery,
mergeThreadgateHiddenReplies,
moderationOpts,
threadgate?.record,
])
/**
* Sets the sort order for the thread and resets the additional thread items
*/
const setSort: typeof baseSetSort = useCallback(
nextSort => {
setOtherItemsVisible(false)
baseSetSort(nextSort)
},
[baseSetSort, setOtherItemsVisible],
)
/**
* Sets the view variant for the thread and resets the additional thread items
*/
const setView: typeof baseSetView = useCallback(
nextView => {
setOtherItemsVisible(false)
baseSetView(nextView)
},
[baseSetView, setOtherItemsVisible],
)
/*
* This is the main thread response, sorted into separate buckets based on
* moderation, and annotated with all UI state needed for rendering.
*/
const {threadItems, otherThreadItems} = useMemo(() => {
return sortAndAnnotateThreadItems(thread, {
view: view,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
moderationOpts: moderationOpts!,
})
}, [
thread,
threadgate?.record,
mergeThreadgateHiddenReplies,
moderationOpts,
view,
])
/*
* Take all three sets of thread items and combine them into a single thread,
* along with any other thread items required for rendering e.g. "Show more
* replies" or the reply composer.
*/
const items = useMemo(() => {
return buildThread({
threadItems,
otherThreadItems,
serverOtherThreadItems,
isLoading: query.isPlaceholderData,
hasSession,
hasOtherThreadItems,
otherItemsVisible,
showOtherItems: () => setOtherItemsVisible(true),
})
}, [
threadItems,
otherThreadItems,
serverOtherThreadItems,
query.isPlaceholderData,
hasSession,
hasOtherThreadItems,
otherItemsVisible,
setOtherItemsVisible,
])
return useMemo(
() => ({
state: {
/*
* Copy in any query state that is useful
*/
isFetching: query.isFetching,
isPlaceholderData: query.isPlaceholderData,
error: query.error,
/*
* Other state
*/
sort,
view,
otherItemsVisible,
},
data: {
items,
threadgate,
},
actions: {
/*
* Copy in any query actions that are useful
*/
insertReplies: mutator.insertReplies,
refetch: query.refetch,
/*
* Other actions
*/
setSort,
setView,
},
}),
[
query,
mutator.insertReplies,
otherItemsVisible,
sort,
view,
setSort,
setView,
threadgate,
items,
],
)
}

View File

@ -0,0 +1,300 @@
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyFeedDefs,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadOtherV2,
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query'
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
import {getBranch} from '#/state/queries/usePostThread/traversal'
import {
type ApiThreadItem,
type createPostThreadOtherQueryKey,
type createPostThreadQueryKey,
type PostThreadParams,
postThreadQueryKeyRoot,
} from '#/state/queries/usePostThread/types'
import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
import {embedViewRecordToPostView} from '#/state/queries/util'
export function createCacheMutator({
queryClient,
postThreadQueryKey,
postThreadOtherQueryKey,
params,
}: {
queryClient: QueryClient
postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
params: Pick<PostThreadParams, 'view'> & {below: number}
}) {
return {
insertReplies(
parentUri: string,
replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
) {
/*
* Main thread query mutator.
*/
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
postThreadQueryKey,
data => {
if (!data) return
return {
...data,
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
...data.thread,
]),
}
},
)
/*
* Additional replies query mutator.
*/
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>(
postThreadOtherQueryKey,
data => {
if (!data) return
return {
...data,
thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([
...data.thread,
]),
}
},
)
function mutator<T>(thread: ApiThreadItem[]): T[] {
for (let i = 0; i < thread.length; i++) {
const existingParent = thread[i]
if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value))
continue
if (existingParent.uri !== parentUri) continue
/*
* Update parent data
*/
existingParent.value.post = {
...existingParent.value.post,
replyCount: (existingParent.value.post.replyCount || 0) + 1,
}
const opDid = getRootPostAtUri(existingParent.value.post)?.host
const nextItem = thread.at(i + 1)
const isReplyToRoot = existingParent.depth === 0
const isEndOfReplyChain =
!nextItem || nextItem.depth <= existingParent.depth
const firstReply = replies.at(0)
const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost(
firstReply?.value,
)
? opDid === firstReply.value.post.author.did
: false
/*
* Always insert replies if the following conditions are met.
*/
const shouldAlwaysInsertReplies =
isReplyToRoot ||
params.view === 'tree' ||
(params.view === 'linear' && isEndOfReplyChain)
/*
* Maybe insert replies if the replier is the OP and certain conditions are met
*/
const shouldReplaceWithOPReplies =
!isReplyToRoot && params.view === 'linear' && opIsReplier
if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) {
const branch = getBranch(thread, i, existingParent.depth)
/*
* OP insertions replace other replies _in linear view_.
*/
const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
const itemsToInsert = replies
.map((r, ri) => {
r.depth = existingParent.depth + 1 + ri
return r
})
.filter(r => {
// Filter out replies that are too deep for our UI
return r.depth <= params.below
})
thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
}
}
return thread as T[]
}
},
/**
* Unused atm, post shadow does the trick, but it would be nice to clean up
* the whole sub-tree on deletes.
*/
deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
postThreadQueryKey,
queryData => {
if (!queryData) return
const thread = [...queryData.thread]
for (let i = 0; i < thread.length; i++) {
const existingPost = thread[i]
if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
if (existingPost.uri === post.uri) {
const branch = getBranch(thread, i, existingPost.depth)
thread.splice(branch.start, branch.length)
break
}
}
return {
...queryData,
thread,
}
},
)
},
}
}
export function getThreadPlaceholder(
queryClient: QueryClient,
uri: string,
): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void {
let partial
for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
/*
* Currently, the backend doesn't send full post info in some cases (for
* example, for quoted posts). We use missing `likeCount` as a way to
* detect that. In the future, we should fix this on the backend, which
* will let us always stop on the first result.
*
* TODO can we send in feeds and quotes?
*/
const hasAllInfo = item.value.post.likeCount != null
if (hasAllInfo) {
return item
} else {
// Keep searching, we might still find a full post in the cache.
partial = item
}
}
return partial
}
export function* getThreadPlaceholderCandidates(
queryClient: QueryClient,
uri: string,
): Generator<
$Typed<
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
}
>,
void
> {
/*
* Check post thread queries first
*/
for (const post of findAllPostsInQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
}
/*
* Check notifications first. If you have a post in notifications, it's
* often due to a like or a repost, and we want to prioritize a post object
* with >0 likes/reposts over a stale version with no metrics in order to
* avoid a notification->post scroll jump.
*/
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
}
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
}
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
}
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
}
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
queryClient,
uri,
)) {
yield postViewToThreadPlaceholder(post)
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator<AppBskyFeedDefs.PostView, void> {
const atUri = new AtUri(uri)
const queryDatas =
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
queryKey: [postThreadQueryKeyRoot],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) continue
const {thread} = queryData
for (const item of thread) {
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
if (didOrHandleUriMatches(atUri, item.value.post)) {
yield item.value.post
}
const qp = getEmbeddedPost(item.value.post.embed)
if (qp && didOrHandleUriMatches(atUri, qp)) {
yield embedViewRecordToPostView(qp)
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
const queryDatas =
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
queryKey: [postThreadQueryKeyRoot],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) continue
const {thread} = queryData
for (const item of thread) {
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
if (item.value.post.author.did === did) {
yield item.value.post.author
}
const qp = getEmbeddedPost(item.value.post.embed)
if (qp && qp.author.did === did) {
yield qp.author
}
}
}
}
}

View File

@ -0,0 +1,539 @@
/* eslint-disable no-labels */
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
import {
type ApiThreadItem,
type PostThreadParams,
type ThreadItem,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
import {
getPostRecord,
getThreadPostNoUnauthenticatedUI,
getThreadPostUI,
getTraversalMetadata,
storeTraversalMetadata,
} from '#/state/queries/usePostThread/utils'
import * as views from '#/state/queries/usePostThread/views'
export function sortAndAnnotateThreadItems(
thread: ApiThreadItem[],
{
threadgateHiddenReplies,
moderationOpts,
view,
skipModerationHandling,
}: {
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
view: PostThreadParams['view']
/**
* Set to `true` in cases where we already know the moderation state of the
* post e.g. when fetching additional replies from the server. This will
* prevent additional sorting or nested-branch truncation, and all replies,
* regardless of moderation state, will be included in the resulting
* `threadItems` array.
*/
skipModerationHandling?: boolean
},
) {
const threadItems: ThreadItem[] = []
const otherThreadItems: ThreadItem[] = []
const metadatas = new Map<string, TraversalMetadata>()
traversal: for (let i = 0; i < thread.length; i++) {
const item = thread[i]
let parentMetadata: TraversalMetadata | undefined
let metadata: TraversalMetadata | undefined
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
parentMetadata = metadatas.get(
getPostRecord(item.value.post).reply?.parent?.uri || '',
)
metadata = getTraversalMetadata({
item,
parentMetadata,
prevItem: thread.at(i - 1),
nextItem: thread.at(i + 1),
})
storeTraversalMetadata(metadatas, metadata)
}
if (item.depth < 0) {
/*
* Parents are ignored until we find the anchor post, then we walk
* _up_ from there.
*/
} else if (item.depth === 0) {
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
threadItems.push(views.threadPostNoUnauthenticated(item))
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
threadItems.push(views.threadPostNotFound(item))
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
threadItems.push(views.threadPostBlocked(item))
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
moderationOpts,
threadgateHiddenReplies,
})
threadItems.push(post)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parent = thread[pi]
if (
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
) {
const post = views.threadPostNoUnauthenticated(parent)
post.ui = getThreadPostNoUnauthenticatedUI({
depth: parent.depth,
// ignore for now
// prevItemDepth: thread[pi - 1]?.depth,
nextItemDepth: thread[pi + 1]?.depth,
})
threadItems.unshift(post)
// for now, break parent traversal at first no-unauthed
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
threadItems.unshift(views.threadPostNotFound(parent))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
threadItems.unshift(views.threadPostBlocked(parent))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
threadItems.unshift(
views.threadPost({
uri: parent.uri,
depth: parent.depth,
value: parent.value,
moderationOpts,
threadgateHiddenReplies,
}),
)
}
}
}
} else if (item.depth > 0) {
/*
* The API does not send down any unavailable replies, so this will
* always be false (for now). If we ever wanted to tombstone them here,
* we could.
*/
const shouldBreak =
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
if (shouldBreak) {
const branch = getBranch(thread, i, item.depth)
// could insert tombstone
i = branch.end
continue traversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
if (parentMetadata) {
/*
* Set this value before incrementing the parent's repliesSeenCounter
*/
metadata!.replyIndex = parentMetadata.repliesIndexCounter
// Increment the parent's repliesIndexCounter
parentMetadata.repliesIndexCounter += 1
}
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
moderationOpts,
threadgateHiddenReplies,
})
if (!post.isBlurred || skipModerationHandling) {
/*
* Not moderated, need to insert it
*/
threadItems.push(post)
/*
* Update seen reply count of parent
*/
if (parentMetadata) {
parentMetadata.repliesSeenCounter += 1
}
} else {
/*
* Moderated in some way, we're going to walk children
*/
const parent = post
const parentIsTopLevelReply = parent.depth === 1
// get sub tree
const branch = getBranch(thread, i, item.depth)
if (parentIsTopLevelReply) {
// push branch anchor into sorted array
otherThreadItems.push(parent)
// skip branch anchor in branch traversal
const startIndex = branch.start + 1
for (let ci = startIndex; ci <= branch.end; ci++) {
const child = thread[ci]
if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
const childParentMetadata = metadatas.get(
getPostRecord(child.value.post).reply?.parent?.uri || '',
)
const childMetadata = getTraversalMetadata({
item: child,
prevItem: thread[ci - 1],
nextItem: thread[ci + 1],
parentMetadata: childParentMetadata,
})
storeTraversalMetadata(metadatas, childMetadata)
if (childParentMetadata) {
/*
* Set this value before incrementing the parent's repliesIndexCounter
*/
childMetadata!.replyIndex =
childParentMetadata.repliesIndexCounter
childParentMetadata.repliesIndexCounter += 1
}
const childPost = views.threadPost({
uri: child.uri,
depth: child.depth,
value: child.value,
moderationOpts,
threadgateHiddenReplies,
})
/*
* If a child is moderated in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the
* parent post directly.
*/
if (childPost.isBlurred) {
ci = getBranch(thread, ci, child.depth).end
} else {
otherThreadItems.push(childPost)
if (childParentMetadata) {
childParentMetadata.repliesSeenCounter += 1
}
}
} else {
/*
* Drop the rest of the branch if we hit anything unexpected
*/
break
}
}
}
/*
* Skip to next branch
*/
i = branch.end
continue traversal
}
}
}
}
/*
* Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
* UI state based on collected metadata. These arrays will be muted in situ.
*/
for (const subset of [threadItems, otherThreadItems]) {
for (let i = 0; i < subset.length; i++) {
const item = subset[i]
const prevItem = subset.at(i - 1)
const nextItem = subset.at(i + 1)
if (item.type === 'threadPost') {
const metadata = metadatas.get(item.uri)
if (metadata) {
if (metadata.parentMetadata) {
/*
* Track what's before/after now that we've applied moderation
*/
if (prevItem?.type === 'threadPost')
metadata.prevItemDepth = prevItem?.depth
if (nextItem?.type === 'threadPost')
metadata.nextItemDepth = nextItem?.depth
/*
* We can now officially calculate `isLastSibling` and `isLastChild`
* based on the actual data that we've seen.
*/
metadata.isLastSibling =
metadata.replyIndex ===
metadata.parentMetadata.repliesSeenCounter - 1
metadata.isLastChild =
metadata.nextItemDepth === undefined ||
metadata.nextItemDepth <= metadata.depth
/*
* If this is the last sibling, it's implicitly part of the last
* branch of this sub-tree.
*/
if (metadata.isLastSibling) {
metadata.isPartOfLastBranchFromDepth = metadata.depth
/**
* If the parent is part of the last branch of the sub-tree, so is the child.
*/
if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
metadata.isPartOfLastBranchFromDepth =
metadata.parentMetadata.isPartOfLastBranchFromDepth
}
}
/*
* If this is the last sibling, and the parent has unhydrated replies,
* at some point down the line we will need to show a "read more".
*/
if (
metadata.parentMetadata.repliesUnhydrated > 0 &&
metadata.isLastSibling
) {
metadata.upcomingParentReadMore = metadata.parentMetadata
}
/*
* Copy in the parent's upcoming read more, if it exists. Once we
* reach the bottom, we'll insert a "read more"
*/
if (metadata.parentMetadata.upcomingParentReadMore) {
metadata.upcomingParentReadMore =
metadata.parentMetadata.upcomingParentReadMore
}
/*
* Copy in the parent's skipped indents
*/
metadata.skippedIndentIndices = new Set([
...metadata.parentMetadata.skippedIndentIndices,
])
/**
* If this is the last sibling, and the parent has no unhydrated
* replies, then we know we can skip an indent line.
*/
if (
metadata.parentMetadata.repliesUnhydrated <= 0 &&
metadata.isLastSibling
) {
/**
* Depth is 2 more than the 0-index of the indent calculation
* bc of how we render these. So instead of handling that in the
* component, we just adjust that back to 0-index here.
*/
metadata.skippedIndentIndices.add(item.depth - 2)
}
}
/*
* If this post has unhydrated replies, and it is the last child, then
* it itself needs a "read more"
*/
if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
metadata.precedesChildReadMore = true
subset.splice(i + 1, 0, views.readMore(metadata))
i++ // skip next iteration
}
/*
* Tree-view only.
*
* If there's an upcoming parent read more, this branch is part of the
* last branch of the sub-tree, and the item itself is the last child,
* insert the parent "read more".
*/
if (
view === 'tree' &&
metadata.upcomingParentReadMore &&
metadata.isPartOfLastBranchFromDepth ===
metadata.upcomingParentReadMore.depth &&
metadata.isLastChild
) {
subset.splice(
i + 1,
0,
views.readMore(metadata.upcomingParentReadMore),
)
i++
}
/**
* Only occurs for the first item in the thread, which may have
* additional parents not included in this request.
*/
if (item.value.moreParents) {
metadata.followsReadMoreUp = true
subset.splice(i, 0, views.readMoreUp(metadata))
i++
}
/*
* Calculate the final UI state for the thread item.
*/
item.ui = getThreadPostUI(metadata)
}
}
}
}
return {
threadItems,
otherThreadItems,
}
}
export function buildThread({
threadItems,
otherThreadItems,
serverOtherThreadItems,
isLoading,
hasSession,
otherItemsVisible,
hasOtherThreadItems,
showOtherItems,
}: {
threadItems: ThreadItem[]
otherThreadItems: ThreadItem[]
serverOtherThreadItems: ThreadItem[]
isLoading: boolean
hasSession: boolean
otherItemsVisible: boolean
hasOtherThreadItems: boolean
showOtherItems: () => void
}) {
/**
* `threadItems` is memoized here, so don't mutate it directly.
*/
const items = [...threadItems]
if (isLoading) {
const anchorPost = items.at(0)
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
const skeletonReplies = hasAnchorFromCache
? anchorPost.value.post.replyCount ?? 4
: 4
if (!items.length) {
items.push(
views.skeleton({
key: 'anchor-skeleton',
item: 'anchor',
}),
)
}
if (hasSession) {
// we might have this from cache
const replyDisabled =
hasAnchorFromCache &&
anchorPost.value.post.viewer?.replyDisabled === true
if (hasAnchorFromCache) {
if (!replyDisabled) {
items.push({
type: 'replyComposer',
key: 'replyComposer',
})
}
} else {
items.push(
views.skeleton({
key: 'replyComposer',
item: 'replyComposer',
}),
)
}
}
for (let i = 0; i < skeletonReplies; i++) {
items.push(
views.skeleton({
key: `anchor-skeleton-reply-${i}`,
item: 'reply',
}),
)
}
} else {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (
item.type === 'threadPost' &&
item.depth === 0 &&
!item.value.post.viewer?.replyDisabled &&
hasSession
) {
items.splice(i + 1, 0, {
type: 'replyComposer',
key: 'replyComposer',
})
break
}
}
if (otherThreadItems.length || hasOtherThreadItems) {
if (otherItemsVisible) {
items.push(...otherThreadItems)
items.push(...serverOtherThreadItems)
} else {
items.push({
type: 'showOtherReplies',
key: 'showOtherReplies',
onPress: showOtherItems,
})
}
}
}
return items
}
/**
* Get the start and end index of a "branch" of the thread. A "branch" is a
* parent and it's children (not siblings). Returned indices are inclusive of
* the parent and its last child.
*
* items[] (index, depth)
* anchor (0, 0)
* branch (1, 1)
* branch (2, 1) (start)
* leaf (3, 2)
* leaf (4, 3)
* leaf (5, 2) (end)
* branch (6, 1)
* branch (7, 1)
*
* const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
*/
export function getBranch(
thread: ApiThreadItem[],
branchStartIndex: number,
branchStartDepth: number,
) {
let end = branchStartIndex
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
const next = thread[ci]
if (next.depth > branchStartDepth) {
end = ci
} else {
end = ci - 1
break
}
}
return {
start: branchStartIndex,
end,
length: end - branchStartIndex,
}
}

View File

@ -0,0 +1,227 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
type AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadOtherV2,
type AppBskyUnspeccedGetPostThreadV2,
type ModerationDecision,
} from '@atproto/api'
export type ApiThreadItem =
| AppBskyUnspeccedGetPostThreadV2.ThreadItem
| AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem
export const postThreadQueryKeyRoot = 'post-thread-v2' as const
export const createPostThreadQueryKey = (props: PostThreadParams) =>
[postThreadQueryKeyRoot, props] as const
export const createPostThreadOtherQueryKey = (
props: Omit<AppBskyUnspeccedGetPostThreadOtherV2.QueryParams, 'anchor'> & {
anchor?: string
},
) => [postThreadQueryKeyRoot, 'other', props] as const
export type PostThreadParams = Pick<
AppBskyUnspeccedGetPostThreadV2.QueryParams,
'sort' | 'prioritizeFollowedUsers'
> & {
anchor?: string
view: 'tree' | 'linear'
}
export type UsePostThreadQueryResult = {
hasOtherReplies: boolean
thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
threadgate?: Omit<AppBskyFeedDefs.ThreadgateView, 'record'> & {
record: AppBskyFeedThreadgate.Record
}
}
export type ThreadItem =
| {
type: 'threadPost'
key: string
uri: string
depth: number
value: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
record: AppBskyFeedPost.Record
}
}
isBlurred: boolean
moderation: ModerationDecision
ui: {
isAnchor: boolean
showParentReplyLine: boolean
showChildReplyLine: boolean
indent: number
isLastChild: boolean
skippedIndentIndices: Set<number>
precedesChildReadMore: boolean
}
}
| {
type: 'threadPostNoUnauthenticated'
key: string
uri: string
depth: number
value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
ui: {
showParentReplyLine: boolean
showChildReplyLine: boolean
}
}
| {
type: 'threadPostNotFound'
key: string
uri: string
depth: number
value: AppBskyUnspeccedDefs.ThreadItemNotFound
}
| {
type: 'threadPostBlocked'
key: string
uri: string
depth: number
value: AppBskyUnspeccedDefs.ThreadItemBlocked
}
| {
type: 'replyComposer'
key: string
}
| {
type: 'showOtherReplies'
key: string
onPress: () => void
}
| {
/*
* Read more replies, downwards in the thread.
*/
type: 'readMore'
key: string
depth: number
href: string
moreReplies: number
skippedIndentIndices: Set<number>
}
| {
/*
* Read more parents, upwards in the thread.
*/
type: 'readMoreUp'
key: string
href: string
}
| {
type: 'skeleton'
key: string
item: 'anchor' | 'reply' | 'replyComposer'
}
/**
* Metadata collected while traversing the raw data from the thread response.
* Some values here can be computed immediately, while others need to be
* computed during a second pass over the thread after we know things like
* total number of replies, the reply index, etc.
*
* The idea here is that these values should be objectively true in all cases,
* such that we can use them later either individually on in composite  to
* drive rendering behaviors.
*/
export type TraversalMetadata = {
/**
* The depth of the post in the reply tree, where 0 is the root post. This is
* calculated on the server.
*/
depth: number
/**
* Indicates if this item is a "read more" link preceding this post that
* continues the thread upwards.
*/
followsReadMoreUp: boolean
/**
* Indicates if the post is the last reply beneath its parent post.
*/
isLastSibling: boolean
/**
* Indicates the post is the end-of-the-line for a given branch of replies.
*/
isLastChild: boolean
/**
* Indicates if the post is the left/lower-most branch of the reply tree.
* Value corresponds to the depth at which this branch started.
*/
isPartOfLastBranchFromDepth?: number
/**
* The depth of the slice immediately following this one, if it exists.
*/
nextItemDepth?: number
/**
* This is a live reference to the parent metadata object. Mutations to this
* are available for later use in children.
*/
parentMetadata?: TraversalMetadata
/**
* Populated during the final traversal of the thread. Denotes whether
* there is a "Read more" link for this item immediately following
* this item.
*/
precedesChildReadMore: boolean
/**
* The depth of the slice immediately preceding this one, if it exists.
*/
prevItemDepth?: number
/**
* Any data needed to be passed along to the "read more" items. Keep this
* trim for better memory usage.
*/
postData: {
uri: string
authorHandle: string
}
/**
* The total number of replies to this post, including those not hydrated
* and returned by the response.
*/
repliesCount: number
/**
* The number of replies to this post not hydrated and returned by the
* response.
*/
repliesUnhydrated: number
/**
* The number of replies that have been seen so far in the traversal.
* Excludes replies that are moderated in some way, since those are not
* "seen" on first load. Use `repliesIndexCounter` for the total number of
* replies that were hydrated in the response.
*
* After traversal, we can use this to calculate if we actually got all the
* replies we expected, or if some were blocked, etc.
*/
repliesSeenCounter: number
/**
* The total number of replies to this post hydrated in this response. Used
* for populating the `replyIndex` of the post by referencing this value on
* the parent.
*/
repliesIndexCounter: number
/**
* The index-0-based index of this reply in the parent post's replies.
*/
replyIndex: number
/**
* Each slice is responsible for rendering reply lines based on its depth.
* This value corresponds to any line indices that can be skipped e.g.
* because there are no further replies below this sub-tree to render.
*/
skippedIndentIndices: Set<number>
/**
* Indicates and stores parent data IF that parent has additional unhydrated
* replies. This value is passed down to children along the left/lower-most
* branch of the tree. When the end is reached, a "read more" is inserted.
*/
upcomingParentReadMore?: TraversalMetadata
}

View File

@ -0,0 +1,170 @@
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
} from '@atproto/api'
import {
type ApiThreadItem,
type ThreadItem,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
import {isDevMode} from '#/storage/hooks/dev-mode'
import * as bsky from '#/types/bsky'
export function getThreadgateRecord(
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
) {
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
view?.record,
AppBskyFeedThreadgate.isRecord,
)
? view?.record
: undefined
}
export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
if (post.record.reply?.root?.uri) {
return new AtUri(post.record.reply.root.uri)
}
}
}
export function getPostRecord(post: AppBskyFeedDefs.PostView) {
return post.record as AppBskyFeedPost.Record
}
export function getTraversalMetadata({
item,
prevItem,
nextItem,
parentMetadata,
}: {
item: ApiThreadItem
prevItem?: ApiThreadItem
nextItem?: ApiThreadItem
parentMetadata?: TraversalMetadata
}): TraversalMetadata {
if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
throw new Error(`Expected thread item to be a post`)
}
const repliesCount = item.value.post.replyCount || 0
const repliesUnhydrated = item.value.moreReplies || 0
const metadata = {
depth: item.depth,
/*
* Unknown until after traversal
*/
isLastChild: false,
/*
* Unknown until after traversal
*/
isLastSibling: false,
/*
* If it's a top level reply, bc we render each top-level branch as a
* separate tree, it's implicitly part of the last branch. For subsequent
* replies, we'll override this after traversal.
*/
isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
nextItemDepth: nextItem?.depth,
parentMetadata,
prevItemDepth: prevItem?.depth,
/*
* Unknown until after traversal
*/
precedesChildReadMore: false,
/*
* Unknown until after traversal
*/
followsReadMoreUp: false,
postData: {
uri: item.uri,
authorHandle: item.value.post.author.handle,
},
repliesCount,
repliesUnhydrated,
repliesSeenCounter: 0,
repliesIndexCounter: 0,
replyIndex: 0,
skippedIndentIndices: new Set<number>(),
}
if (isDevMode()) {
// @ts-ignore dev only for debugging
metadata.postData.text = getPostRecord(item.value.post).text
}
return metadata
}
export function storeTraversalMetadata(
metadatas: Map<string, TraversalMetadata>,
metadata: TraversalMetadata,
) {
metadatas.set(metadata.postData.uri, metadata)
if (isDevMode()) {
// @ts-ignore dev only for debugging
metadatas.set(metadata.postData.text, metadata)
// @ts-ignore
window.__thread = metadatas
}
}
export function getThreadPostUI({
depth,
repliesCount,
prevItemDepth,
isLastChild,
skippedIndentIndices,
repliesSeenCounter,
repliesUnhydrated,
precedesChildReadMore,
followsReadMoreUp,
}: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] {
const isReplyAndHasReplies =
depth > 0 &&
repliesCount > 0 &&
(repliesCount - repliesUnhydrated === repliesSeenCounter ||
repliesSeenCounter > 0)
return {
isAnchor: depth === 0,
showParentReplyLine:
followsReadMoreUp ||
(!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
showChildReplyLine: depth < 0 || isReplyAndHasReplies,
indent: depth,
/*
* If there are no slices below this one, or the next slice has a depth <=
* than the depth of this post, it's the last child of the reply tree. It
* is not necessarily the last leaf in the parent branch, since it could
* have another sibling.
*/
isLastChild,
skippedIndentIndices,
precedesChildReadMore: precedesChildReadMore ?? false,
}
}
export function getThreadPostNoUnauthenticatedUI({
depth,
prevItemDepth,
}: {
depth: number
prevItemDepth?: number
nextItemDepth?: number
}): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] {
return {
showChildReplyLine: depth < 0,
showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
}
}

View File

@ -0,0 +1,183 @@
import {
type $Typed,
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
moderatePost,
type ModerationOpts,
} from '@atproto/api'
import {makeProfileLink} from '#/lib/routes/links'
import {
type ApiThreadItem,
type ThreadItem,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
export function threadPostNoUnauthenticated({
uri,
depth,
value,
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> {
return {
type: 'threadPostNoUnauthenticated',
key: uri,
uri,
depth,
value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated,
// @ts-ignore populated by the traversal
ui: {},
}
}
export function threadPostNotFound({
uri,
depth,
value,
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNotFound'}> {
return {
type: 'threadPostNotFound',
key: uri,
uri,
depth,
value: value as AppBskyUnspeccedDefs.ThreadItemNotFound,
}
}
export function threadPostBlocked({
uri,
depth,
value,
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostBlocked'}> {
return {
type: 'threadPostBlocked',
key: uri,
uri,
depth,
value: value as AppBskyUnspeccedDefs.ThreadItemBlocked,
}
}
export function threadPost({
uri,
depth,
value,
moderationOpts,
threadgateHiddenReplies,
}: {
uri: string
depth: number
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
moderationOpts: ModerationOpts
threadgateHiddenReplies: Set<string>
}): Extract<ThreadItem, {type: 'threadPost'}> {
const moderation = moderatePost(value.post, moderationOpts)
const modui = moderation.ui('contentList')
const blurred = modui.blur || modui.filter
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
const isBlurred = hiddenByThreadgate || blurred || muted
return {
type: 'threadPost',
key: uri,
uri,
depth,
value: {
...value,
/*
* Do not spread anything here, load bearing for post shadow strict
* equality reference checks.
*/
post: value.post as Omit<AppBskyFeedDefs.PostView, 'record'> & {
record: AppBskyFeedPost.Record
},
},
isBlurred,
moderation,
// @ts-ignore populated by the traversal
ui: {},
}
}
export function readMore({
depth,
repliesUnhydrated,
skippedIndentIndices,
postData,
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMore'}> {
const urip = new AtUri(postData.uri)
const href = makeProfileLink(
{
did: urip.host,
handle: postData.authorHandle,
},
'post',
urip.rkey,
)
return {
type: 'readMore' as const,
key: `readMore:${postData.uri}`,
href,
moreReplies: repliesUnhydrated,
depth,
skippedIndentIndices,
}
}
export function readMoreUp({
postData,
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMoreUp'}> {
const urip = new AtUri(postData.uri)
const href = makeProfileLink(
{
did: urip.host,
handle: postData.authorHandle,
},
'post',
urip.rkey,
)
return {
type: 'readMoreUp' as const,
key: `readMoreUp:${postData.uri}`,
href,
}
}
export function skeleton({
key,
item,
}: Omit<Extract<ThreadItem, {type: 'skeleton'}>, 'type'>): Extract<
ThreadItem,
{type: 'skeleton'}
> {
return {
type: 'skeleton',
key,
item,
}
}
export function postViewToThreadPlaceholder(
post: AppBskyFeedDefs.PostView,
): $Typed<
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
}
> {
return {
$type: 'app.bsky.unspecced.getPostThreadV2#threadItem',
uri: post.uri,
depth: 0, // reset to 0 for highlighted post
value: {
$type: 'app.bsky.unspecced.defs#threadItemPost',
post,
opThread: false,
moreParents: false,
moreReplies: 0,
hiddenByThreadgate: false,
mutedByViewer: false,
},
}
}

View File

@ -2,6 +2,7 @@ import React from 'react'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyUnspeccedGetPostThreadV2,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/macro'
@ -24,9 +25,17 @@ export interface ComposerOptsPostRef {
moderation?: ModerationDecision
}
export type OnPostSuccessData =
| {
replyToUri?: string
posts: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
}
| undefined
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: (postUri: string | undefined) => void
onPostSuccess?: (data: OnPostSuccessData) => void
quote?: AppBskyFeedDefs.PostView
mention?: string // handle of user to mention
openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void

View File

@ -83,3 +83,17 @@ export function useMergedThreadgateHiddenReplies({
return set
}, [uris, recentlyUnhiddenUris, threadgateRecord])
}
export function useMergeThreadgateHiddenReplies() {
const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
return React.useCallback(
(threadgate?: AppBskyFeedThreadgate.Record) => {
const set = new Set([...(threadgate?.hiddenReplies || []), ...uris])
for (const uri of recentlyUnhiddenUris) {
set.delete(uri)
}
return set
},
[uris, recentlyUnhiddenUris],
)
}

View File

@ -5,3 +5,17 @@ export function useDevMode() {
return [devMode, setDevMode] as const
}
let cachedIsDevMode: boolean | undefined
/**
* Does not update when toggling dev mode on or off. This util simply retrieves
* the value and caches in memory indefinitely. So after an update, you'll need
* to reload the app so it can pull a fresh value from storage.
*/
export function isDevMode() {
if (__DEV__) return true
if (cachedIsDevMode === undefined) {
cachedIsDevMode = device.get(['devMode']) ?? false
}
return cachedIsDevMode
}

5
src/types/utils.ts Normal file
View File

@ -0,0 +1,5 @@
export type Literal<T, A = string> = T extends A
? string extends T
? never
: T
: never

View File

@ -45,6 +45,7 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyFeedDefs,
type AppBskyFeedGetPostThread,
AppBskyUnspeccedDefs,
type BskyAgent,
type RichText,
} from '@atproto/api'
@ -55,6 +56,7 @@ import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until'
import {
MAX_GRAPHEME_LENGTH,
@ -87,7 +89,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {
@ -152,6 +154,7 @@ type Props = ComposerOpts
export const ComposePost = ({
replyTo,
onPost,
onPostSuccess,
quote: initQuote,
mention: initMention,
openEmojiPicker,
@ -388,8 +391,10 @@ export const ComposePost = ({
setError('')
setIsPublishing(true)
let postUri
let postUri: string | undefined
let postSuccessData: OnPostSuccessData
try {
logger.info(`composer: posting...`)
postUri = (
await apilib.post(agent, queryClient, {
thread,
@ -398,16 +403,48 @@ export const ComposePost = ({
langs: toPostLanguages(langPrefs.postLanguage),
})
).uris[0]
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
*/
try {
await whenAppViewReady(agent, postUri, res => {
const postedThread = res?.data?.thread
return AppBskyFeedDefs.isThreadViewPost(postedThread)
})
if (postUri) {
logger.info(`composer: waiting for app view`)
const posts = await retry(
5,
_e => true,
async () => {
const res = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: postUri!,
above: false,
below: thread.posts.length - 1,
branchingFactor: 1,
})
if (res.data.thread.length !== thread.posts.length) {
throw new Error(`composer: app view is not ready`)
}
if (
!res.data.thread.every(p =>
AppBskyUnspeccedDefs.isThreadItemPost(p.value),
)
) {
throw new Error(`composer: app view returned non-post items`)
}
return res.data.thread
},
1e3,
)
postSuccessData = {
replyToUri: replyTo?.uri,
posts,
}
}
} catch (waitErr: any) {
logger.error(waitErr, {
message: `Waiting for app view failed`,
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
// Keep going because the post *was* published.
}
} catch (e: any) {
logger.error(e, {
@ -465,12 +502,14 @@ export const ComposePost = ({
quotedThread.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
return true
}
return false
})
} else {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
}
onClose()
Toast.show(
@ -489,6 +528,7 @@ export const ComposePost = ({
langPrefs.postLanguage,
onClose,
onPost,
onPostSuccess,
initQuote,
replyTo,
setLangPrefs,

View File

@ -1,8 +1,7 @@
import React, {memo, useRef, useState} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import {useWindowDimensions, View} from 'react-native'
import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
AppBskyFeedDefs,
type AppBskyFeedThreadgate,
@ -13,11 +12,9 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {cleanError} from '#/lib/strings/errors'
@ -37,6 +34,7 @@ import {
import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useUnstablePostSource} from '#/state/unstable-post-source'
import {List, type ListMethods} from '#/view/com/util/List'
@ -301,11 +299,14 @@ export function PostThread({uri}: {uri: string}) {
// maintainVisibleContentPosition and onContentSizeChange
// to "hold onto" the correct row instead of the first one.
/*
* This is basically `!!parents.length`, see notes on `isParentLoading`
*/
if (!highlightedPost.ctx.isParentLoading && !deferParents) {
// When progressively revealing parents, rendering a placeholder
// here will cause scrolling jumps. Don't add it unless you test it.
// QT'ing this thread is a great way to test all the scrolling hacks:
// https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
// https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
// Everything is loaded
let startIndex = Math.max(0, parents.length - maxParents)
@ -581,6 +582,9 @@ export function PostThread({uri}: {uri: string}) {
onEndReached={onEndReached}
onEndReachedThreshold={2}
onScrollToTop={onScrollToTop}
/**
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
*/
maintainVisibleContentPosition={
isNative && hasParents
? MAINTAIN_VISIBLE_CONTENT_POSITION
@ -729,17 +733,16 @@ let ThreadMenu = ({
ThreadMenu = memo(ThreadMenu)
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const safeAreaInsets = useSafeAreaInsets()
const fabMinimalShellTransform = useMinimalShellFabTransform()
const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => {
return {
bottom: footerHeight.get(),
}
})
return (
<Animated.View
style={[
styles.prompt,
fabMinimalShellTransform,
{
bottom: clamp(safeAreaInsets.bottom, 13, 60),
},
]}>
<Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
@ -904,12 +907,3 @@ function hasBranchingReplies(node?: ThreadNode) {
}
return true
}
const styles = StyleSheet.create({
prompt: {
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},
})

View File

@ -1,20 +1,25 @@
import {View} from 'react-native'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function PostThreadComposePrompt({
onPressCompose,
style,
}: {
onPressCompose: () => void
style?: StyleProp<ViewStyle>
}) {
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
@ -28,29 +33,49 @@ export function PostThreadComposePrompt({
onOut: onHoverOut,
} = useInteractionState()
useHideBottomBarBorderForScreen()
return (
<PressableScale
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
<View
style={[
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
]}
onPress={() => {
onPressCompose()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPressCompose()
playHaptic('Heavy')
})}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
<View
gtMobile
? [
a.py_xs,
a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
]
: [a.px_md, a.pb_2xs],
style,
]}>
{!gtMobile && (
<LinearGradient
key={t.name} // android does not update when you change the colors. sigh.
start={[0.5, 0]}
end={[0.5, 1]}
colors={[
transparentifyColor(t.atoms.bg.backgroundColor, 0),
t.atoms.bg.backgroundColor,
]}
locations={[0.15, 0.4]}
style={[a.absolute, a.inset_0]}
/>
)}
<PressableScale
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}
onPress={() => {
onPressCompose()
playHaptic('Light')
}}
onLongPress={ios(() => {
onPressCompose()
playHaptic('Heavy')
})}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
style={[
a.flex_row,
a.align_center,
@ -58,6 +83,7 @@ export function PostThreadComposePrompt({
a.gap_sm,
a.rounded_full,
(!gtMobile || hovered) && t.atoms.bg_contrast_25,
native([a.border, t.atoms.border_contrast_low]),
a.transition_color,
]}>
<UserAvatar
@ -68,7 +94,7 @@ export function PostThreadComposePrompt({
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Write your reply</Trans>
</Text>
</View>
</PressableScale>
</PressableScale>
</View>
)
}

View File

@ -39,6 +39,7 @@ import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {type ThreadPost} from '#/state/queries/post-thread'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
@ -85,6 +86,7 @@ export function PostThreadItem({
hasPrecedingItem,
overrideBlur,
onPostReply,
onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
@ -103,6 +105,7 @@ export function PostThreadItem({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
@ -139,6 +142,7 @@ export function PostThreadItem({
hasPrecedingItem={hasPrecedingItem}
overrideBlur={overrideBlur}
onPostReply={onPostReply}
onPostSuccess={onPostSuccess}
hideTopBorder={hideTopBorder}
threadgateRecord={threadgateRecord}
anchorPostSource={anchorPostSource}
@ -185,6 +189,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem,
overrideBlur,
onPostReply,
onPostSuccess,
hideTopBorder,
threadgateRecord,
anchorPostSource,
@ -204,6 +209,7 @@ let PostThreadItemLoaded = ({
hasPrecedingItem: boolean
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
onPostSuccess?: (data: OnPostSuccessData) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
anchorPostSource?: PostSource
@ -298,6 +304,7 @@ let PostThreadItemLoaded = ({
moderation,
},
onPost: onPostReply,
onPostSuccess: onPostSuccess,
})
}

View File

@ -1,28 +1,38 @@
import React from 'react'
import {useCallback} from 'react'
import {useFocusEffect} from '@react-navigation/native'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
import {PostThread} from '#/screens/PostThread'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export function PostThreadScreen({route}: Props) {
const setMinimalShellMode = useSetMinimalShellMode()
const gate = useGate()
const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
useFocusEffect(
React.useCallback(() => {
useCallback(() => {
setMinimalShellMode(false)
}, [setMinimalShellMode]),
)
return (
<Layout.Screen testID="postThreadScreen">
<PostThreadComponent uri={uri} />
{gate('post_threads_v2_unspecced') || __DEV__ ? (
<PostThread uri={uri} />
) : (
<PostThreadComponent uri={uri} />
)}
</Layout.Screen>
)
}

View File

@ -37,6 +37,7 @@ export function Composer({}: {winHeight: number}) {
cancelRef={ref}
replyTo={state?.replyTo}
onPost={state?.onPost}
onPostSuccess={state?.onPostSuccess}
quote={state?.quote}
mention={state?.mention}
text={state?.text}

View File

@ -49,6 +49,7 @@ export function Composer({winHeight}: {winHeight: number}) {
<ComposePost
replyTo={state.replyTo}
onPost={state.onPost}
onPostSuccess={state.onPostSuccess}
quote={state.quote}
mention={state.mention}
text={state.text}

View File

@ -105,6 +105,7 @@ function Inner({state}: {state: ComposerOpts}) {
replyTo={state.replyTo}
quote={state.quote}
onPost={state.onPost}
onPostSuccess={state.onPostSuccess}
mention={state.mention}
openEmojiPicker={onOpenPicker}
text={state.text}

View File

@ -12,6 +12,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {BOTTOM_BAR_AVI} from '#/lib/demo'
import {useHaptics} from '#/lib/haptics'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
import {usePalette} from '#/lib/hooks/usePalette'
@ -73,6 +74,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const playHaptic = useHaptics()
const hasHomeBadge = useHomeBadge()
const gate = useGate()
const hideBorder = useHideBottomBarBorder()
const iconWidth = 28
const showSignIn = useCallback(() => {
@ -146,7 +148,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
style={[
styles.bottomBar,
pal.view,
pal.border,
hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
footerMinimalShellTransform,
]}

View File

@ -5,16 +5,18 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigationState} from '@react-navigation/native'
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {useHomeBadge} from '#/state/home-badge'
import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useCloseAllActiveElements} from '#/state/util'
import {Link} from '#/view/com/util/Link'
import {Logo} from '#/view/icons/Logo'
@ -49,6 +51,8 @@ export function BottomBarWeb() {
const footerMinimalShellTransform = useMinimalShellFooterTransform()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const {footerHeight} = useShellLayout()
const hideBorder = useHideBottomBarBorder()
const iconWidth = 26
const unreadMessageCount = useUnreadMessageCount()
@ -74,9 +78,12 @@ export function BottomBarWeb() {
styles.bottomBar,
styles.bottomBarWeb,
t.atoms.bg,
t.atoms.border_contrast_low,
hideBorder
? {borderColor: t.atoms.bg.backgroundColor}
: t.atoms.border_contrast_low,
footerMinimalShellTransform,
]}>
]}
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
{hasSession ? (
<>
<NavItem

View File

@ -63,6 +63,20 @@
"@atproto/xrpc" "^0.7.0"
"@atproto/xrpc-server" "^0.7.18"
"@atproto/api@^0.15.14":
version "0.15.14"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.14.tgz#41ff6ce2e7603119a005b7b5ce8e64551ec84879"
integrity sha512-FHEMAdscG+r2OFcZUIzPyTDpwzRAyinRsIIaTcuqe0MgZWF4CEGNAKPos0IbecBzMxTOzUHE18dQDKhoXMdgvg==
dependencies:
"@atproto/common-web" "^0.4.2"
"@atproto/lexicon" "^0.4.11"
"@atproto/syntax" "^0.4.0"
"@atproto/xrpc" "^0.7.0"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/api@^0.15.9":
version "0.15.9"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.9.tgz#f8c40afd6e414ab107d63d6f08d9e264bf9a149a"