More reorg
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
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({
|
||||
sortReplies,
|
||||
treeViewEnabled,
|
||||
setSortReplies,
|
||||
setTreeViewEnabled,
|
||||
}: {
|
||||
sortReplies: string
|
||||
treeViewEnabled: boolean
|
||||
setSortReplies: (newValue: string) => void
|
||||
setTreeViewEnabled: (newValue: boolean) => void
|
||||
}): React.ReactNode {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Thread options`)}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
label={_(msg`Thread options`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
hitSlop={HITSLOP_10}
|
||||
{...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={() => {
|
||||
setTreeViewEnabled(false)
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Linear</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={!treeViewEnabled} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Threaded`)}
|
||||
onPress={() => {
|
||||
setTreeViewEnabled(true)
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Threaded</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={treeViewEnabled} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.LabelText>
|
||||
<Trans>Reply sorting</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Top replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('top')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Top replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'top'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Oldest replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('oldest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Oldest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'oldest'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Newest replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('newest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Newest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'newest'} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import {useMemo, useRef, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {
|
||||
HiddenReplyKind,
|
||||
type Slice,
|
||||
usePostThread,
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
|
||||
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
|
||||
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
|
||||
import {ReadMore} from '#/screens/PostThread/components/ReadMore'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Inner({uri}: {uri: string | undefined}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
// const {hasSession, currentAccount} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
const {
|
||||
isLoaded: isThreadPreferencesLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
} = useThreadPreferences()
|
||||
|
||||
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
|
||||
Set<HiddenReplyKind>
|
||||
>(new Set())
|
||||
|
||||
const {isFetching, error, data, refetch, insertReplies} = usePostThread({
|
||||
enabled: isThreadPreferencesLoaded,
|
||||
params: {
|
||||
anchor: uri,
|
||||
sort: sortReplies,
|
||||
view: treeViewEnabled ? 'tree' : 'linear',
|
||||
prioritizeFollowedUsers,
|
||||
},
|
||||
state: {
|
||||
shownHiddenReplyKinds,
|
||||
},
|
||||
})
|
||||
|
||||
const optimisticOnPostReply = (data: OnPostSuccessData) => {
|
||||
if (data) {
|
||||
const {replyToUri, posts} = data
|
||||
if (replyToUri && posts.length) {
|
||||
insertReplies(replyToUri, posts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {openComposer} = useOpenComposer()
|
||||
const onReplyToAnchor = () => {
|
||||
const anchorPost = data?.items.find(
|
||||
slice => slice.type === 'threadPost' && slice.ui.isAnchor,
|
||||
)
|
||||
if (anchorPost?.type !== 'threadPost') {
|
||||
return
|
||||
}
|
||||
const post = anchorPost.value.post
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: anchorPost.uri,
|
||||
cid: post.cid,
|
||||
text: post.record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation: anchorPost.moderation,
|
||||
},
|
||||
onPostSuccess: optimisticOnPostReply,
|
||||
})
|
||||
}
|
||||
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const headerRef = useRef<View | null>(null)
|
||||
const anchorRef = useRef<View | null>(null)
|
||||
/**
|
||||
* WEB ONLY
|
||||
*
|
||||
* Fires any time the content of the list changes. If user switches back to a
|
||||
* sort that was rendered previously, this does NOT fire. Therefore, scroll
|
||||
* is only reset to the anchor on initial render, or fresh data.
|
||||
*
|
||||
* When this fires, the `List` is scrolled all the way to the top, so
|
||||
* measurements taken from `top` correspond to the top of the screen. This
|
||||
* handler scrolls the `List` to the top of the highlighted post, minus any
|
||||
* fixed elements.
|
||||
*/
|
||||
const onContentSizeChangeWebOnly = web(() => {
|
||||
const anchorElement = anchorRef.current as any as Element
|
||||
const headerElement = headerRef.current as any as Element
|
||||
if (anchorElement && headerElement) {
|
||||
// distance from top of the list (screen)
|
||||
const anchorOffsetTop = anchorElement.getBoundingClientRect().top
|
||||
const headerHeight = headerElement.getBoundingClientRect().height
|
||||
// don't scroll past 0
|
||||
const scrollPosition = Math.max(0, anchorOffsetTop - headerHeight)
|
||||
listRef.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: scrollPosition,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* On native, any time we navigate to a new post/reply (even if the data is
|
||||
* cached), we skip rendering parents so that the anchor post is the first
|
||||
* item in the list. That way,
|
||||
* `maintainVisibleContentPosition={{minIndexForVisible: 0}}` will pin the
|
||||
* anchor post to the top of the screen, and on the next render, we'll
|
||||
* include parents.
|
||||
*
|
||||
* On the web this is not necessary because we can synchronously adjust the
|
||||
* scroll in onContentSizeChange instead.
|
||||
*/
|
||||
const [deferParents, setDeferParents] = useState(isNative)
|
||||
const items = useMemo(() => {
|
||||
return (data?.items ?? []).filter(item => {
|
||||
return !('depth' in item) || item.depth >= 0 || !deferParents
|
||||
})
|
||||
}, [data, deferParents])
|
||||
|
||||
const renderItem = ({item, index}: {item: Slice; index: number}) => {
|
||||
if (item.type === 'threadPost') {
|
||||
return (
|
||||
<View
|
||||
ref={item.ui.isAnchor ? anchorRef : undefined}
|
||||
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
|
||||
<PostThreadItem
|
||||
post={item.value.post}
|
||||
record={item.value.post.record}
|
||||
threadgateRecord={data?.threadgate?.record ?? undefined}
|
||||
moderation={item.moderation}
|
||||
treeView={treeViewEnabled}
|
||||
depth={item.depth}
|
||||
// TODO
|
||||
// prevPost={prev}
|
||||
// nextPost={next}
|
||||
isHighlightedPost={item.ui.isAnchor}
|
||||
hasMore={false} // TODO need to replace this entirely
|
||||
showChildReplyLine={item.ui.showChildReplyLine}
|
||||
showParentReplyLine={item.ui.showParentReplyLine}
|
||||
hasPrecedingItem={item.ui.showParentReplyLine} // !!hasUnrevealedParents // TODO
|
||||
overrideBlur={
|
||||
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && item.depth > 0
|
||||
}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
hideTopBorder={index === 0} // && !item.isParentLoading} // TODO
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'readMore') {
|
||||
return <ReadMore item={item} view={treeViewEnabled ? 'tree' : 'linear'} />
|
||||
} else if (item.type === 'threadPostBlocked') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
index !== 0 && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Blocked post.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'threadPostNotFound') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
index !== 0 && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Deleted post.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'replyComposer') {
|
||||
return (
|
||||
<View>
|
||||
{gtPhone && (
|
||||
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'showHiddenReplies') {
|
||||
return (
|
||||
<PostThreadShowHiddenReplies
|
||||
type={item.kind === 'muted' ? 'muted' : 'hidden'}
|
||||
onPress={() =>
|
||||
setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
sortReplies={sortReplies}
|
||||
treeViewEnabled={treeViewEnabled}
|
||||
setSortReplies={setSortReplies}
|
||||
setTreeViewEnabled={setTreeViewEnabled}
|
||||
/>
|
||||
</Layout.Header.Slot>
|
||||
</Layout.Header.Outer>
|
||||
|
||||
{error ? (
|
||||
<PostThreadError error={error} />
|
||||
) : (
|
||||
<ScrollProvider
|
||||
// onMomentumEnd={onMomentumEnd}
|
||||
>
|
||||
<List
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={onContentSizeChangeWebOnly}
|
||||
// onStartReached={onStartReached}
|
||||
// onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
// onScrollToTop={onScrollToTop}
|
||||
/**
|
||||
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
|
||||
*/
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? {minIndexForVisible: 0} : undefined
|
||||
}
|
||||
desktopFixedHeight
|
||||
// removeClippedSubviews={isAndroid ? false : undefined}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
/*
|
||||
* Using `isFetching` over `isFetchingNextPage` is done on
|
||||
* purpose here so we get the loader on initial render
|
||||
*/
|
||||
isFetchingNextPage={isFetching}
|
||||
error={cleanError(error)}
|
||||
onRetry={refetch}
|
||||
/*
|
||||
* 200 is based on the minimum height of a post. This is enough
|
||||
* extra height for the `maintainVisPos` to work without
|
||||
* causing weird jumps on web or glitches on native
|
||||
*/
|
||||
height={windowHeight - 200}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PostThreadError({error}: {error: Error}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
// TODO use new cleanError hook
|
||||
const {title: _title, message: _message} = useMemo(() => {
|
||||
let title = _(msg`An error occurred`)
|
||||
let message = cleanError(error)
|
||||
|
||||
if (error.message.startsWith('Post not found')) {
|
||||
title = _(msg`Post not found`)
|
||||
message = _(msg`The post may have been deleted.`)
|
||||
}
|
||||
return {title, message}
|
||||
}, [_, error])
|
||||
|
||||
return <View />
|
||||
}
|
||||
|
||||
const keyExtractor = (item: Slice) => {
|
||||
return item.key
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
|
||||
export function useThreadPreferences() {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const nextThreadPreferences = preferences?.threadViewPrefs
|
||||
|
||||
/*
|
||||
* Create local state representations of server state
|
||||
*/
|
||||
const [sortReplies, setSortReplies] = useState(
|
||||
nextThreadPreferences?.sort ?? 'hotness',
|
||||
)
|
||||
const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
|
||||
!!nextThreadPreferences?.prioritizeFollowedUsers,
|
||||
)
|
||||
const [treeViewEnabled, setTreeViewEnabled] = useState(
|
||||
!!nextThreadPreferences?.lab_treeViewEnabled,
|
||||
)
|
||||
|
||||
/**
|
||||
* Cache existing and if we get a server update, reset local state
|
||||
*/
|
||||
const [prevServerPrefs, setPrevServerPrefs] = useState(nextThreadPreferences)
|
||||
if (nextThreadPreferences && prevServerPrefs !== nextThreadPreferences) {
|
||||
setPrevServerPrefs(nextThreadPreferences)
|
||||
|
||||
/*
|
||||
* Reset
|
||||
*/
|
||||
setSortReplies(nextThreadPreferences.sort)
|
||||
setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers)
|
||||
setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled)
|
||||
}
|
||||
|
||||
const isLoaded = !!prevServerPrefs
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
}),
|
||||
[
|
||||
isLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,15 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {msg, Trans, Plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useCallback} from 'react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
// import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {
|
||||
HiddenReplyKind,
|
||||
type Slice,
|
||||
usePostThread,
|
||||
PostThreadParams,
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
|
||||
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
|
||||
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus'
|
||||
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
||||
import {Inner} from '#/screens/PostThread'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReadMore} from '#/screens/PostThread/components/ReadMore'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
||||
export function PostThreadScreen({route}: Props) {
|
||||
@@ -61,442 +31,3 @@ export function PostThreadScreen({route}: Props) {
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function useThreadPreferences() {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const nextThreadPreferences = preferences?.threadViewPrefs
|
||||
|
||||
/*
|
||||
* Create local state representations of server state
|
||||
*/
|
||||
const [sortReplies, setSortReplies] = useState(
|
||||
nextThreadPreferences?.sort ?? 'hotness',
|
||||
)
|
||||
const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState(
|
||||
!!nextThreadPreferences?.prioritizeFollowedUsers,
|
||||
)
|
||||
const [treeViewEnabled, setTreeViewEnabled] = useState(
|
||||
!!nextThreadPreferences?.lab_treeViewEnabled,
|
||||
)
|
||||
|
||||
/**
|
||||
* Cache existing and if we get a server update, reset local state
|
||||
*/
|
||||
const [prevServerPrefs, setPrevServerPrefs] = useState(nextThreadPreferences)
|
||||
if (nextThreadPreferences && prevServerPrefs !== nextThreadPreferences) {
|
||||
setPrevServerPrefs(nextThreadPreferences)
|
||||
|
||||
/*
|
||||
* Reset
|
||||
*/
|
||||
setSortReplies(nextThreadPreferences.sort)
|
||||
setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers)
|
||||
setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled)
|
||||
}
|
||||
|
||||
const isLoaded = !!prevServerPrefs
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
}),
|
||||
[
|
||||
isLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
setPrioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({uri}: {uri: string | undefined}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
// const {hasSession, currentAccount} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
const {
|
||||
isLoaded: isThreadPreferencesLoaded,
|
||||
sortReplies,
|
||||
setSortReplies,
|
||||
prioritizeFollowedUsers,
|
||||
treeViewEnabled,
|
||||
setTreeViewEnabled,
|
||||
} = useThreadPreferences()
|
||||
|
||||
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
|
||||
Set<HiddenReplyKind>
|
||||
>(new Set())
|
||||
|
||||
const {isFetching, error, data, refetch, insertReplies} = usePostThread({
|
||||
enabled: isThreadPreferencesLoaded,
|
||||
params: {
|
||||
anchor: uri,
|
||||
sort: sortReplies,
|
||||
view: treeViewEnabled ? 'tree' : 'linear',
|
||||
prioritizeFollowedUsers,
|
||||
},
|
||||
state: {
|
||||
shownHiddenReplyKinds,
|
||||
},
|
||||
})
|
||||
|
||||
const optimisticOnPostReply = (data: OnPostSuccessData) => {
|
||||
if (data) {
|
||||
const {replyToUri, posts} = data
|
||||
if (replyToUri && posts.length) {
|
||||
insertReplies(replyToUri, posts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {openComposer} = useOpenComposer()
|
||||
const onReplyToAnchor = () => {
|
||||
const anchorPost = data?.items.find(
|
||||
slice => slice.type === 'threadPost' && slice.ui.isAnchor,
|
||||
)
|
||||
if (anchorPost?.type !== 'threadPost') {
|
||||
return
|
||||
}
|
||||
const post = anchorPost.value.post
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: anchorPost.uri,
|
||||
cid: post.cid,
|
||||
text: post.record.text,
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation: anchorPost.moderation,
|
||||
},
|
||||
onPostSuccess: optimisticOnPostReply,
|
||||
})
|
||||
}
|
||||
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const headerRef = useRef<View | null>(null)
|
||||
const anchorRef = useRef<View | null>(null)
|
||||
/**
|
||||
* WEB ONLY
|
||||
*
|
||||
* Fires any time the content of the list changes. If user switches back to a
|
||||
* sort that was rendered previously, this does NOT fire. Therefore, scroll
|
||||
* is only reset to the anchor on initial render, or fresh data.
|
||||
*
|
||||
* When this fires, the `List` is scrolled all the way to the top, so
|
||||
* measurements taken from `top` correspond to the top of the screen. This
|
||||
* handler scrolls the `List` to the top of the highlighted post, minus any
|
||||
* fixed elements.
|
||||
*/
|
||||
const onContentSizeChangeWebOnly = web(() => {
|
||||
const anchorElement = anchorRef.current as any as Element
|
||||
const headerElement = headerRef.current as any as Element
|
||||
if (anchorElement && headerElement) {
|
||||
// distance from top of the list (screen)
|
||||
const anchorOffsetTop = anchorElement.getBoundingClientRect().top
|
||||
const headerHeight = headerElement.getBoundingClientRect().height
|
||||
// don't scroll past 0
|
||||
const scrollPosition = Math.max(0, anchorOffsetTop - headerHeight)
|
||||
listRef.current?.scrollToOffset({
|
||||
animated: false,
|
||||
offset: scrollPosition,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* On native, any time we navigate to a new post/reply (even if the data is
|
||||
* cached), we skip rendering parents so that the anchor post is the first
|
||||
* item in the list. That way,
|
||||
* `maintainVisibleContentPosition={{minIndexForVisible: 0}}` will pin the
|
||||
* anchor post to the top of the screen, and on the next render, we'll
|
||||
* include parents.
|
||||
*
|
||||
* On the web this is not necessary because we can synchronously adjust the
|
||||
* scroll in onContentSizeChange instead.
|
||||
*/
|
||||
const [deferParents, setDeferParents] = useState(isNative)
|
||||
const items = useMemo(() => {
|
||||
return (data?.items ?? []).filter(item => {
|
||||
return !('depth' in item) || item.depth >= 0 || !deferParents
|
||||
})
|
||||
}, [data, deferParents])
|
||||
|
||||
const renderItem = ({item, index}: {item: Slice; index: number}) => {
|
||||
if (item.type === 'threadPost') {
|
||||
return (
|
||||
<View
|
||||
ref={item.ui.isAnchor ? anchorRef : undefined}
|
||||
onLayout={deferParents ? () => setDeferParents(false) : undefined}>
|
||||
<PostThreadItem
|
||||
post={item.value.post}
|
||||
record={item.value.post.record}
|
||||
threadgateRecord={data?.threadgate?.record ?? undefined}
|
||||
moderation={item.moderation}
|
||||
treeView={treeViewEnabled}
|
||||
depth={item.depth}
|
||||
// TODO
|
||||
// prevPost={prev}
|
||||
// nextPost={next}
|
||||
isHighlightedPost={item.ui.isAnchor}
|
||||
hasMore={false} // TODO need to replace this entirely
|
||||
showChildReplyLine={item.ui.showChildReplyLine}
|
||||
showParentReplyLine={item.ui.showParentReplyLine}
|
||||
hasPrecedingItem={item.ui.showParentReplyLine} // !!hasUnrevealedParents // TODO
|
||||
overrideBlur={
|
||||
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && item.depth > 0
|
||||
}
|
||||
onPostSuccess={optimisticOnPostReply}
|
||||
hideTopBorder={index === 0} // && !item.isParentLoading} // TODO
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'readMore') {
|
||||
return (
|
||||
<ReadMore item={item} view={treeViewEnabled ? 'tree' : 'linear'} />
|
||||
)
|
||||
} else if (item.type === 'threadPostBlocked') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
index !== 0 && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Blocked post.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'threadPostNotFound') {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
index !== 0 && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Deleted post.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'replyComposer') {
|
||||
return (
|
||||
<View>
|
||||
{gtPhone && (
|
||||
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item.type === 'showHiddenReplies') {
|
||||
return (
|
||||
<PostThreadShowHiddenReplies
|
||||
type={item.kind === 'muted' ? 'muted' : 'hidden'}
|
||||
onPress={() =>
|
||||
setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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>
|
||||
<ThreadMenu
|
||||
sortReplies={sortReplies}
|
||||
treeViewEnabled={treeViewEnabled}
|
||||
setSortReplies={setSortReplies}
|
||||
setTreeViewEnabled={setTreeViewEnabled}
|
||||
/>
|
||||
</Layout.Header.Slot>
|
||||
</Layout.Header.Outer>
|
||||
|
||||
{error ? (
|
||||
<PostThreadError error={error} />
|
||||
) : (
|
||||
<ScrollProvider
|
||||
// onMomentumEnd={onMomentumEnd}
|
||||
>
|
||||
<List
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={onContentSizeChangeWebOnly}
|
||||
// onStartReached={onStartReached}
|
||||
// onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
// onScrollToTop={onScrollToTop}
|
||||
/**
|
||||
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
|
||||
*/
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? {minIndexForVisible: 0} : undefined
|
||||
}
|
||||
desktopFixedHeight
|
||||
// removeClippedSubviews={isAndroid ? false : undefined}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
/*
|
||||
* Using `isFetching` over `isFetchingNextPage` is done on
|
||||
* purpose here so we get the loader on initial render
|
||||
*/
|
||||
isFetchingNextPage={isFetching}
|
||||
error={cleanError(error)}
|
||||
onRetry={refetch}
|
||||
/*
|
||||
* 200 is based on the minimum height of a post. This is enough
|
||||
* extra height for the `maintainVisPos` to work without
|
||||
* causing weird jumps on web or glitches on native
|
||||
*/
|
||||
height={windowHeight - 200}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PostThreadError({error}: {error: Error}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
// TODO use new cleanError hook
|
||||
const {title: _title, message: _message} = useMemo(() => {
|
||||
let title = _(msg`An error occurred`)
|
||||
let message = cleanError(error)
|
||||
|
||||
if (error.message.startsWith('Post not found')) {
|
||||
title = _(msg`Post not found`)
|
||||
message = _(msg`The post may have been deleted.`)
|
||||
}
|
||||
return {title, message}
|
||||
}, [_, error])
|
||||
|
||||
return <View />
|
||||
}
|
||||
|
||||
function ThreadMenu({
|
||||
sortReplies,
|
||||
treeViewEnabled,
|
||||
setSortReplies,
|
||||
setTreeViewEnabled,
|
||||
}: {
|
||||
sortReplies: string
|
||||
treeViewEnabled: boolean
|
||||
setSortReplies: (newValue: string) => void
|
||||
setTreeViewEnabled: (newValue: boolean) => void
|
||||
}): React.ReactNode {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Thread options`)}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
label={_(msg`Thread options`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
hitSlop={HITSLOP_10}
|
||||
{...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={() => {
|
||||
setTreeViewEnabled(false)
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Linear</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={!treeViewEnabled} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Threaded`)}
|
||||
onPress={() => {
|
||||
setTreeViewEnabled(true)
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Threaded</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={treeViewEnabled} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.LabelText>
|
||||
<Trans>Reply sorting</Trans>
|
||||
</Menu.LabelText>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Top replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('top')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Top replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'top'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Oldest replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('oldest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Oldest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'oldest'} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Newest replies first`)}
|
||||
onPress={() => {
|
||||
setSortReplies('newest')
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Newest replies first</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemRadio selected={sortReplies === 'newest'} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const keyExtractor = (item: Slice) => {
|
||||
return item.key
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user