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)
})
dynamicActivate(defaultLocale) // async import of locale data
}, [resumeSession])
}, [])
useEffect(() => {
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)
logger.debug(`session: logged in`, {
@ -238,6 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}),
)
setState(s => ({...s, agent}))
upsertAccount(account)
},
[upsertAccount],

View File

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

View File

@ -1,6 +1,4 @@
import React, {useRef} from 'react'
import {runInAction} from 'mobx'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
Pressable,
@ -11,8 +9,6 @@ import {
} from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views'
import {PostThreadModel} from 'state/models/content/post-thread'
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@ -23,45 +19,36 @@ import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {isNative} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {
ThreadNode,
ThreadPost,
usePostThreadQuery,
sortThread,
} from '#/state/queries/post-thread'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
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 {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 =
| PostThreadItemModel
| ThreadPost
| typeof TOP_COMPONENT
| typeof PARENT_SPINNER
| typeof REPLY_PROMPT
@ -69,66 +56,125 @@ type YieldedItem =
| typeof BLOCKED
| typeof PARENT_SPINNER
export const PostThread = observer(function PostThread({
export function PostThread({
uri,
view,
onPressReply,
treeView,
}: {
uri: string
view: PostThreadModel
uri: string | undefined
onPressReply: () => void
treeView: boolean
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTablet, isDesktop} = useWebMediaQueries()
const ref = useRef<FlatList>(null)
const hasScrolledIntoView = useRef<boolean>(false)
const [isRefreshing, setIsRefreshing] = React.useState(false)
const [maxVisible, setMaxVisible] = React.useState(100)
const navigation = useNavigation<NavigationProp>()
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 {
isLoading,
isError,
error,
refetch,
isRefetching,
data: thread,
dataUpdatedAt,
} = usePostThreadQuery(uri)
const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
useSetTitle(
view.thread?.postRecord &&
rootPost &&
`${sanitizeDisplayName(
view.thread.post.author.displayName ||
`@${view.thread.post.author.handle}`,
)}: "${view.thread?.postRecord?.text}"`,
rootPost.author.displayName || `@${rootPost.author.handle}`,
)}: "${rootPostRecord?.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 () => {
setIsRefreshing(true)
try {
view?.refresh()
} catch (err) {
logger.error('Failed to refresh posts thread', {error: err})
function PostThreadLoaded({
thread,
isRefetching,
dataUpdatedAt,
treeView,
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)
}, [view, setIsRefreshing])
arr.push(BOTTOM_COMPONENT)
return arr
}, [thread, maxVisible, store.preferences.thread])
const onContentSizeChange = React.useCallback(() => {
// TODO
/*const onContentSizeChange = React.useCallback(() => {
// only run once
if (hasScrolledIntoView.current) {
return
@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({
view.isFromCache,
view.isLoadingFromCache,
view.isLoading,
])
])*/
const onScrollToIndexFailed = React.useCallback(
(info: {
index: number
@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({
[ref],
)
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) {
@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({
<ActivityIndicator />
</View>
)
} else if (item instanceof PostThreadItemModel) {
const prev = (
index - 1 >= 0 ? posts[index - 1] : undefined
) as PostThreadItemModel
} else if (isThreadPost(item)) {
const prev = isThreadPost(posts[index - 1])
? (posts[index - 1] as ThreadPost)
: undefined
return (
<PostThreadItem
item={item}
onPostReply={onRefresh}
hasPrecedingItem={prev?._showChildReplyLine}
post={item.post}
record={item.record}
dataUpdatedAt={dataUpdatedAt}
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,
@ -278,75 +323,116 @@ export const PostThread = observer(function PostThread({
posts,
onRefresh,
treeView,
dataUpdatedAt,
_,
],
)
// loading
// =
if (
!view.hasLoaded ||
(view.isLoading && !view.isRefreshing) ||
view.params.uri !== uri
) {
return (
<CenteredView>
<View style={s.p20}>
<ActivityIndicator size="large" />
</View>
</CenteredView>
)
}
return (
<FlatList
ref={ref}
data={posts}
initialNumToRender={posts.length}
maintainVisibleContentPosition={
undefined // TODO
// isNative && view.isFromCache && view.isCachedPostAReply
// ? MAINTAIN_VISIBLE_CONTENT_POSITION
// : undefined
}
keyExtractor={item => item._reactKey}
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
// =
if (view.hasError) {
if (view.notFound) {
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb5]}>
<Trans>Post not found</Trans>
</Text>
<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>
)
function PostThreadBlocked() {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
return (
<CenteredView>
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
</CenteredView>
)
}
if (view.isBlocked) {
}, [navigation])
return (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb5]}>
<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 (
<CenteredView>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb5]}>
<Trans>Post hidden</Trans>
<Trans>Post not found</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>
<Trans>The post may have been deleted.</Trans>
</Text>
<TouchableOpacity
onPress={onPressBack}
@ -366,69 +452,37 @@ export const PostThread = observer(function PostThread({
</CenteredView>
)
}
// loaded
// =
return (
<FlatList
ref={ref}
data={posts}
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
/>
<CenteredView>
<ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
</CenteredView>
)
})
}
function* flattenThread(
post: PostThreadItemModel,
isAscending = false,
function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
}
function* flattenThreadSkeleton(
node: ThreadNode,
): Generator<YieldedItem, void> {
if (post.parent) {
if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
yield DELETED
} else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
yield BLOCKED
} else {
yield* flattenThread(post.parent as PostThreadItemModel, true)
if (node.type === 'post') {
if (node.parent) {
yield* flattenThreadSkeleton(node.parent)
}
}
yield post
if (post._isHighlightedPost) {
yield REPLY_PROMPT
}
if (post.replies?.length) {
for (const reply of post.replies) {
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
yield DELETED
} else {
yield* flattenThread(reply as PostThreadItemModel)
yield node
if (node.ctx.isHighlightedPost) {
yield REPLY_PROMPT
}
if (node.replies?.length) {
for (const reply of node.replies) {
yield* flattenThreadSkeleton(reply)
}
}
} else if (!isAscending && !post.parent && post.post.replyCount) {
runInAction(() => {
post._hasMore = true
})
} else if (node.type === 'not-found') {
yield DELETED
} else if (node.type === 'blocked') {
yield BLOCKED
}
}

View File

@ -1,18 +1,17 @@
import React, {useMemo} from 'react'
import {observer} from 'mobx-react-lite'
import {Linking, StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri, AppBskyFeedDefs} from '@atproto/api'
import {StyleSheet, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
AtUri,
AppBskyFeedDefs,
AppBskyFeedPost,
RichText as RichTextAPI,
moderatePost,
PostModeration,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text'
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles'
import {niceDate} from 'lib/strings/time'
@ -24,7 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta'
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 {ContentHider} from '../util/moderation/ContentHider'
import {PostAlerts} from '../util/moderation/PostAlerts'
@ -36,54 +36,145 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {makeProfileLink} from 'lib/routes/links'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {MAX_POST_LINES} from 'lib/constants'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
export const PostThreadItem = observer(function PostThreadItem({
item,
onPostReply,
hasPrecedingItem,
export function PostThreadItem({
post,
record,
dataUpdatedAt,
treeView,
depth,
isHighlightedPost,
hasMore,
showChildReplyLine,
showParentReplyLine,
hasPrecedingItem,
onPostReply,
}: {
item: PostThreadItemModel
onPostReply: () => void
hasPrecedingItem: boolean
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
dataUpdatedAt: number
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 store = useStores()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const langPrefs = useLanguagePrefs()
const [deleted, setDeleted] = React.useState(false)
const [limitLines, setLimitLines] = React.useState(
countLines(item.richText?.text) >= MAX_POST_LINES,
countLines(richText?.text) >= MAX_POST_LINES,
)
const styles = useStyles()
const record = item.postRecord
const hasEngagement = item.post.likeCount || item.post.repostCount
const hasEngagement = post.likeCount || post.repostCount
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return makeProfileLink(item.post.author, 'post', urip.rkey)
}, [item.post.uri, item.post.author])
const itemTitle = `Post by ${item.post.author.handle}`
const authorHref = makeProfileLink(item.post.author)
const authorTitle = item.post.author.handle
const isAuthorMuted = item.post.author.viewer?.muted
const rootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const itemTitle = `Post by ${post.author.handle}`
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
const isAuthorMuted = post.author.viewer?.muted
const likesHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by')
}, [item.post.uri, item.post.author])
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
}, [post.uri, post.author])
const likesTitle = 'Likes on this post'
const repostsHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by')
}, [item.post.uri, item.post.author])
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const repostsTitle = 'Reposts of this post'
const translatorUrl = getTranslatorLink(
@ -94,73 +185,26 @@ export const PostThreadItem = observer(function PostThreadItem({
() =>
Boolean(
langPrefs.primaryLanguage &&
!isPostInLanguage(item.post, [langPrefs.primaryLanguage]),
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
),
[item.post, langPrefs.primaryLanguage],
[post, langPrefs.primaryLanguage],
)
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
replyTo: {
uri: item.post.uri,
cid: item.post.cid,
text: record?.text as string,
uri: post.uri,
cid: post.cid,
text: record.text,
author: {
handle: item.post.author.handle,
displayName: item.post.author.displayName,
avatar: item.post.author.avatar,
handle: post.author.handle,
displayName: post.author.displayName,
avatar: post.author.avatar,
},
},
onPost: onPostReply,
})
}, [store, item, 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])
}, [store, post, record, onPostReply])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
@ -170,24 +214,10 @@ export const PostThreadItem = observer(function PostThreadItem({
return <ErrorMessage message="Invalid or unsupported post record" />
}
if (deleted) {
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) {
if (isHighlightedPost) {
return (
<>
{item.rootUri !== item.uri && (
{rootUri !== post.uri && (
<View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
<View style={{width: 38}}>
<View
@ -204,7 +234,7 @@ export const PostThreadItem = observer(function PostThreadItem({
)}
<Link
testID={`postThreadItem-by-${item.post.author.handle}`}
testID={`postThreadItem-by-${post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
noFeedback
accessible={false}>
@ -213,10 +243,10 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar
size={52}
did={item.post.author.did}
handle={item.post.author.handle}
avatar={item.post.author.avatar}
moderation={item.moderation.avatar}
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
moderation={moderation.avatar}
/>
</View>
<View style={styles.layoutContent}>
@ -233,17 +263,17 @@ export const PostThreadItem = observer(function PostThreadItem({
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
item.post.author.displayName ||
sanitizeHandle(item.post.author.handle),
post.author.displayName ||
sanitizeHandle(post.author.handle),
)}
</Text>
</Link>
<TimeElapsed timestamp={item.post.indexedAt}>
<TimeElapsed timestamp={post.indexedAt}>
{({timeElapsed}) => (
<Text
type="md"
style={[styles.metaItem, pal.textLight]}
title={niceDate(item.post.indexedAt)}>
title={niceDate(post.indexedAt)}>
&middot;&nbsp;{timeElapsed}
</Text>
)}
@ -280,23 +310,15 @@ export const PostThreadItem = observer(function PostThreadItem({
href={authorHref}
title={authorTitle}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(item.post.author.handle, '@')}
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
</View>
<PostDropdownBtn
testID="postDropdownBtn"
itemUri={itemUri}
itemCid={itemCid}
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}
post={post}
record={record}
style={{
paddingVertical: 6,
paddingHorizontal: 10,
@ -307,16 +329,16 @@ export const PostThreadItem = observer(function PostThreadItem({
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
<ContentHider
moderation={item.moderation.content}
moderation={moderation.content}
ignoreMute
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
<PostAlerts
moderation={item.moderation.content}
moderation={moderation.content}
includeMute
style={styles.alert}
/>
{item.richText?.text ? (
{richText?.text ? (
<View
style={[
styles.postTextContainer,
@ -324,59 +346,56 @@ export const PostThreadItem = observer(function PostThreadItem({
]}>
<RichText
type="post-text-lg"
richText={item.richText}
richText={richText}
lineHeight={1.3}
style={s.flex1}
/>
</View>
) : undefined}
{item.post.embed && (
{post.embed && (
<ContentHider
moderation={item.moderation.embed}
ignoreMute={isEmbedByEmbedder(
item.post.embed,
item.post.author.did,
)}
moderation={moderation.embed}
ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
style={s.mb10}>
<PostEmbeds
embed={item.post.embed}
moderation={item.moderation.embed}
embed={post.embed}
moderation={moderation.embed}
/>
</ContentHider>
)}
</ContentHider>
<ExpandedPostDetails
post={item.post}
post={post}
translatorUrl={translatorUrl}
needsTranslation={needsTranslation}
/>
{hasEngagement ? (
<View style={[styles.expandedInfo, pal.border]}>
{item.post.repostCount ? (
{post.repostCount ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text testID="repostCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(item.post.repostCount)}
{formatCount(post.repostCount)}
</Text>{' '}
{pluralize(item.post.repostCount, 'repost')}
{pluralize(post.repostCount, 'repost')}
</Text>
</Link>
) : (
<></>
)}
{item.post.likeCount ? (
{post.likeCount ? (
<Link
style={styles.expandedInfoItem}
href={likesHref}
title={likesTitle}>
<Text testID="likeCount" type="lg" style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(item.post.likeCount)}
{formatCount(post.likeCount)}
</Text>{' '}
{pluralize(item.post.likeCount, 'like')}
{pluralize(post.likeCount, 'like')}
</Text>
</Link>
) : (
@ -389,24 +408,9 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[s.pl10, s.pb5]}>
<PostCtrls
big
itemUri={itemUri}
itemCid={itemCid}
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)}
post={post}
record={record}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/>
</View>
</View>
@ -414,17 +418,19 @@ export const PostThreadItem = observer(function PostThreadItem({
</>
)
} else {
const isThreadedChild = treeView && item._depth > 1
const isThreadedChild = treeView && depth > 1
return (
<PostOuterWrapper
item={item}
hasPrecedingItem={hasPrecedingItem}
treeView={treeView}>
post={post}
depth={depth}
showParentReplyLine={!!showParentReplyLine}
treeView={treeView}
hasPrecedingItem={hasPrecedingItem}>
<PostHider
testID={`postThreadItem-by-${item.post.author.handle}`}
href={itemHref}
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
style={[pal.view]}
moderation={item.moderation.content}>
moderation={moderation.content}>
<PostSandboxWarning />
<View
@ -435,7 +441,7 @@ export const PostThreadItem = observer(function PostThreadItem({
height: isThreadedChild ? 8 : 16,
}}>
<View style={{width: 38}}>
{!isThreadedChild && item._showParentReplyLine && (
{!isThreadedChild && showParentReplyLine && (
<View
style={[
styles.replyLine,
@ -454,21 +460,20 @@ export const PostThreadItem = observer(function PostThreadItem({
style={[
styles.layout,
{
paddingBottom:
item._showChildReplyLine && !isThreadedChild ? 0 : 8,
paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8,
},
]}>
{!isThreadedChild && (
<View style={styles.layoutAvi}>
<PreviewableUserAvatar
size={38}
did={item.post.author.did}
handle={item.post.author.handle}
avatar={item.post.author.avatar}
moderation={item.moderation.avatar}
did={post.author.did}
handle={post.author.handle}
avatar={post.author.avatar}
moderation={moderation.avatar}
/>
{item._showChildReplyLine && (
{showChildReplyLine && (
<View
style={[
styles.replyLine,
@ -485,10 +490,10 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={styles.layoutContent}>
<PostMeta
author={item.post.author}
authorHasWarning={!!item.post.author.labels?.length}
timestamp={item.post.indexedAt}
postHref={itemHref}
author={post.author}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={postHref}
showAvatar={isThreadedChild}
avatarSize={26}
displayNameType="md-bold"
@ -496,14 +501,14 @@ export const PostThreadItem = observer(function PostThreadItem({
style={isThreadedChild && s.mb5}
/>
<PostAlerts
moderation={item.moderation.content}
moderation={moderation.content}
style={styles.alert}
/>
{item.richText?.text ? (
{richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
type="post-text"
richText={item.richText}
richText={richText}
style={[pal.text, s.flex1]}
lineHeight={1.3}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
@ -518,42 +523,24 @@ export const PostThreadItem = observer(function PostThreadItem({
href="#"
/>
) : undefined}
{item.post.embed && (
{post.embed && (
<ContentHider
style={styles.contentHider}
moderation={item.moderation.embed}>
moderation={moderation.embed}>
<PostEmbeds
embed={item.post.embed}
moderation={item.moderation.embed}
embed={post.embed}
moderation={moderation.embed}
/>
</ContentHider>
)}
<PostCtrls
itemUri={itemUri}
itemCid={itemCid}
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)}
post={post}
record={record}
onPressReply={onPressReply}
onPressToggleRepost={onPressToggleRepost}
onPressToggleLike={onPressToggleLike}
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}
/>
</View>
</View>
{item._hasMore ? (
{hasMore ? (
<Link
style={[
styles.loadMore,
@ -563,7 +550,7 @@ export const PostThreadItem = observer(function PostThreadItem({
paddingBottom: treeView ? 4 : 12,
},
]}
href={itemHref}
href={postHref}
title={itemTitle}
noFeedback>
<Text type="sm-medium" style={pal.textLight}>
@ -580,22 +567,26 @@ export const PostThreadItem = observer(function PostThreadItem({
</PostOuterWrapper>
)
}
})
}
function PostOuterWrapper({
item,
hasPrecedingItem,
post,
treeView,
depth,
showParentReplyLine,
hasPrecedingItem,
children,
}: React.PropsWithChildren<{
item: PostThreadItemModel
hasPrecedingItem: boolean
post: AppBskyFeedDefs.PostView
treeView: boolean
depth: number
showParentReplyLine: boolean
hasPrecedingItem: boolean
}>) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const styles = useStyles()
if (treeView && item._depth > 1) {
if (treeView && depth > 1) {
return (
<View
style={[
@ -605,13 +596,13 @@ function PostOuterWrapper({
{
flexDirection: 'row',
paddingLeft: 20,
borderTopWidth: item._depth === 1 ? 1 : 0,
paddingTop: item._depth === 1 ? 8 : 0,
borderTopWidth: depth === 1 ? 1 : 0,
paddingTop: depth === 1 ? 8 : 0,
},
]}>
{Array.from(Array(item._depth - 1)).map((_, n: number) => (
{Array.from(Array(depth - 1)).map((_, n: number) => (
<View
key={`${item.uri}-padding-${n}`}
key={`${post.uri}-padding-${n}`}
style={{
borderLeftWidth: 2,
borderLeftColor: pal.colors.border,
@ -630,7 +621,7 @@ function PostOuterWrapper({
styles.outer,
pal.view,
pal.border,
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor,
]}>
{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 {InteractionManager, StyleSheet, View} from 'react-native'
import React from 'react'
import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {observer} from 'mobx-react-lite'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
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 {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThreadModel} from 'state/models/content/post-thread'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {logger} from '#/logger'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
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'>
export const PostThreadScreen = withAuthRequired(
observer(function PostThreadScreenImpl({route}: Props) {
const store = useStores()
const queryClient = useQueryClient()
const {fabMinimalShellTransform} = useMinimalShellMode()
const setMinimalShellMode = useSetMinimalShellMode()
const safeAreaInsets = useSafeAreaInsets()
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 uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(false)
const threadCleanup = view.registerListeners()
InteractionManager.runAfterInteractions(() => {
if (!view.hasLoaded && !view.isLoading) {
view.setup().catch(err => {
logger.error('Failed to fetch thread', {error: err})
})
}
})
return () => {
threadCleanup()
}
}, [view, setMinimalShellMode]),
}, [setMinimalShellMode]),
)
const onPressReply = React.useCallback(() => {
if (!view.thread) {
if (!resolvedUri) {
return
}
const thread = queryClient.getQueryData<ThreadNode>(
POST_THREAD_RQKEY(resolvedUri),
)
if (thread?.type !== 'post') {
return
}
store.shell.openComposer({
replyTo: {
uri: view.thread.post.uri,
cid: view.thread.post.cid,
text: view.thread.postRecord?.text as string,
uri: thread.post.uri,
cid: thread.post.cid,
text: thread.record.text,
author: {
handle: view.thread.post.author.handle,
displayName: view.thread.post.author.displayName,
avatar: view.thread.post.author.avatar,
handle: thread.post.author.handle,
displayName: thread.post.author.displayName,
avatar: thread.post.author.avatar,
},
},
onPost: () => view.refresh(),
onPost: () =>
queryClient.invalidateQueries({
queryKey: POST_THREAD_RQKEY(resolvedUri || ''),
}),
})
}, [view, store])
}, [store, queryClient, resolvedUri])
return (
<View style={s.hContentRegion}>
{isMobile && <ViewHeader title="Post" />}
<View style={s.flex1}>
<PostThreadComponent
uri={uri}
view={view}
onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled}
/>
{uriError ? (
<CenteredView>
<ErrorMessage message={String(uriError)} />
</CenteredView>
) : (
<PostThreadComponent
uri={resolvedUri}
onPressReply={onPressReply}
treeView={!!store.preferences.thread.lab_treeViewEnabled}
/>
)}
</View>
{isMobile && (
<Animated.View