Refactor post threads to use react query (#1851)

* Add post and post-thread queries

* Update PostThread components to use new queries

* Move from normalized cache to shadow cache model

* Merge post shadow into the post automatically

* Remove dead code

* Remove old temporary session

* Fix: set agent on session creation

* Temporarily double-login

* Handle post-thread uri resolution errors
zio/stable
Paul Frazee 2023-11-09 15:35:25 -08:00 committed by GitHub
parent 625cbc435f
commit fb4f5709c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1386 additions and 476 deletions

View File

@ -46,7 +46,7 @@ const InnerApp = observer(function AppImpl() {
analytics.init(store) analytics.init(store)
}) })
dynamicActivate(defaultLocale) // async import of locale data dynamicActivate(defaultLocale) // async import of locale data
}, [resumeSession]) }, [])
useEffect(() => { useEffect(() => {
const account = persisted.get('session').currentAccount const account = persisted.get('session').currentAccount

90
src/state/cache/post-shadow.ts vendored 100644
View File

@ -0,0 +1,90 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
repostUri: string | undefined
repostCount: number | undefined
isDeleted: boolean
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
interface CacheEntry {
ts: number
value: PostShadow
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
ifAfterTS: number,
): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromPost(post),
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post.uri, onUpdate])
// react to post updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromPost(post)})
}
firstRun.current = false
}, [post])
return state.ts > ifAfterTS ? mergeShadow(post, state.value) : post
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
emitter.emit(uri, value)
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
return {
likeUri: post.viewer?.like,
likeCount: post.likeCount,
repostUri: post.viewer?.repost,
repostCount: post.repostCount,
isDeleted: false,
}
}
function mergeShadow(
post: AppBskyFeedDefs.PostView,
shadow: PostShadow,
): AppBskyFeedDefs.PostView | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return {
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
viewer: {
...(post.viewer || {}),
like: shadow.likeUri,
repost: shadow.repostUri,
},
}
}

View File

@ -0,0 +1,177 @@
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedGetPostThread,
} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {useSession} from '../session'
import {ThreadViewPreference} from '../models/ui/preferences'
export const RQKEY = (uri: string) => ['post-thread', uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
export interface ThreadCtx {
depth: number
isHighlightedPost?: boolean
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
}
export type ThreadPost = {
type: 'post'
_reactKey: string
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
parent?: ThreadNode
replies?: ThreadNode[]
viewer?: AppBskyFeedDefs.ViewerThreadState
ctx: ThreadCtx
}
export type ThreadNotFound = {
type: 'not-found'
_reactKey: string
uri: string
ctx: ThreadCtx
}
export type ThreadBlocked = {
type: 'blocked'
_reactKey: string
uri: string
ctx: ThreadCtx
}
export type ThreadUnknown = {
type: 'unknown'
uri: string
}
export type ThreadNode =
| ThreadPost
| ThreadNotFound
| ThreadBlocked
| ThreadUnknown
export function usePostThreadQuery(uri: string | undefined) {
const {agent} = useSession()
return useQuery<ThreadNode, Error>(
RQKEY(uri || ''),
async () => {
const res = await agent.getPostThread({uri: uri!})
if (res.success) {
return responseToThreadNodes(res.data.thread)
}
return {type: 'unknown', uri: uri!}
},
{enabled: !!uri},
)
}
export function sortThread(
node: ThreadNode,
opts: ThreadViewPreference,
): ThreadNode {
if (node.type !== 'post') {
return node
}
if (node.replies) {
node.replies.sort((a: ThreadNode, b: ThreadNode) => {
if (a.type !== 'post') {
return 1
}
if (b.type !== 'post') {
return -1
}
const aIsByOp = a.post.author.did === node.post?.author.did
const bIsByOp = b.post.author.did === node.post?.author.did
if (aIsByOp && bIsByOp) {
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
} else if (aIsByOp) {
return -1 // op's own reply
} else if (bIsByOp) {
return 1 // op's own reply
}
if (opts.prioritizeFollowedUsers) {
const af = a.post.author.viewer?.following
const bf = b.post.author.viewer?.following
if (af && !bf) {
return -1
} else if (!af && bf) {
return 1
}
}
if (opts.sort === 'oldest') {
return a.post.indexedAt.localeCompare(b.post.indexedAt)
} else if (opts.sort === 'newest') {
return b.post.indexedAt.localeCompare(a.post.indexedAt)
} else if (opts.sort === 'most-likes') {
if (a.post.likeCount === b.post.likeCount) {
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
} else {
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
}
} else if (opts.sort === 'random') {
return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
}
return b.post.indexedAt.localeCompare(a.post.indexedAt)
})
node.replies.forEach(reply => sortThread(reply, opts))
}
return node
}
// internal methods
// =
function responseToThreadNodes(
node: ThreadViewNode,
depth = 0,
direction: 'up' | 'down' | 'start' = 'start',
): ThreadNode {
if (
AppBskyFeedDefs.isThreadViewPost(node) &&
AppBskyFeedPost.isRecord(node.post.record) &&
AppBskyFeedPost.validateRecord(node.post.record).success
) {
return {
type: 'post',
_reactKey: node.post.uri,
uri: node.post.uri,
post: node.post,
record: node.post.record,
parent:
node.parent && direction !== 'down'
? responseToThreadNodes(node.parent, depth - 1, 'up')
: undefined,
replies:
node.replies?.length && direction !== 'up'
? node.replies.map(reply =>
responseToThreadNodes(reply, depth + 1, 'down'),
)
: undefined,
viewer: node.viewer,
ctx: {
depth,
isHighlightedPost: depth === 0,
hasMore:
direction === 'down' && !node.replies?.length && !!node.replyCount,
showChildReplyLine:
direction === 'up' ||
(direction === 'down' && !!node.replies?.length),
showParentReplyLine:
(direction === 'up' && !!node.parent) ||
(direction === 'down' && depth !== 1),
},
}
} else if (AppBskyFeedDefs.isBlockedPost(node)) {
return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
} else if (AppBskyFeedDefs.isNotFoundPost(node)) {
return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
} else {
return {type: 'unknown', uri: ''}
}
}

View File

@ -0,0 +1,156 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {useQuery, useMutation} from '@tanstack/react-query'
import {useSession} from '../session'
import {updatePostShadow} from '../cache/post-shadow'
export const RQKEY = (postUri: string) => ['post', postUri]
export function usePostQuery(uri: string | undefined) {
const {agent} = useSession()
return useQuery<AppBskyFeedDefs.PostView>(
RQKEY(uri || ''),
async () => {
const res = await agent.getPosts({uris: [uri!]})
if (res.success && res.data.posts[0]) {
return res.data.posts[0]
}
throw new Error('No data')
},
{
enabled: !!uri,
},
)
}
export function usePostLikeMutation() {
const {agent} = useSession()
return useMutation<
{uri: string}, // responds with the uri of the like
Error,
{uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes
>(post => agent.like(post.uri, post.cid), {
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
likeCount: variables.likeCount + 1,
likeUri: 'pending',
})
},
onSuccess(data, variables) {
// finalize the post-shadow with the like URI
updatePostShadow(variables.uri, {
likeUri: data.uri,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.uri, {
likeCount: variables.likeCount,
likeUri: undefined,
})
},
})
}
export function usePostUnlikeMutation() {
const {agent} = useSession()
return useMutation<
void,
Error,
{postUri: string; likeUri: string; likeCount: number}
>(
async ({likeUri}) => {
await agent.deleteLike(likeUri)
},
{
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount - 1,
likeUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeCount: variables.likeCount,
likeUri: variables.likeUri,
})
},
},
)
}
export function usePostRepostMutation() {
const {agent} = useSession()
return useMutation<
{uri: string}, // responds with the uri of the repost
Error,
{uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts
>(post => agent.repost(post.uri, post.cid), {
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
repostCount: variables.repostCount + 1,
repostUri: 'pending',
})
},
onSuccess(data, variables) {
// finalize the post-shadow with the repost URI
updatePostShadow(variables.uri, {
repostUri: data.uri,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.uri, {
repostCount: variables.repostCount,
repostUri: undefined,
})
},
})
}
export function usePostUnrepostMutation() {
const {agent} = useSession()
return useMutation<
void,
Error,
{postUri: string; repostUri: string; repostCount: number}
>(
async ({repostUri}) => {
await agent.deleteRepost(repostUri)
},
{
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount - 1,
repostUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostCount: variables.repostCount,
repostUri: variables.repostUri,
})
},
},
)
}
export function usePostDeleteMutation() {
const {agent} = useSession()
return useMutation<void, Error, {uri: string}>(
async ({uri}) => {
await agent.deletePost(uri)
},
{
onSuccess(data, variables) {
updatePostShadow(variables.uri, {isDeleted: true})
},
},
)
}

View File

@ -0,0 +1,17 @@
import {useQuery} from '@tanstack/react-query'
import {AtUri} from '@atproto/api'
import {useSession} from '../session'
export const RQKEY = (uri: string) => ['resolved-uri', uri]
export function useResolveUriQuery(uri: string) {
const {agent} = useSession()
return useQuery<string | undefined, Error>(RQKEY(uri), async () => {
const urip = new AtUri(uri)
if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({handle: urip.host})
urip.host = res.data.did
}
return urip.toString()
})
}

View File

@ -186,6 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}), }),
) )
setState(s => ({...s, agent}))
upsertAccount(account) upsertAccount(account)
logger.debug(`session: logged in`, { logger.debug(`session: logged in`, {
@ -238,6 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}), }),
) )
setState(s => ({...s, agent}))
upsertAccount(account) upsertAccount(account)
}, },
[upsertAccount], [upsertAccount],

View File

@ -20,6 +20,7 @@ import {ServiceDescription} from 'state/models/session'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {useSessionApi} from '#/state/session'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
@ -59,6 +60,7 @@ export const LoginForm = ({
const passwordInputRef = useRef<TextInput>(null) const passwordInputRef = useRef<TextInput>(null)
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {login} = useSessionApi()
const onPressSelectService = () => { const onPressSelectService = () => {
openModal({ openModal({
@ -98,6 +100,12 @@ export const LoginForm = ({
} }
} }
// TODO remove double login
await login({
service: serviceUrl,
identifier: fullIdent,
password,
})
await store.session.login({ await store.session.login({
service: serviceUrl, service: serviceUrl,
identifier: fullIdent, identifier: fullIdent,

View File

@ -1,6 +1,4 @@
import React, {useRef} from 'react' import React, {useRef} from 'react'
import {runInAction} from 'mobx'
import {observer} from 'mobx-react-lite'
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@ -11,8 +9,6 @@ import {
} from 'react-native' } from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views' import {CenteredView, FlatList} from '../util/Views'
import {PostThreadModel} from 'state/models/content/post-thread'
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -23,45 +19,36 @@ import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {isNative} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {
ThreadNode,
ThreadPost,
usePostThreadQuery,
sortThread,
} from '#/state/queries/post-thread'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {logger} from '#/logger' import {cleanError} from '#/lib/strings/errors'
import {useStores} from '#/state'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} // const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
const TOP_COMPONENT = {_reactKey: '__top_component__'}
const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
const REPLY_PROMPT = {_reactKey: '__reply__'}
const DELETED = {_reactKey: '__deleted__'}
const BLOCKED = {_reactKey: '__blocked__'}
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
const LOAD_MORE = {_reactKey: '__load_more__'}
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
const TOP_COMPONENT = {
_reactKey: '__top_component__',
_isHighlightedPost: false,
}
const PARENT_SPINNER = {
_reactKey: '__parent_spinner__',
_isHighlightedPost: false,
}
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
const CHILD_SPINNER = {
_reactKey: '__child_spinner__',
_isHighlightedPost: false,
}
const LOAD_MORE = {
_reactKey: '__load_more__',
_isHighlightedPost: false,
}
const BOTTOM_COMPONENT = {
_reactKey: '__bottom_component__',
_isHighlightedPost: false,
_showBorder: true,
}
type YieldedItem = type YieldedItem =
| PostThreadItemModel | ThreadPost
| typeof TOP_COMPONENT | typeof TOP_COMPONENT
| typeof PARENT_SPINNER | typeof PARENT_SPINNER
| typeof REPLY_PROMPT | typeof REPLY_PROMPT
@ -69,66 +56,125 @@ type YieldedItem =
| typeof BLOCKED | typeof BLOCKED
| typeof PARENT_SPINNER | typeof PARENT_SPINNER
export const PostThread = observer(function PostThread({ export function PostThread({
uri, uri,
view,
onPressReply, onPressReply,
treeView, treeView,
}: { }: {
uri: string uri: string | undefined
view: PostThreadModel
onPressReply: () => void onPressReply: () => void
treeView: boolean treeView: boolean
}) { }) {
const pal = usePalette('default') const {
const {_} = useLingui() isLoading,
const {isTablet, isDesktop} = useWebMediaQueries() isError,
const ref = useRef<FlatList>(null) error,
const hasScrolledIntoView = useRef<boolean>(false) refetch,
const [isRefreshing, setIsRefreshing] = React.useState(false) isRefetching,
const [maxVisible, setMaxVisible] = React.useState(100) data: thread,
const navigation = useNavigation<NavigationProp>() dataUpdatedAt,
const posts = React.useMemo(() => { } = usePostThreadQuery(uri)
if (view.thread) { const rootPost = thread?.type === 'post' ? thread.post : undefined
let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
}
if (view.isLoadingFromCache) {
if (view.thread?.postRecord?.reply) {
arr.unshift(PARENT_SPINNER)
}
arr.push(CHILD_SPINNER)
} else {
arr.push(BOTTOM_COMPONENT)
}
return arr
}
return []
}, [view.isLoadingFromCache, view.thread, maxVisible])
const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
useSetTitle( useSetTitle(
view.thread?.postRecord && rootPost &&
`${sanitizeDisplayName( `${sanitizeDisplayName(
view.thread.post.author.displayName || rootPost.author.displayName || `@${rootPost.author.handle}`,
`@${view.thread.post.author.handle}`, )}: "${rootPostRecord?.text}"`,
)}: "${view.thread?.postRecord?.text}"`,
) )
// events if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
// = return (
<PostThreadError
error={error}
notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
onRefresh={refetch}
/>
)
}
if (AppBskyFeedDefs.isBlockedPost(thread)) {
return <PostThreadBlocked />
}
if (!thread || isLoading) {
return (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}
return (
<PostThreadLoaded
thread={thread}
isRefetching={isRefetching}
dataUpdatedAt={dataUpdatedAt}
treeView={treeView}
onRefresh={refetch}
onPressReply={onPressReply}
/>
)
}
const onRefresh = React.useCallback(async () => { function PostThreadLoaded({
setIsRefreshing(true) thread,
try { isRefetching,
view?.refresh() dataUpdatedAt,
} catch (err) { treeView,
logger.error('Failed to refresh posts thread', {error: err}) onRefresh,
onPressReply,
}: {
thread: ThreadNode
isRefetching: boolean
dataUpdatedAt: number
treeView: boolean
onRefresh: () => void
onPressReply: () => void
}) {
const {_} = useLingui()
const pal = usePalette('default')
const store = useStores()
const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null)
// const hasScrolledIntoView = useRef<boolean>(false) TODO
const [maxVisible, setMaxVisible] = React.useState(100)
// TODO
// const posts = React.useMemo(() => {
// if (view.thread) {
// let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
// if (arr.length > maxVisible) {
// arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
// }
// if (view.isLoadingFromCache) {
// if (view.thread?.postRecord?.reply) {
// arr.unshift(PARENT_SPINNER)
// }
// arr.push(CHILD_SPINNER)
// } else {
// arr.push(BOTTOM_COMPONENT)
// }
// return arr
// }
// return []
// }, [view.isLoadingFromCache, view.thread, maxVisible])
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
const posts = React.useMemo(() => {
let arr = [TOP_COMPONENT].concat(
Array.from(
flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
),
)
if (arr.length > maxVisible) {
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
} }
setIsRefreshing(false) arr.push(BOTTOM_COMPONENT)
}, [view, setIsRefreshing]) return arr
}, [thread, maxVisible, store.preferences.thread])
const onContentSizeChange = React.useCallback(() => { // TODO
/*const onContentSizeChange = React.useCallback(() => {
// only run once // only run once
if (hasScrolledIntoView.current) { if (hasScrolledIntoView.current) {
return return
@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({
view.isFromCache, view.isFromCache,
view.isLoadingFromCache, view.isLoadingFromCache,
view.isLoading, view.isLoading,
]) ])*/
const onScrollToIndexFailed = React.useCallback( const onScrollToIndexFailed = React.useCallback(
(info: { (info: {
index: number index: number
@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({
[ref], [ref],
) )
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const renderItem = React.useCallback( const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => { ({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) { if (item === TOP_COMPONENT) {
@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) )
} else if (item instanceof PostThreadItemModel) { } else if (isThreadPost(item)) {
const prev = ( const prev = isThreadPost(posts[index - 1])
index - 1 >= 0 ? posts[index - 1] : undefined ? (posts[index - 1] as ThreadPost)
) as PostThreadItemModel : undefined
return ( return (
<PostThreadItem <PostThreadItem
item={item} post={item.post}
onPostReply={onRefresh} record={item.record}
hasPrecedingItem={prev?._showChildReplyLine} dataUpdatedAt={dataUpdatedAt}
treeView={treeView} treeView={treeView}
depth={item.ctx.depth}
isHighlightedPost={item.ctx.isHighlightedPost}
hasMore={item.ctx.hasMore}
showChildReplyLine={item.ctx.showChildReplyLine}
showParentReplyLine={item.ctx.showParentReplyLine}
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
onPostReply={onRefresh}
/> />
) )
} }
return <></> return null
}, },
[ [
isTablet, isTablet,
@ -278,75 +323,116 @@ export const PostThread = observer(function PostThread({
posts, posts,
onRefresh, onRefresh,
treeView, treeView,
dataUpdatedAt,
_, _,
], ],
) )
// loading return (
// = <FlatList
if ( ref={ref}
!view.hasLoaded || data={posts}
(view.isLoading && !view.isRefreshing) || initialNumToRender={posts.length}
view.params.uri !== uri maintainVisibleContentPosition={
) { undefined // TODO
return ( // isNative && view.isFromCache && view.isCachedPostAReply
<CenteredView> // ? MAINTAIN_VISIBLE_CONTENT_POSITION
<View style={s.p20}> // : undefined
<ActivityIndicator size="large" /> }
</View> keyExtractor={item => item._reactKey}
</CenteredView> renderItem={renderItem}
) refreshControl={
} <RefreshControl
refreshing={isRefetching}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onContentSizeChange={
undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
}
onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)
}
// error function PostThreadBlocked() {
// = const {_} = useLingui()
if (view.hasError) { const pal = usePalette('default')
if (view.notFound) { const navigation = useNavigation<NavigationProp>()
return (
<CenteredView> const onPressBack = React.useCallback(() => {
<View style={[pal.view, pal.border, styles.notFoundContainer]}> if (navigation.canGoBack()) {
<Text type="title-lg" style={[pal.text, s.mb5]}> navigation.goBack()
<Trans>Post not found</Trans> } else {
</Text> navigation.navigate('Home')
<Text type="md" style={[pal.text, s.mb10]}>
<Trans>The post may have been deleted.</Trans>
</Text>
<TouchableOpacity
onPress={onPressBack}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<Text type="2xl" style={pal.link}>
<FontAwesomeIcon
icon="angle-left"
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
size={14}
/>
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
</View>
</CenteredView>
)
} }
return ( }, [navigation])
<CenteredView>
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> return (
</CenteredView> <CenteredView>
) <View style={[pal.view, pal.border, styles.notFoundContainer]}>
} <Text type="title-lg" style={[pal.text, s.mb5]}>
if (view.isBlocked) { <Trans>Post hidden</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb10]}>
<Trans>
You have blocked the author or you have been blocked by the author.
</Trans>
</Text>
<TouchableOpacity
onPress={onPressBack}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<Text type="2xl" style={pal.link}>
<FontAwesomeIcon
icon="angle-left"
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
size={14}
/>
Back
</Text>
</TouchableOpacity>
</View>
</CenteredView>
)
}
function PostThreadError({
onRefresh,
notFound,
error,
}: {
onRefresh: () => void
notFound: boolean
error: Error | null
}) {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (notFound) {
return ( return (
<CenteredView> <CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}> <View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb5]}> <Text type="title-lg" style={[pal.text, s.mb5]}>
<Trans>Post hidden</Trans> <Trans>Post not found</Trans>
</Text> </Text>
<Text type="md" style={[pal.text, s.mb10]}> <Text type="md" style={[pal.text, s.mb10]}>
<Trans> <Trans>The post may have been deleted.</Trans>
You have blocked the author or you have been blocked by the
author.
</Trans>
</Text> </Text>
<TouchableOpacity <TouchableOpacity
onPress={onPressBack} onPress={onPressBack}
@ -366,69 +452,37 @@ export const PostThread = observer(function PostThread({
</CenteredView> </CenteredView>
) )
} }
// loaded
// =
return ( return (
<FlatList <CenteredView>
ref={ref} <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
data={posts} </CenteredView>
initialNumToRender={posts.length}
maintainVisibleContentPosition={
isNative && view.isFromCache && view.isCachedPostAReply
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onContentSizeChange={
isNative && view.isFromCache ? undefined : onContentSizeChange
}
onScrollToIndexFailed={onScrollToIndexFailed}
style={s.hContentRegion}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
) )
}) }
function* flattenThread( function isThreadPost(v: unknown): v is ThreadPost {
post: PostThreadItemModel, return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
isAscending = false, }
function* flattenThreadSkeleton(
node: ThreadNode,
): Generator<YieldedItem, void> { ): Generator<YieldedItem, void> {
if (post.parent) { if (node.type === 'post') {
if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { if (node.parent) {
yield DELETED yield* flattenThreadSkeleton(node.parent)
} else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
yield BLOCKED
} else {
yield* flattenThread(post.parent as PostThreadItemModel, true)
} }
} yield node
yield post if (node.ctx.isHighlightedPost) {
if (post._isHighlightedPost) { yield REPLY_PROMPT
yield REPLY_PROMPT }
} if (node.replies?.length) {
if (post.replies?.length) { for (const reply of node.replies) {
for (const reply of post.replies) { yield* flattenThreadSkeleton(reply)
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
yield DELETED
} else {
yield* flattenThread(reply as PostThreadItemModel)
} }
} }
} else if (!isAscending && !post.parent && post.post.replyCount) { } else if (node.type === 'not-found') {
runInAction(() => { yield DELETED
post._hasMore = true } else if (node.type === 'blocked') {
}) yield BLOCKED
} }
} }

View File

@ -1,18 +1,17 @@
import React, {useMemo} from 'react' import React, {useMemo} from 'react'
import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native'
import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri, AppBskyFeedDefs} from '@atproto/api'
import { import {
FontAwesomeIcon, AtUri,
FontAwesomeIconStyle, AppBskyFeedDefs,
} from '@fortawesome/react-native-fontawesome' AppBskyFeedPost,
import {PostThreadItemModel} from 'state/models/content/post-thread-item' RichText as RichTextAPI,
moderatePost,
PostModeration,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link' import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
@ -24,7 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/post-embeds' import {PostEmbeds} from '../util/post-embeds'
import {PostCtrls} from '../util/post-ctrls/PostCtrls' import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
import {PostHider} from '../util/moderation/PostHider' import {PostHider} from '../util/moderation/PostHider'
import {ContentHider} from '../util/moderation/ContentHider' import {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts' import {PostAlerts} from '../util/moderation/PostAlerts'
@ -36,54 +36,145 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export const PostThreadItem = observer(function PostThreadItem({ export function PostThreadItem({
item, post,
onPostReply, record,
hasPrecedingItem, dataUpdatedAt,
treeView, treeView,
depth,
isHighlightedPost,
hasMore,
showChildReplyLine,
showParentReplyLine,
hasPrecedingItem,
onPostReply,
}: { }: {
item: PostThreadItemModel post: AppBskyFeedDefs.PostView
onPostReply: () => void record: AppBskyFeedPost.Record
hasPrecedingItem: boolean dataUpdatedAt: number
treeView: boolean treeView: boolean
depth: number
isHighlightedPost?: boolean
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
hasPrecedingItem: boolean
onPostReply: () => void
}) {
const store = useStores()
const postShadowed = usePostShadow(post, dataUpdatedAt)
const richText = useMemo(
() =>
post &&
AppBskyFeedPost.isRecord(post?.record) &&
AppBskyFeedPost.validateRecord(post?.record).success
? new RichTextAPI({
text: post.record.text,
facets: post.record.facets,
})
: undefined,
[post],
)
const moderation = useMemo(
() =>
post ? moderatePost(post, store.preferences.moderationOpts) : undefined,
[post, store],
)
if (postShadowed === POST_TOMBSTONE) {
return <PostThreadItemDeleted />
}
if (richText && moderation) {
return (
<PostThreadItemLoaded
post={postShadowed}
record={record}
richText={richText}
moderation={moderation}
treeView={treeView}
depth={depth}
isHighlightedPost={isHighlightedPost}
hasMore={hasMore}
showChildReplyLine={showChildReplyLine}
showParentReplyLine={showParentReplyLine}
hasPrecedingItem={hasPrecedingItem}
onPostReply={onPostReply}
/>
)
}
return null
}
function PostThreadItemDeleted() {
const styles = useStyles()
const pal = usePalette('default')
return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
<FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
<Text style={[pal.textLight, s.ml10]}>
<Trans>This post has been deleted.</Trans>
</Text>
</View>
)
}
function PostThreadItemLoaded({
post,
record,
richText,
moderation,
treeView,
depth,
isHighlightedPost,
hasMore,
showChildReplyLine,
showParentReplyLine,
hasPrecedingItem,
onPostReply,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
richText: RichTextAPI
moderation: PostModeration
treeView: boolean
depth: number
isHighlightedPost?: boolean
hasMore?: boolean
showChildReplyLine?: boolean
showParentReplyLine?: boolean
hasPrecedingItem: boolean
onPostReply: () => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const [deleted, setDeleted] = React.useState(false)
const [limitLines, setLimitLines] = React.useState( const [limitLines, setLimitLines] = React.useState(
countLines(item.richText?.text) >= MAX_POST_LINES, countLines(richText?.text) >= MAX_POST_LINES,
) )
const styles = useStyles() const styles = useStyles()
const record = item.postRecord const hasEngagement = post.likeCount || post.repostCount
const hasEngagement = item.post.likeCount || item.post.repostCount
const itemUri = item.post.uri const rootUri = record.reply?.root?.uri || post.uri
const itemCid = item.post.cid const postHref = React.useMemo(() => {
const itemHref = React.useMemo(() => { const urip = new AtUri(post.uri)
const urip = new AtUri(item.post.uri) return makeProfileLink(post.author, 'post', urip.rkey)
return makeProfileLink(item.post.author, 'post', urip.rkey) }, [post.uri, post.author])
}, [item.post.uri, item.post.author]) const itemTitle = `Post by ${post.author.handle}`
const itemTitle = `Post by ${item.post.author.handle}` const authorHref = makeProfileLink(post.author)
const authorHref = makeProfileLink(item.post.author) const authorTitle = post.author.handle
const authorTitle = item.post.author.handle const isAuthorMuted = post.author.viewer?.muted
const isAuthorMuted = item.post.author.viewer?.muted
const likesHref = React.useMemo(() => { const likesHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by') return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
}, [item.post.uri, item.post.author]) }, [post.uri, post.author])
const likesTitle = 'Likes on this post' const likesTitle = 'Likes on this post'
const repostsHref = React.useMemo(() => { const repostsHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by') return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [item.post.uri, item.post.author]) }, [post.uri, post.author])
const repostsTitle = 'Reposts of this post' const repostsTitle = 'Reposts of this post'
const translatorUrl = getTranslatorLink( const translatorUrl = getTranslatorLink(
@ -94,73 +185,26 @@ export const PostThreadItem = observer(function PostThreadItem({
() => () =>
Boolean( Boolean(
langPrefs.primaryLanguage && langPrefs.primaryLanguage &&
!isPostInLanguage(item.post, [langPrefs.primaryLanguage]), !isPostInLanguage(post, [langPrefs.primaryLanguage]),
), ),
[item.post, langPrefs.primaryLanguage], [post, langPrefs.primaryLanguage],
) )
const onPressReply = React.useCallback(() => { const onPressReply = React.useCallback(() => {
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: item.post.uri, uri: post.uri,
cid: item.post.cid, cid: post.cid,
text: record?.text as string, text: record.text,
author: { author: {
handle: item.post.author.handle, handle: post.author.handle,
displayName: item.post.author.displayName, displayName: post.author.displayName,
avatar: item.post.author.avatar, avatar: post.author.avatar,
}, },
}, },
onPost: onPostReply, onPost: onPostReply,
}) })
}, [store, item, record, onPostReply]) }, [store, post, record, onPostReply])
const onPressToggleRepost = React.useCallback(() => {
return item
.toggleRepost()
.catch(e => logger.error('Failed to toggle repost', {error: e}))
}, [item])
const onPressToggleLike = React.useCallback(() => {
return item
.toggleLike()
.catch(e => logger.error('Failed to toggle like', {error: e}))
}, [item])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(item.data.rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [item, toggleThreadMute])
const onDeletePost = React.useCallback(() => {
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [item])
const onPressShowMore = React.useCallback(() => { const onPressShowMore = React.useCallback(() => {
setLimitLines(false) setLimitLines(false)
@ -170,24 +214,10 @@ export const PostThreadItem = observer(function PostThreadItem({
return <ErrorMessage message="Invalid or unsupported post record" /> return <ErrorMessage message="Invalid or unsupported post record" />
} }
if (deleted) { if (isHighlightedPost) {
return (
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
<FontAwesomeIcon
icon={['far', 'trash-can']}
style={pal.icon as FontAwesomeIconStyle}
/>
<Text style={[pal.textLight, s.ml10]}>
<Trans>This post has been deleted.</Trans>
</Text>
</View>
)
}
if (item._isHighlightedPost) {
return ( return (
<> <>
{item.rootUri !== item.uri && ( {rootUri !== post.uri && (
<View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}> <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
<View style={{width: 38}}> <View style={{width: 38}}>
<View <View
@ -204,7 +234,7 @@ export const PostThreadItem = observer(function PostThreadItem({
)} )}
<Link <Link
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
noFeedback noFeedback
accessible={false}> accessible={false}>
@ -213,10 +243,10 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[styles.layoutAvi, {paddingBottom: 8}]}> <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={52} size={52}
did={item.post.author.did} did={post.author.did}
handle={item.post.author.handle} handle={post.author.handle}
avatar={item.post.author.avatar} avatar={post.author.avatar}
moderation={item.moderation.avatar} moderation={moderation.avatar}
/> />
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
@ -233,17 +263,17 @@ export const PostThreadItem = observer(function PostThreadItem({
numberOfLines={1} numberOfLines={1}
lineHeight={1.2}> lineHeight={1.2}>
{sanitizeDisplayName( {sanitizeDisplayName(
item.post.author.displayName || post.author.displayName ||
sanitizeHandle(item.post.author.handle), sanitizeHandle(post.author.handle),
)} )}
</Text> </Text>
</Link> </Link>
<TimeElapsed timestamp={item.post.indexedAt}> <TimeElapsed timestamp={post.indexedAt}>
{({timeElapsed}) => ( {({timeElapsed}) => (
<Text <Text
type="md" type="md"
style={[styles.metaItem, pal.textLight]} style={[styles.metaItem, pal.textLight]}
title={niceDate(item.post.indexedAt)}> title={niceDate(post.indexedAt)}>
&middot;&nbsp;{timeElapsed} &middot;&nbsp;{timeElapsed}
</Text> </Text>
)} )}
@ -280,23 +310,15 @@ export const PostThreadItem = observer(function PostThreadItem({
href={authorHref} href={authorHref}
title={authorTitle}> title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(item.post.author.handle, '@')} {sanitizeHandle(post.author.handle, '@')}
</Text> </Text>
</Link> </Link>
</View> </View>
</View> </View>
<PostDropdownBtn <PostDropdownBtn
testID="postDropdownBtn" testID="postDropdownBtn"
itemUri={itemUri} post={post}
itemCid={itemCid} record={record}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
style={{ style={{
paddingVertical: 6, paddingVertical: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
@ -307,16 +329,16 @@ export const PostThreadItem = observer(function PostThreadItem({
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider <ContentHider
moderation={item.moderation.content} moderation={moderation.content}
ignoreMute ignoreMute
style={styles.contentHider} style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}> childContainerStyle={styles.contentHiderChild}>
<PostAlerts <PostAlerts
moderation={item.moderation.content} moderation={moderation.content}
includeMute includeMute
style={styles.alert} style={styles.alert}
/> />
{item.richText?.text ? ( {richText?.text ? (
<View <View
style={[ style={[
styles.postTextContainer, styles.postTextContainer,
@ -324,59 +346,56 @@ export const PostThreadItem = observer(function PostThreadItem({
]}> ]}>
<RichText <RichText
type="post-text-lg" type="post-text-lg"
richText={item.richText} richText={richText}
lineHeight={1.3} lineHeight={1.3}
style={s.flex1} style={s.flex1}
/> />
</View> </View>
) : undefined} ) : undefined}
{item.post.embed && ( {post.embed && (
<ContentHider <ContentHider
moderation={item.moderation.embed} moderation={moderation.embed}
ignoreMute={isEmbedByEmbedder( ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
item.post.embed,
item.post.author.did,
)}
style={s.mb10}> style={s.mb10}>
<PostEmbeds <PostEmbeds
embed={item.post.embed} embed={post.embed}
moderation={item.moderation.embed} moderation={moderation.embed}
/> />
</ContentHider> </ContentHider>
)} )}
</ContentHider> </ContentHider>
<ExpandedPostDetails <ExpandedPostDetails
post={item.post} post={post}
translatorUrl={translatorUrl} translatorUrl={translatorUrl}
needsTranslation={needsTranslation} needsTranslation={needsTranslation}
/> />
{hasEngagement ? ( {hasEngagement ? (
<View style={[styles.expandedInfo, pal.border]}> <View style={[styles.expandedInfo, pal.border]}>
{item.post.repostCount ? ( {post.repostCount ? (
<Link <Link
style={styles.expandedInfoItem} style={styles.expandedInfoItem}
href={repostsHref} href={repostsHref}
title={repostsTitle}> title={repostsTitle}>
<Text testID="repostCount" type="lg" style={pal.textLight}> <Text testID="repostCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}> <Text type="xl-bold" style={pal.text}>
{formatCount(item.post.repostCount)} {formatCount(post.repostCount)}
</Text>{' '} </Text>{' '}
{pluralize(item.post.repostCount, 'repost')} {pluralize(post.repostCount, 'repost')}
</Text> </Text>
</Link> </Link>
) : ( ) : (
<></> <></>
)} )}
{item.post.likeCount ? ( {post.likeCount ? (
<Link <Link
style={styles.expandedInfoItem} style={styles.expandedInfoItem}
href={likesHref} href={likesHref}
title={likesTitle}> title={likesTitle}>
<Text testID="likeCount" type="lg" style={pal.textLight}> <Text testID="likeCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}> <Text type="xl-bold" style={pal.text}>
{formatCount(item.post.likeCount)} {formatCount(post.likeCount)}
</Text>{' '} </Text>{' '}
{pluralize(item.post.likeCount, 'like')} {pluralize(post.likeCount, 'like')}
</Text> </Text>
</Link> </Link>
) : ( ) : (
@ -389,24 +408,9 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[s.pl10, s.pb5]}> <View style={[s.pl10, s.pb5]}>
<PostCtrls <PostCtrls
big big
itemUri={itemUri} post={post}
itemCid={itemCid} record={record}
itemHref={itemHref}
itemTitle={itemTitle}
author={item.post.author}
text={item.richText?.text || record.text}
indexedAt={item.post.indexedAt}
isAuthor={item.post.author.did === store.me.did}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/> />
</View> </View>
</View> </View>
@ -414,17 +418,19 @@ export const PostThreadItem = observer(function PostThreadItem({
</> </>
) )
} else { } else {
const isThreadedChild = treeView && item._depth > 1 const isThreadedChild = treeView && depth > 1
return ( return (
<PostOuterWrapper <PostOuterWrapper
item={item} post={post}
hasPrecedingItem={hasPrecedingItem} depth={depth}
treeView={treeView}> showParentReplyLine={!!showParentReplyLine}
treeView={treeView}
hasPrecedingItem={hasPrecedingItem}>
<PostHider <PostHider
testID={`postThreadItem-by-${item.post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
href={itemHref} href={postHref}
style={[pal.view]} style={[pal.view]}
moderation={item.moderation.content}> moderation={moderation.content}>
<PostSandboxWarning /> <PostSandboxWarning />
<View <View
@ -435,7 +441,7 @@ export const PostThreadItem = observer(function PostThreadItem({
height: isThreadedChild ? 8 : 16, height: isThreadedChild ? 8 : 16,
}}> }}>
<View style={{width: 38}}> <View style={{width: 38}}>
{!isThreadedChild && item._showParentReplyLine && ( {!isThreadedChild && showParentReplyLine && (
<View <View
style={[ style={[
styles.replyLine, styles.replyLine,
@ -454,21 +460,20 @@ export const PostThreadItem = observer(function PostThreadItem({
style={[ style={[
styles.layout, styles.layout,
{ {
paddingBottom: paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8,
item._showChildReplyLine && !isThreadedChild ? 0 : 8,
}, },
]}> ]}>
{!isThreadedChild && ( {!isThreadedChild && (
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
<PreviewableUserAvatar <PreviewableUserAvatar
size={38} size={38}
did={item.post.author.did} did={post.author.did}
handle={item.post.author.handle} handle={post.author.handle}
avatar={item.post.author.avatar} avatar={post.author.avatar}
moderation={item.moderation.avatar} moderation={moderation.avatar}
/> />
{item._showChildReplyLine && ( {showChildReplyLine && (
<View <View
style={[ style={[
styles.replyLine, styles.replyLine,
@ -485,10 +490,10 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<PostMeta <PostMeta
author={item.post.author} author={post.author}
authorHasWarning={!!item.post.author.labels?.length} authorHasWarning={!!post.author.labels?.length}
timestamp={item.post.indexedAt} timestamp={post.indexedAt}
postHref={itemHref} postHref={postHref}
showAvatar={isThreadedChild} showAvatar={isThreadedChild}
avatarSize={26} avatarSize={26}
displayNameType="md-bold" displayNameType="md-bold"
@ -496,14 +501,14 @@ export const PostThreadItem = observer(function PostThreadItem({
style={isThreadedChild && s.mb5} style={isThreadedChild && s.mb5}
/> />
<PostAlerts <PostAlerts
moderation={item.moderation.content} moderation={moderation.content}
style={styles.alert} style={styles.alert}
/> />
{item.richText?.text ? ( {richText?.text ? (
<View style={styles.postTextContainer}> <View style={styles.postTextContainer}>
<RichText <RichText
type="post-text" type="post-text"
richText={item.richText} richText={richText}
style={[pal.text, s.flex1]} style={[pal.text, s.flex1]}
lineHeight={1.3} lineHeight={1.3}
numberOfLines={limitLines ? MAX_POST_LINES : undefined} numberOfLines={limitLines ? MAX_POST_LINES : undefined}
@ -518,42 +523,24 @@ export const PostThreadItem = observer(function PostThreadItem({
href="#" href="#"
/> />
) : undefined} ) : undefined}
{item.post.embed && ( {post.embed && (
<ContentHider <ContentHider
style={styles.contentHider} style={styles.contentHider}
moderation={item.moderation.embed}> moderation={moderation.embed}>
<PostEmbeds <PostEmbeds
embed={item.post.embed} embed={post.embed}
moderation={item.moderation.embed} moderation={moderation.embed}
/> />
</ContentHider> </ContentHider>
)} )}
<PostCtrls <PostCtrls
itemUri={itemUri} post={post}
itemCid={itemCid} record={record}
itemHref={itemHref}
itemTitle={itemTitle}
author={item.post.author}
text={item.richText?.text || record.text}
indexedAt={item.post.indexedAt}
isAuthor={item.post.author.did === store.me.did}
replyCount={item.post.replyCount}
repostCount={item.post.repostCount}
likeCount={item.post.likeCount}
isReposted={!!item.post.viewer?.repost}
isLiked={!!item.post.viewer?.like}
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
onPressReply={onPressReply} onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/> />
</View> </View>
</View> </View>
{item._hasMore ? ( {hasMore ? (
<Link <Link
style={[ style={[
styles.loadMore, styles.loadMore,
@ -563,7 +550,7 @@ export const PostThreadItem = observer(function PostThreadItem({
paddingBottom: treeView ? 4 : 12, paddingBottom: treeView ? 4 : 12,
}, },
]} ]}
href={itemHref} href={postHref}
title={itemTitle} title={itemTitle}
noFeedback> noFeedback>
<Text type="sm-medium" style={pal.textLight}> <Text type="sm-medium" style={pal.textLight}>
@ -580,22 +567,26 @@ export const PostThreadItem = observer(function PostThreadItem({
</PostOuterWrapper> </PostOuterWrapper>
) )
} }
}) }
function PostOuterWrapper({ function PostOuterWrapper({
item, post,
hasPrecedingItem,
treeView, treeView,
depth,
showParentReplyLine,
hasPrecedingItem,
children, children,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
item: PostThreadItemModel post: AppBskyFeedDefs.PostView
hasPrecedingItem: boolean
treeView: boolean treeView: boolean
depth: number
showParentReplyLine: boolean
hasPrecedingItem: boolean
}>) { }>) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const pal = usePalette('default') const pal = usePalette('default')
const styles = useStyles() const styles = useStyles()
if (treeView && item._depth > 1) { if (treeView && depth > 1) {
return ( return (
<View <View
style={[ style={[
@ -605,13 +596,13 @@ function PostOuterWrapper({
{ {
flexDirection: 'row', flexDirection: 'row',
paddingLeft: 20, paddingLeft: 20,
borderTopWidth: item._depth === 1 ? 1 : 0, borderTopWidth: depth === 1 ? 1 : 0,
paddingTop: item._depth === 1 ? 8 : 0, paddingTop: depth === 1 ? 8 : 0,
}, },
]}> ]}>
{Array.from(Array(item._depth - 1)).map((_, n: number) => ( {Array.from(Array(depth - 1)).map((_, n: number) => (
<View <View
key={`${item.uri}-padding-${n}`} key={`${post.uri}-padding-${n}`}
style={{ style={{
borderLeftWidth: 2, borderLeftWidth: 2,
borderLeftColor: pal.colors.border, borderLeftColor: pal.colors.border,
@ -630,7 +621,7 @@ function PostOuterWrapper({
styles.outer, styles.outer,
pal.view, pal.view,
pal.border, pal.border,
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor, styles.cursor,
]}> ]}>
{children} {children}

View File

@ -0,0 +1,210 @@
import React from 'react'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper'
import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links'
import {getTranslatorLink} from '#/locale/helpers'
import {useStores} from '#/state'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {logger} from '#/logger'
export function PostDropdownBtn({
testID,
post,
record,
style,
}: {
testID: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const theme = useTheme()
const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const rootUri = record.reply?.root?.uri || post.uri
const isThreadMuted = mutedThreads.includes(rootUri)
const isAuthor = post.author.did === store.me.did
const href = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: post.uri}).then(
() => {
Toast.show('Post deleted')
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
},
)
}, [post, postDeleteMutation])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
} else {
Toast.show('You will now receive notifications for this thread')
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const dropdownItems: NativeDropdownItem[] = [
{
label: 'Translate',
onPress() {
onOpenTranslate()
},
testID: 'postDropdownTranslateBtn',
icon: {
ios: {
name: 'character.book.closed',
},
android: 'ic_menu_sort_alphabetically',
web: 'language',
},
},
{
label: 'Copy post text',
onPress() {
onCopyPostText()
},
testID: 'postDropdownCopyTextBtn',
icon: {
ios: {
name: 'doc.on.doc',
},
android: 'ic_menu_edit',
web: ['far', 'paste'],
},
},
{
label: 'Share',
onPress() {
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
{
label: 'separator',
},
{
label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
onPress() {
onToggleThreadMute()
},
testID: 'postDropdownMuteThreadBtn',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
},
{
label: 'separator',
},
!isAuthor && {
label: 'Report post',
onPress() {
openModal({
name: 'report',
uri: post.uri,
cid: post.cid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: 'separator',
},
isAuthor && {
label: 'Delete post',
onPress() {
openModal({
name: 'confirm',
title: 'Delete this post?',
message: 'Are you sure? This can not be undone.',
onPressConfirm: onDeletePost,
})
},
testID: 'postDropdownDeleteBtn',
icon: {
ios: {
name: 'trash',
},
android: 'ic_menu_delete',
web: ['far', 'trash-can'],
},
},
].filter(Boolean) as NativeDropdownItem[]
return (
<EventStopper>
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel="More post options"
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
</View>
</NativeDropdown>
</EventStopper>
)
}

View File

@ -0,0 +1,200 @@
import React, {useCallback} from 'react'
import {
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
import {Text} from '../text/Text'
import {PostDropdownBtn} from '../forms/PostDropdownBtn2'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {pluralize} from 'lib/strings/helpers'
import {useTheme} from 'lib/ThemeContext'
import {useStores} from 'state/index'
import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutation,
usePostUnlikeMutation,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post'
export function PostCtrls({
big,
post,
record,
style,
onPressReply,
}: {
big?: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
style?: StyleProp<ViewStyle>
onPressReply: () => void
}) {
const store = useStores()
const theme = useTheme()
const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation()
const postUnlikeMutation = usePostUnlikeMutation()
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const defaultCtrlColor = React.useMemo(
() => ({
color: theme.palette.default.postCtrl,
}),
[theme],
) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) {
Haptics.default()
postLikeMutation.mutate({
uri: post.uri,
cid: post.cid,
likeCount: post.likeCount || 0,
})
} else {
postUnlikeMutation.mutate({
postUri: post.uri,
likeUri: post.viewer.like,
likeCount: post.likeCount || 0,
})
}
}, [post, postLikeMutation, postUnlikeMutation])
const onRepost = useCallback(() => {
closeModal()
if (!post.viewer?.repost) {
Haptics.default()
postRepostMutation.mutate({
uri: post.uri,
cid: post.cid,
repostCount: post.repostCount || 0,
})
} else {
postUnrepostMutation.mutate({
postUri: post.uri,
repostUri: post.viewer.repost,
repostCount: post.repostCount || 0,
})
}
}, [post, closeModal, postRepostMutation, postUnrepostMutation])
const onQuote = useCallback(() => {
closeModal()
store.shell.openComposer({
quote: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
})
Haptics.default()
}, [post, record, store.shell, closeModal])
return (
<View style={[styles.ctrls, style]}>
<TouchableOpacity
testID="replyBtn"
style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]}
onPress={onPressReply}
accessibilityRole="button"
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]}
strokeWidth={3}
size={big ? 20 : 15}
/>
{typeof post.replyCount !== 'undefined' ? (
<Text style={[defaultCtrlColor, s.ml5, s.f15]}>
{post.replyCount}
</Text>
) : undefined}
</TouchableOpacity>
<RepostButton
big={big}
isReposted={!!post.viewer?.repost}
repostCount={post.repostCount}
onRepost={onRepost}
onQuote={onQuote}
/>
<TouchableOpacity
testID="likeBtn"
style={[styles.ctrl, !big && styles.ctrlPad]}
onPress={onPressToggleLike}
accessibilityRole="button"
accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${
post.likeCount
} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
<HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} />
) : (
<HeartIcon
style={[defaultCtrlColor, big ? styles.mt1 : undefined]}
strokeWidth={3}
size={big ? 20 : 16}
/>
)}
{typeof post.likeCount !== 'undefined' ? (
<Text
testID="likeCount"
style={
post.viewer?.like
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{post.likeCount}
</Text>
) : undefined}
</TouchableOpacity>
{big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
post={post}
record={record}
style={styles.ctrlPad}
/>
)}
{/* used for adding pad to the right side */}
<View />
</View>
)
}
const styles = StyleSheet.create({
ctrls: {
flexDirection: 'row',
justifyContent: 'space-between',
},
ctrl: {
flexDirection: 'row',
alignItems: 'center',
},
ctrlPad: {
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 5,
paddingRight: 5,
},
ctrlIconLiked: {
color: colors.like,
},
mt1: {
marginTop: 1,
},
})

View File

@ -1,7 +1,8 @@
import React, {useMemo} from 'react' import React from 'react'
import {InteractionManager, StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers' import {makeRecordUri} from 'lib/strings/url-helpers'
@ -9,79 +10,83 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ComposePrompt} from 'view/com/composer/Prompt' import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThreadModel} from 'state/models/content/post-thread'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
RQKEY as POST_THREAD_RQKEY,
ThreadNode,
} from '#/state/queries/post-thread'
import {clamp} from 'lodash' import {clamp} from 'lodash'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {logger} from '#/logger'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ErrorMessage} from '../com/util/error/ErrorMessage'
import {CenteredView} from '../com/util/Views'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export const PostThreadScreen = withAuthRequired( export const PostThreadScreen = withAuthRequired(
observer(function PostThreadScreenImpl({route}: Props) { observer(function PostThreadScreenImpl({route}: Props) {
const store = useStores() const store = useStores()
const queryClient = useQueryClient()
const {fabMinimalShellTransform} = useMinimalShellMode() const {fabMinimalShellTransform} = useMinimalShellMode()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()
const {name, rkey} = route.params const {name, rkey} = route.params
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const view = useMemo<PostThreadModel>(
() => new PostThreadModel(store, {uri}),
[store, uri],
)
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
setMinimalShellMode(false) setMinimalShellMode(false)
const threadCleanup = view.registerListeners() }, [setMinimalShellMode]),
InteractionManager.runAfterInteractions(() => {
if (!view.hasLoaded && !view.isLoading) {
view.setup().catch(err => {
logger.error('Failed to fetch thread', {error: err})
})
}
})
return () => {
threadCleanup()
}
}, [view, setMinimalShellMode]),
) )
const onPressReply = React.useCallback(() => { const onPressReply = React.useCallback(() => {
if (!view.thread) { if (!resolvedUri) {
return
}
const thread = queryClient.getQueryData<ThreadNode>(
POST_THREAD_RQKEY(resolvedUri),
)
if (thread?.type !== 'post') {
return return
} }
store.shell.openComposer({ store.shell.openComposer({
replyTo: { replyTo: {
uri: view.thread.post.uri, uri: thread.post.uri,
cid: view.thread.post.cid, cid: thread.post.cid,
text: view.thread.postRecord?.text as string, text: thread.record.text,
author: { author: {
handle: view.thread.post.author.handle, handle: thread.post.author.handle,
displayName: view.thread.post.author.displayName, displayName: thread.post.author.displayName,
avatar: view.thread.post.author.avatar, avatar: thread.post.author.avatar,
}, },
}, },
onPost: () => view.refresh(), onPost: () =>
queryClient.invalidateQueries({
queryKey: POST_THREAD_RQKEY(resolvedUri || ''),
}),
}) })
}, [view, store]) }, [store, queryClient, resolvedUri])
return ( return (
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
{isMobile && <ViewHeader title="Post" />} {isMobile && <ViewHeader title="Post" />}
<View style={s.flex1}> <View style={s.flex1}>
<PostThreadComponent {uriError ? (
uri={uri} <CenteredView>
view={view} <ErrorMessage message={String(uriError)} />
onPressReply={onPressReply} </CenteredView>
treeView={!!store.preferences.thread.lab_treeViewEnabled} ) : (
/> <PostThreadComponent
uri={resolvedUri}
onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled}
/>
)}
</View> </View>
{isMobile && ( {isMobile && (
<Animated.View <Animated.View