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 errorszio/stable
parent
625cbc435f
commit
fb4f5709c4
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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: ''}
|
||||
}
|
||||
}
|
|
@ -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})
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)}>
|
||||
· {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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue