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)
|
analytics.init(store)
|
||||||
})
|
})
|
||||||
dynamicActivate(defaultLocale) // async import of locale data
|
dynamicActivate(defaultLocale) // async import of locale data
|
||||||
}, [resumeSession])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const account = persisted.get('session').currentAccount
|
const account = persisted.get('session').currentAccount
|
||||||
|
|
|
@ -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)
|
upsertAccount(account)
|
||||||
|
|
||||||
logger.debug(`session: logged in`, {
|
logger.debug(`session: logged in`, {
|
||||||
|
@ -238,6 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
setState(s => ({...s, agent}))
|
||||||
upsertAccount(account)
|
upsertAccount(account)
|
||||||
},
|
},
|
||||||
[upsertAccount],
|
[upsertAccount],
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {ServiceDescription} from 'state/models/session'
|
||||||
import {isNetworkError} from 'lib/strings/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
import {useSessionApi} from '#/state/session'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
|
@ -59,6 +60,7 @@ export const LoginForm = ({
|
||||||
const passwordInputRef = useRef<TextInput>(null)
|
const passwordInputRef = useRef<TextInput>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
|
const {login} = useSessionApi()
|
||||||
|
|
||||||
const onPressSelectService = () => {
|
const onPressSelectService = () => {
|
||||||
openModal({
|
openModal({
|
||||||
|
@ -98,6 +100,12 @@ export const LoginForm = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO remove double login
|
||||||
|
await login({
|
||||||
|
service: serviceUrl,
|
||||||
|
identifier: fullIdent,
|
||||||
|
password,
|
||||||
|
})
|
||||||
await store.session.login({
|
await store.session.login({
|
||||||
service: serviceUrl,
|
service: serviceUrl,
|
||||||
identifier: fullIdent,
|
identifier: fullIdent,
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import React, {useRef} from 'react'
|
import React, {useRef} from 'react'
|
||||||
import {runInAction} from 'mobx'
|
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
@ -11,8 +9,6 @@ import {
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyFeedDefs} from '@atproto/api'
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {CenteredView, FlatList} from '../util/Views'
|
import {CenteredView, FlatList} from '../util/Views'
|
||||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
|
||||||
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
|
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
|
@ -23,45 +19,36 @@ import {ViewHeader} from '../util/ViewHeader'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isNative} from 'platform/detection'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||||
|
import {
|
||||||
|
ThreadNode,
|
||||||
|
ThreadPost,
|
||||||
|
usePostThreadQuery,
|
||||||
|
sortThread,
|
||||||
|
} from '#/state/queries/post-thread'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {logger} from '#/logger'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {useStores} from '#/state'
|
||||||
import {Trans, msg} from '@lingui/macro'
|
import {Trans, msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
|
// const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} TODO
|
||||||
|
|
||||||
|
const TOP_COMPONENT = {_reactKey: '__top_component__'}
|
||||||
|
const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
|
||||||
|
const REPLY_PROMPT = {_reactKey: '__reply__'}
|
||||||
|
const DELETED = {_reactKey: '__deleted__'}
|
||||||
|
const BLOCKED = {_reactKey: '__blocked__'}
|
||||||
|
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
|
||||||
|
const LOAD_MORE = {_reactKey: '__load_more__'}
|
||||||
|
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
|
||||||
|
|
||||||
const TOP_COMPONENT = {
|
|
||||||
_reactKey: '__top_component__',
|
|
||||||
_isHighlightedPost: false,
|
|
||||||
}
|
|
||||||
const PARENT_SPINNER = {
|
|
||||||
_reactKey: '__parent_spinner__',
|
|
||||||
_isHighlightedPost: false,
|
|
||||||
}
|
|
||||||
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
|
|
||||||
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
|
|
||||||
const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
|
|
||||||
const CHILD_SPINNER = {
|
|
||||||
_reactKey: '__child_spinner__',
|
|
||||||
_isHighlightedPost: false,
|
|
||||||
}
|
|
||||||
const LOAD_MORE = {
|
|
||||||
_reactKey: '__load_more__',
|
|
||||||
_isHighlightedPost: false,
|
|
||||||
}
|
|
||||||
const BOTTOM_COMPONENT = {
|
|
||||||
_reactKey: '__bottom_component__',
|
|
||||||
_isHighlightedPost: false,
|
|
||||||
_showBorder: true,
|
|
||||||
}
|
|
||||||
type YieldedItem =
|
type YieldedItem =
|
||||||
| PostThreadItemModel
|
| ThreadPost
|
||||||
| typeof TOP_COMPONENT
|
| typeof TOP_COMPONENT
|
||||||
| typeof PARENT_SPINNER
|
| typeof PARENT_SPINNER
|
||||||
| typeof REPLY_PROMPT
|
| typeof REPLY_PROMPT
|
||||||
|
@ -69,66 +56,125 @@ type YieldedItem =
|
||||||
| typeof BLOCKED
|
| typeof BLOCKED
|
||||||
| typeof PARENT_SPINNER
|
| typeof PARENT_SPINNER
|
||||||
|
|
||||||
export const PostThread = observer(function PostThread({
|
export function PostThread({
|
||||||
uri,
|
uri,
|
||||||
view,
|
|
||||||
onPressReply,
|
onPressReply,
|
||||||
treeView,
|
treeView,
|
||||||
}: {
|
}: {
|
||||||
uri: string
|
uri: string | undefined
|
||||||
view: PostThreadModel
|
|
||||||
onPressReply: () => void
|
onPressReply: () => void
|
||||||
treeView: boolean
|
treeView: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const {
|
||||||
const {_} = useLingui()
|
isLoading,
|
||||||
const {isTablet, isDesktop} = useWebMediaQueries()
|
isError,
|
||||||
const ref = useRef<FlatList>(null)
|
error,
|
||||||
const hasScrolledIntoView = useRef<boolean>(false)
|
refetch,
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
isRefetching,
|
||||||
const [maxVisible, setMaxVisible] = React.useState(100)
|
data: thread,
|
||||||
const navigation = useNavigation<NavigationProp>()
|
dataUpdatedAt,
|
||||||
const posts = React.useMemo(() => {
|
} = usePostThreadQuery(uri)
|
||||||
if (view.thread) {
|
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
||||||
let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
|
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
||||||
if (arr.length > maxVisible) {
|
|
||||||
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
|
||||||
}
|
|
||||||
if (view.isLoadingFromCache) {
|
|
||||||
if (view.thread?.postRecord?.reply) {
|
|
||||||
arr.unshift(PARENT_SPINNER)
|
|
||||||
}
|
|
||||||
arr.push(CHILD_SPINNER)
|
|
||||||
} else {
|
|
||||||
arr.push(BOTTOM_COMPONENT)
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}, [view.isLoadingFromCache, view.thread, maxVisible])
|
|
||||||
const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
|
|
||||||
useSetTitle(
|
useSetTitle(
|
||||||
view.thread?.postRecord &&
|
rootPost &&
|
||||||
`${sanitizeDisplayName(
|
`${sanitizeDisplayName(
|
||||||
view.thread.post.author.displayName ||
|
rootPost.author.displayName || `@${rootPost.author.handle}`,
|
||||||
`@${view.thread.post.author.handle}`,
|
)}: "${rootPostRecord?.text}"`,
|
||||||
)}: "${view.thread?.postRecord?.text}"`,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// events
|
if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
|
||||||
// =
|
return (
|
||||||
|
<PostThreadError
|
||||||
|
error={error}
|
||||||
|
notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
|
||||||
|
onRefresh={refetch}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (AppBskyFeedDefs.isBlockedPost(thread)) {
|
||||||
|
return <PostThreadBlocked />
|
||||||
|
}
|
||||||
|
if (!thread || isLoading) {
|
||||||
|
return (
|
||||||
|
<CenteredView>
|
||||||
|
<View style={s.p20}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</View>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PostThreadLoaded
|
||||||
|
thread={thread}
|
||||||
|
isRefetching={isRefetching}
|
||||||
|
dataUpdatedAt={dataUpdatedAt}
|
||||||
|
treeView={treeView}
|
||||||
|
onRefresh={refetch}
|
||||||
|
onPressReply={onPressReply}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const onRefresh = React.useCallback(async () => {
|
function PostThreadLoaded({
|
||||||
setIsRefreshing(true)
|
thread,
|
||||||
try {
|
isRefetching,
|
||||||
view?.refresh()
|
dataUpdatedAt,
|
||||||
} catch (err) {
|
treeView,
|
||||||
logger.error('Failed to refresh posts thread', {error: err})
|
onRefresh,
|
||||||
|
onPressReply,
|
||||||
|
}: {
|
||||||
|
thread: ThreadNode
|
||||||
|
isRefetching: boolean
|
||||||
|
dataUpdatedAt: number
|
||||||
|
treeView: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
onPressReply: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const store = useStores()
|
||||||
|
const {isTablet, isDesktop} = useWebMediaQueries()
|
||||||
|
const ref = useRef<FlatList>(null)
|
||||||
|
// const hasScrolledIntoView = useRef<boolean>(false) TODO
|
||||||
|
const [maxVisible, setMaxVisible] = React.useState(100)
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// const posts = React.useMemo(() => {
|
||||||
|
// if (view.thread) {
|
||||||
|
// let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread)))
|
||||||
|
// if (arr.length > maxVisible) {
|
||||||
|
// arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
||||||
|
// }
|
||||||
|
// if (view.isLoadingFromCache) {
|
||||||
|
// if (view.thread?.postRecord?.reply) {
|
||||||
|
// arr.unshift(PARENT_SPINNER)
|
||||||
|
// }
|
||||||
|
// arr.push(CHILD_SPINNER)
|
||||||
|
// } else {
|
||||||
|
// arr.push(BOTTOM_COMPONENT)
|
||||||
|
// }
|
||||||
|
// return arr
|
||||||
|
// }
|
||||||
|
// return []
|
||||||
|
// }, [view.isLoadingFromCache, view.thread, maxVisible])
|
||||||
|
// const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost)
|
||||||
|
const posts = React.useMemo(() => {
|
||||||
|
let arr = [TOP_COMPONENT].concat(
|
||||||
|
Array.from(
|
||||||
|
flattenThreadSkeleton(sortThread(thread, store.preferences.thread)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (arr.length > maxVisible) {
|
||||||
|
arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
|
||||||
}
|
}
|
||||||
setIsRefreshing(false)
|
arr.push(BOTTOM_COMPONENT)
|
||||||
}, [view, setIsRefreshing])
|
return arr
|
||||||
|
}, [thread, maxVisible, store.preferences.thread])
|
||||||
|
|
||||||
const onContentSizeChange = React.useCallback(() => {
|
// TODO
|
||||||
|
/*const onContentSizeChange = React.useCallback(() => {
|
||||||
// only run once
|
// only run once
|
||||||
if (hasScrolledIntoView.current) {
|
if (hasScrolledIntoView.current) {
|
||||||
return
|
return
|
||||||
|
@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({
|
||||||
view.isFromCache,
|
view.isFromCache,
|
||||||
view.isLoadingFromCache,
|
view.isLoadingFromCache,
|
||||||
view.isLoading,
|
view.isLoading,
|
||||||
])
|
])*/
|
||||||
const onScrollToIndexFailed = React.useCallback(
|
const onScrollToIndexFailed = React.useCallback(
|
||||||
(info: {
|
(info: {
|
||||||
index: number
|
index: number
|
||||||
|
@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({
|
||||||
[ref],
|
[ref],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressBack = React.useCallback(() => {
|
|
||||||
if (navigation.canGoBack()) {
|
|
||||||
navigation.goBack()
|
|
||||||
} else {
|
|
||||||
navigation.navigate('Home')
|
|
||||||
}
|
|
||||||
}, [navigation])
|
|
||||||
|
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
({item, index}: {item: YieldedItem; index: number}) => {
|
({item, index}: {item: YieldedItem; index: number}) => {
|
||||||
if (item === TOP_COMPONENT) {
|
if (item === TOP_COMPONENT) {
|
||||||
|
@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
} else if (item instanceof PostThreadItemModel) {
|
} else if (isThreadPost(item)) {
|
||||||
const prev = (
|
const prev = isThreadPost(posts[index - 1])
|
||||||
index - 1 >= 0 ? posts[index - 1] : undefined
|
? (posts[index - 1] as ThreadPost)
|
||||||
) as PostThreadItemModel
|
: undefined
|
||||||
return (
|
return (
|
||||||
<PostThreadItem
|
<PostThreadItem
|
||||||
item={item}
|
post={item.post}
|
||||||
onPostReply={onRefresh}
|
record={item.record}
|
||||||
hasPrecedingItem={prev?._showChildReplyLine}
|
dataUpdatedAt={dataUpdatedAt}
|
||||||
treeView={treeView}
|
treeView={treeView}
|
||||||
|
depth={item.ctx.depth}
|
||||||
|
isHighlightedPost={item.ctx.isHighlightedPost}
|
||||||
|
hasMore={item.ctx.hasMore}
|
||||||
|
showChildReplyLine={item.ctx.showChildReplyLine}
|
||||||
|
showParentReplyLine={item.ctx.showParentReplyLine}
|
||||||
|
hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
|
||||||
|
onPostReply={onRefresh}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <></>
|
return null
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
isTablet,
|
isTablet,
|
||||||
|
@ -278,75 +323,116 @@ export const PostThread = observer(function PostThread({
|
||||||
posts,
|
posts,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
treeView,
|
treeView,
|
||||||
|
dataUpdatedAt,
|
||||||
_,
|
_,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
// loading
|
return (
|
||||||
// =
|
<FlatList
|
||||||
if (
|
ref={ref}
|
||||||
!view.hasLoaded ||
|
data={posts}
|
||||||
(view.isLoading && !view.isRefreshing) ||
|
initialNumToRender={posts.length}
|
||||||
view.params.uri !== uri
|
maintainVisibleContentPosition={
|
||||||
) {
|
undefined // TODO
|
||||||
return (
|
// isNative && view.isFromCache && view.isCachedPostAReply
|
||||||
<CenteredView>
|
// ? MAINTAIN_VISIBLE_CONTENT_POSITION
|
||||||
<View style={s.p20}>
|
// : undefined
|
||||||
<ActivityIndicator size="large" />
|
}
|
||||||
</View>
|
keyExtractor={item => item._reactKey}
|
||||||
</CenteredView>
|
renderItem={renderItem}
|
||||||
)
|
refreshControl={
|
||||||
}
|
<RefreshControl
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={pal.colors.text}
|
||||||
|
titleColor={pal.colors.text}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onContentSizeChange={
|
||||||
|
undefined //TODOisNative && view.isFromCache ? undefined : onContentSizeChange
|
||||||
|
}
|
||||||
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||||
|
style={s.hContentRegion}
|
||||||
|
// @ts-ignore our .web version only -prf
|
||||||
|
desktopFixedHeight
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// error
|
function PostThreadBlocked() {
|
||||||
// =
|
const {_} = useLingui()
|
||||||
if (view.hasError) {
|
const pal = usePalette('default')
|
||||||
if (view.notFound) {
|
const navigation = useNavigation<NavigationProp>()
|
||||||
return (
|
|
||||||
<CenteredView>
|
const onPressBack = React.useCallback(() => {
|
||||||
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
if (navigation.canGoBack()) {
|
||||||
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
navigation.goBack()
|
||||||
<Trans>Post not found</Trans>
|
} else {
|
||||||
</Text>
|
navigation.navigate('Home')
|
||||||
<Text type="md" style={[pal.text, s.mb10]}>
|
|
||||||
<Trans>The post may have been deleted.</Trans>
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPressBack}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Back`)}
|
|
||||||
accessibilityHint="">
|
|
||||||
<Text type="2xl" style={pal.link}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="angle-left"
|
|
||||||
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</CenteredView>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (
|
}, [navigation])
|
||||||
<CenteredView>
|
|
||||||
<ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
|
return (
|
||||||
</CenteredView>
|
<CenteredView>
|
||||||
)
|
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
||||||
}
|
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
||||||
if (view.isBlocked) {
|
<Trans>Post hidden</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text type="md" style={[pal.text, s.mb10]}>
|
||||||
|
<Trans>
|
||||||
|
You have blocked the author or you have been blocked by the author.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPressBack}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Back`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<Text type="2xl" style={pal.link}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="angle-left"
|
||||||
|
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</CenteredView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostThreadError({
|
||||||
|
onRefresh,
|
||||||
|
notFound,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
onRefresh: () => void
|
||||||
|
notFound: boolean
|
||||||
|
error: Error | null
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const onPressBack = React.useCallback(() => {
|
||||||
|
if (navigation.canGoBack()) {
|
||||||
|
navigation.goBack()
|
||||||
|
} else {
|
||||||
|
navigation.navigate('Home')
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<CenteredView>
|
<CenteredView>
|
||||||
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
||||||
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
||||||
<Trans>Post hidden</Trans>
|
<Trans>Post not found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="md" style={[pal.text, s.mb10]}>
|
<Text type="md" style={[pal.text, s.mb10]}>
|
||||||
<Trans>
|
<Trans>The post may have been deleted.</Trans>
|
||||||
You have blocked the author or you have been blocked by the
|
|
||||||
author.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPressBack}
|
onPress={onPressBack}
|
||||||
|
@ -366,69 +452,37 @@ export const PostThread = observer(function PostThread({
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loaded
|
|
||||||
// =
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<CenteredView>
|
||||||
ref={ref}
|
<ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
|
||||||
data={posts}
|
</CenteredView>
|
||||||
initialNumToRender={posts.length}
|
|
||||||
maintainVisibleContentPosition={
|
|
||||||
isNative && view.isFromCache && view.isCachedPostAReply
|
|
||||||
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
keyExtractor={item => item._reactKey}
|
|
||||||
renderItem={renderItem}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefreshing}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
tintColor={pal.colors.text}
|
|
||||||
titleColor={pal.colors.text}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onContentSizeChange={
|
|
||||||
isNative && view.isFromCache ? undefined : onContentSizeChange
|
|
||||||
}
|
|
||||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
||||||
style={s.hContentRegion}
|
|
||||||
// @ts-ignore our .web version only -prf
|
|
||||||
desktopFixedHeight
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
function* flattenThread(
|
function isThreadPost(v: unknown): v is ThreadPost {
|
||||||
post: PostThreadItemModel,
|
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
|
||||||
isAscending = false,
|
}
|
||||||
|
|
||||||
|
function* flattenThreadSkeleton(
|
||||||
|
node: ThreadNode,
|
||||||
): Generator<YieldedItem, void> {
|
): Generator<YieldedItem, void> {
|
||||||
if (post.parent) {
|
if (node.type === 'post') {
|
||||||
if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
|
if (node.parent) {
|
||||||
yield DELETED
|
yield* flattenThreadSkeleton(node.parent)
|
||||||
} else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
|
|
||||||
yield BLOCKED
|
|
||||||
} else {
|
|
||||||
yield* flattenThread(post.parent as PostThreadItemModel, true)
|
|
||||||
}
|
}
|
||||||
}
|
yield node
|
||||||
yield post
|
if (node.ctx.isHighlightedPost) {
|
||||||
if (post._isHighlightedPost) {
|
yield REPLY_PROMPT
|
||||||
yield REPLY_PROMPT
|
}
|
||||||
}
|
if (node.replies?.length) {
|
||||||
if (post.replies?.length) {
|
for (const reply of node.replies) {
|
||||||
for (const reply of post.replies) {
|
yield* flattenThreadSkeleton(reply)
|
||||||
if (AppBskyFeedDefs.isNotFoundPost(reply)) {
|
|
||||||
yield DELETED
|
|
||||||
} else {
|
|
||||||
yield* flattenThread(reply as PostThreadItemModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!isAscending && !post.parent && post.post.replyCount) {
|
} else if (node.type === 'not-found') {
|
||||||
runInAction(() => {
|
yield DELETED
|
||||||
post._hasMore = true
|
} else if (node.type === 'blocked') {
|
||||||
})
|
yield BLOCKED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
import React, {useMemo} from 'react'
|
import React, {useMemo} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {Linking, StyleSheet, View} from 'react-native'
|
|
||||||
import Clipboard from '@react-native-clipboard/clipboard'
|
|
||||||
import {AtUri, AppBskyFeedDefs} from '@atproto/api'
|
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
AtUri,
|
||||||
FontAwesomeIconStyle,
|
AppBskyFeedDefs,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
AppBskyFeedPost,
|
||||||
import {PostThreadItemModel} from 'state/models/content/post-thread-item'
|
RichText as RichTextAPI,
|
||||||
|
moderatePost,
|
||||||
|
PostModeration,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {Link, TextLink} from '../util/Link'
|
import {Link, TextLink} from '../util/Link'
|
||||||
import {RichText} from '../util/text/RichText'
|
import {RichText} from '../util/text/RichText'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
|
|
||||||
import * as Toast from '../util/Toast'
|
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {niceDate} from 'lib/strings/time'
|
import {niceDate} from 'lib/strings/time'
|
||||||
|
@ -24,7 +23,8 @@ import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds} from '../util/post-embeds'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls2'
|
||||||
|
import {PostDropdownBtn} from '../util/forms/PostDropdownBtn2'
|
||||||
import {PostHider} from '../util/moderation/PostHider'
|
import {PostHider} from '../util/moderation/PostHider'
|
||||||
import {ContentHider} from '../util/moderation/ContentHider'
|
import {ContentHider} from '../util/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../util/moderation/PostAlerts'
|
import {PostAlerts} from '../util/moderation/PostAlerts'
|
||||||
|
@ -36,54 +36,145 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {MAX_POST_LINES} from 'lib/constants'
|
import {MAX_POST_LINES} from 'lib/constants'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
import {usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
|
||||||
|
|
||||||
export const PostThreadItem = observer(function PostThreadItem({
|
export function PostThreadItem({
|
||||||
item,
|
post,
|
||||||
onPostReply,
|
record,
|
||||||
hasPrecedingItem,
|
dataUpdatedAt,
|
||||||
treeView,
|
treeView,
|
||||||
|
depth,
|
||||||
|
isHighlightedPost,
|
||||||
|
hasMore,
|
||||||
|
showChildReplyLine,
|
||||||
|
showParentReplyLine,
|
||||||
|
hasPrecedingItem,
|
||||||
|
onPostReply,
|
||||||
}: {
|
}: {
|
||||||
item: PostThreadItemModel
|
post: AppBskyFeedDefs.PostView
|
||||||
onPostReply: () => void
|
record: AppBskyFeedPost.Record
|
||||||
hasPrecedingItem: boolean
|
dataUpdatedAt: number
|
||||||
treeView: boolean
|
treeView: boolean
|
||||||
|
depth: number
|
||||||
|
isHighlightedPost?: boolean
|
||||||
|
hasMore?: boolean
|
||||||
|
showChildReplyLine?: boolean
|
||||||
|
showParentReplyLine?: boolean
|
||||||
|
hasPrecedingItem: boolean
|
||||||
|
onPostReply: () => void
|
||||||
|
}) {
|
||||||
|
const store = useStores()
|
||||||
|
const postShadowed = usePostShadow(post, dataUpdatedAt)
|
||||||
|
const richText = useMemo(
|
||||||
|
() =>
|
||||||
|
post &&
|
||||||
|
AppBskyFeedPost.isRecord(post?.record) &&
|
||||||
|
AppBskyFeedPost.validateRecord(post?.record).success
|
||||||
|
? new RichTextAPI({
|
||||||
|
text: post.record.text,
|
||||||
|
facets: post.record.facets,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
[post],
|
||||||
|
)
|
||||||
|
const moderation = useMemo(
|
||||||
|
() =>
|
||||||
|
post ? moderatePost(post, store.preferences.moderationOpts) : undefined,
|
||||||
|
[post, store],
|
||||||
|
)
|
||||||
|
if (postShadowed === POST_TOMBSTONE) {
|
||||||
|
return <PostThreadItemDeleted />
|
||||||
|
}
|
||||||
|
if (richText && moderation) {
|
||||||
|
return (
|
||||||
|
<PostThreadItemLoaded
|
||||||
|
post={postShadowed}
|
||||||
|
record={record}
|
||||||
|
richText={richText}
|
||||||
|
moderation={moderation}
|
||||||
|
treeView={treeView}
|
||||||
|
depth={depth}
|
||||||
|
isHighlightedPost={isHighlightedPost}
|
||||||
|
hasMore={hasMore}
|
||||||
|
showChildReplyLine={showChildReplyLine}
|
||||||
|
showParentReplyLine={showParentReplyLine}
|
||||||
|
hasPrecedingItem={hasPrecedingItem}
|
||||||
|
onPostReply={onPostReply}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostThreadItemDeleted() {
|
||||||
|
const styles = useStyles()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
return (
|
||||||
|
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
|
||||||
|
<FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} />
|
||||||
|
<Text style={[pal.textLight, s.ml10]}>
|
||||||
|
<Trans>This post has been deleted.</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostThreadItemLoaded({
|
||||||
|
post,
|
||||||
|
record,
|
||||||
|
richText,
|
||||||
|
moderation,
|
||||||
|
treeView,
|
||||||
|
depth,
|
||||||
|
isHighlightedPost,
|
||||||
|
hasMore,
|
||||||
|
showChildReplyLine,
|
||||||
|
showParentReplyLine,
|
||||||
|
hasPrecedingItem,
|
||||||
|
onPostReply,
|
||||||
|
}: {
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
record: AppBskyFeedPost.Record
|
||||||
|
richText: RichTextAPI
|
||||||
|
moderation: PostModeration
|
||||||
|
treeView: boolean
|
||||||
|
depth: number
|
||||||
|
isHighlightedPost?: boolean
|
||||||
|
hasMore?: boolean
|
||||||
|
showChildReplyLine?: boolean
|
||||||
|
showParentReplyLine?: boolean
|
||||||
|
hasPrecedingItem: boolean
|
||||||
|
onPostReply: () => void
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const mutedThreads = useMutedThreads()
|
|
||||||
const toggleThreadMute = useToggleThreadMute()
|
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const [deleted, setDeleted] = React.useState(false)
|
|
||||||
const [limitLines, setLimitLines] = React.useState(
|
const [limitLines, setLimitLines] = React.useState(
|
||||||
countLines(item.richText?.text) >= MAX_POST_LINES,
|
countLines(richText?.text) >= MAX_POST_LINES,
|
||||||
)
|
)
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const record = item.postRecord
|
const hasEngagement = post.likeCount || post.repostCount
|
||||||
const hasEngagement = item.post.likeCount || item.post.repostCount
|
|
||||||
|
|
||||||
const itemUri = item.post.uri
|
const rootUri = record.reply?.root?.uri || post.uri
|
||||||
const itemCid = item.post.cid
|
const postHref = React.useMemo(() => {
|
||||||
const itemHref = React.useMemo(() => {
|
const urip = new AtUri(post.uri)
|
||||||
const urip = new AtUri(item.post.uri)
|
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||||
return makeProfileLink(item.post.author, 'post', urip.rkey)
|
}, [post.uri, post.author])
|
||||||
}, [item.post.uri, item.post.author])
|
const itemTitle = `Post by ${post.author.handle}`
|
||||||
const itemTitle = `Post by ${item.post.author.handle}`
|
const authorHref = makeProfileLink(post.author)
|
||||||
const authorHref = makeProfileLink(item.post.author)
|
const authorTitle = post.author.handle
|
||||||
const authorTitle = item.post.author.handle
|
const isAuthorMuted = post.author.viewer?.muted
|
||||||
const isAuthorMuted = item.post.author.viewer?.muted
|
|
||||||
const likesHref = React.useMemo(() => {
|
const likesHref = React.useMemo(() => {
|
||||||
const urip = new AtUri(item.post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by')
|
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||||
}, [item.post.uri, item.post.author])
|
}, [post.uri, post.author])
|
||||||
const likesTitle = 'Likes on this post'
|
const likesTitle = 'Likes on this post'
|
||||||
const repostsHref = React.useMemo(() => {
|
const repostsHref = React.useMemo(() => {
|
||||||
const urip = new AtUri(item.post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by')
|
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
|
||||||
}, [item.post.uri, item.post.author])
|
}, [post.uri, post.author])
|
||||||
const repostsTitle = 'Reposts of this post'
|
const repostsTitle = 'Reposts of this post'
|
||||||
|
|
||||||
const translatorUrl = getTranslatorLink(
|
const translatorUrl = getTranslatorLink(
|
||||||
|
@ -94,73 +185,26 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
() =>
|
() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
langPrefs.primaryLanguage &&
|
langPrefs.primaryLanguage &&
|
||||||
!isPostInLanguage(item.post, [langPrefs.primaryLanguage]),
|
!isPostInLanguage(post, [langPrefs.primaryLanguage]),
|
||||||
),
|
),
|
||||||
[item.post, langPrefs.primaryLanguage],
|
[post, langPrefs.primaryLanguage],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = React.useCallback(() => {
|
||||||
store.shell.openComposer({
|
store.shell.openComposer({
|
||||||
replyTo: {
|
replyTo: {
|
||||||
uri: item.post.uri,
|
uri: post.uri,
|
||||||
cid: item.post.cid,
|
cid: post.cid,
|
||||||
text: record?.text as string,
|
text: record.text,
|
||||||
author: {
|
author: {
|
||||||
handle: item.post.author.handle,
|
handle: post.author.handle,
|
||||||
displayName: item.post.author.displayName,
|
displayName: post.author.displayName,
|
||||||
avatar: item.post.author.avatar,
|
avatar: post.author.avatar,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onPost: onPostReply,
|
onPost: onPostReply,
|
||||||
})
|
})
|
||||||
}, [store, item, record, onPostReply])
|
}, [store, post, record, onPostReply])
|
||||||
|
|
||||||
const onPressToggleRepost = React.useCallback(() => {
|
|
||||||
return item
|
|
||||||
.toggleRepost()
|
|
||||||
.catch(e => logger.error('Failed to toggle repost', {error: e}))
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onPressToggleLike = React.useCallback(() => {
|
|
||||||
return item
|
|
||||||
.toggleLike()
|
|
||||||
.catch(e => logger.error('Failed to toggle like', {error: e}))
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onCopyPostText = React.useCallback(() => {
|
|
||||||
Clipboard.setString(record?.text || '')
|
|
||||||
Toast.show('Copied to clipboard')
|
|
||||||
}, [record])
|
|
||||||
|
|
||||||
const onOpenTranslate = React.useCallback(() => {
|
|
||||||
Linking.openURL(translatorUrl)
|
|
||||||
}, [translatorUrl])
|
|
||||||
|
|
||||||
const onToggleThreadMute = React.useCallback(() => {
|
|
||||||
try {
|
|
||||||
const muted = toggleThreadMute(item.data.rootUri)
|
|
||||||
if (muted) {
|
|
||||||
Toast.show('You will no longer receive notifications for this thread')
|
|
||||||
} else {
|
|
||||||
Toast.show('You will now receive notifications for this thread')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Failed to toggle thread mute', {error: e})
|
|
||||||
}
|
|
||||||
}, [item, toggleThreadMute])
|
|
||||||
|
|
||||||
const onDeletePost = React.useCallback(() => {
|
|
||||||
item.delete().then(
|
|
||||||
() => {
|
|
||||||
setDeleted(true)
|
|
||||||
Toast.show('Post deleted')
|
|
||||||
},
|
|
||||||
e => {
|
|
||||||
logger.error('Failed to delete post', {error: e})
|
|
||||||
Toast.show('Failed to delete post, please try again')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}, [item])
|
|
||||||
|
|
||||||
const onPressShowMore = React.useCallback(() => {
|
const onPressShowMore = React.useCallback(() => {
|
||||||
setLimitLines(false)
|
setLimitLines(false)
|
||||||
|
@ -170,24 +214,10 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
return <ErrorMessage message="Invalid or unsupported post record" />
|
return <ErrorMessage message="Invalid or unsupported post record" />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deleted) {
|
if (isHighlightedPost) {
|
||||||
return (
|
|
||||||
<View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'trash-can']}
|
|
||||||
style={pal.icon as FontAwesomeIconStyle}
|
|
||||||
/>
|
|
||||||
<Text style={[pal.textLight, s.ml10]}>
|
|
||||||
<Trans>This post has been deleted.</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item._isHighlightedPost) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.rootUri !== item.uri && (
|
{rootUri !== post.uri && (
|
||||||
<View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
|
<View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
|
||||||
<View style={{width: 38}}>
|
<View style={{width: 38}}>
|
||||||
<View
|
<View
|
||||||
|
@ -204,7 +234,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
testID={`postThreadItem-by-${post.author.handle}`}
|
||||||
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
|
||||||
noFeedback
|
noFeedback
|
||||||
accessible={false}>
|
accessible={false}>
|
||||||
|
@ -213,10 +243,10 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
|
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={52}
|
size={52}
|
||||||
did={item.post.author.did}
|
did={post.author.did}
|
||||||
handle={item.post.author.handle}
|
handle={post.author.handle}
|
||||||
avatar={item.post.author.avatar}
|
avatar={post.author.avatar}
|
||||||
moderation={item.moderation.avatar}
|
moderation={moderation.avatar}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
|
@ -233,17 +263,17 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
lineHeight={1.2}>
|
lineHeight={1.2}>
|
||||||
{sanitizeDisplayName(
|
{sanitizeDisplayName(
|
||||||
item.post.author.displayName ||
|
post.author.displayName ||
|
||||||
sanitizeHandle(item.post.author.handle),
|
sanitizeHandle(post.author.handle),
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
<TimeElapsed timestamp={item.post.indexedAt}>
|
<TimeElapsed timestamp={post.indexedAt}>
|
||||||
{({timeElapsed}) => (
|
{({timeElapsed}) => (
|
||||||
<Text
|
<Text
|
||||||
type="md"
|
type="md"
|
||||||
style={[styles.metaItem, pal.textLight]}
|
style={[styles.metaItem, pal.textLight]}
|
||||||
title={niceDate(item.post.indexedAt)}>
|
title={niceDate(post.indexedAt)}>
|
||||||
· {timeElapsed}
|
· {timeElapsed}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
@ -280,23 +310,15 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
href={authorHref}
|
href={authorHref}
|
||||||
title={authorTitle}>
|
title={authorTitle}>
|
||||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||||
{sanitizeHandle(item.post.author.handle, '@')}
|
{sanitizeHandle(post.author.handle, '@')}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<PostDropdownBtn
|
<PostDropdownBtn
|
||||||
testID="postDropdownBtn"
|
testID="postDropdownBtn"
|
||||||
itemUri={itemUri}
|
post={post}
|
||||||
itemCid={itemCid}
|
record={record}
|
||||||
itemHref={itemHref}
|
|
||||||
itemTitle={itemTitle}
|
|
||||||
isAuthor={item.post.author.did === store.me.did}
|
|
||||||
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
|
|
||||||
onCopyPostText={onCopyPostText}
|
|
||||||
onOpenTranslate={onOpenTranslate}
|
|
||||||
onToggleThreadMute={onToggleThreadMute}
|
|
||||||
onDeletePost={onDeletePost}
|
|
||||||
style={{
|
style={{
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
|
@ -307,16 +329,16 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
</View>
|
</View>
|
||||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||||
<ContentHider
|
<ContentHider
|
||||||
moderation={item.moderation.content}
|
moderation={moderation.content}
|
||||||
ignoreMute
|
ignoreMute
|
||||||
style={styles.contentHider}
|
style={styles.contentHider}
|
||||||
childContainerStyle={styles.contentHiderChild}>
|
childContainerStyle={styles.contentHiderChild}>
|
||||||
<PostAlerts
|
<PostAlerts
|
||||||
moderation={item.moderation.content}
|
moderation={moderation.content}
|
||||||
includeMute
|
includeMute
|
||||||
style={styles.alert}
|
style={styles.alert}
|
||||||
/>
|
/>
|
||||||
{item.richText?.text ? (
|
{richText?.text ? (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.postTextContainer,
|
styles.postTextContainer,
|
||||||
|
@ -324,59 +346,56 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
]}>
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
type="post-text-lg"
|
type="post-text-lg"
|
||||||
richText={item.richText}
|
richText={richText}
|
||||||
lineHeight={1.3}
|
lineHeight={1.3}
|
||||||
style={s.flex1}
|
style={s.flex1}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{item.post.embed && (
|
{post.embed && (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
moderation={item.moderation.embed}
|
moderation={moderation.embed}
|
||||||
ignoreMute={isEmbedByEmbedder(
|
ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
|
||||||
item.post.embed,
|
|
||||||
item.post.author.did,
|
|
||||||
)}
|
|
||||||
style={s.mb10}>
|
style={s.mb10}>
|
||||||
<PostEmbeds
|
<PostEmbeds
|
||||||
embed={item.post.embed}
|
embed={post.embed}
|
||||||
moderation={item.moderation.embed}
|
moderation={moderation.embed}
|
||||||
/>
|
/>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)}
|
)}
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
<ExpandedPostDetails
|
<ExpandedPostDetails
|
||||||
post={item.post}
|
post={post}
|
||||||
translatorUrl={translatorUrl}
|
translatorUrl={translatorUrl}
|
||||||
needsTranslation={needsTranslation}
|
needsTranslation={needsTranslation}
|
||||||
/>
|
/>
|
||||||
{hasEngagement ? (
|
{hasEngagement ? (
|
||||||
<View style={[styles.expandedInfo, pal.border]}>
|
<View style={[styles.expandedInfo, pal.border]}>
|
||||||
{item.post.repostCount ? (
|
{post.repostCount ? (
|
||||||
<Link
|
<Link
|
||||||
style={styles.expandedInfoItem}
|
style={styles.expandedInfoItem}
|
||||||
href={repostsHref}
|
href={repostsHref}
|
||||||
title={repostsTitle}>
|
title={repostsTitle}>
|
||||||
<Text testID="repostCount" type="lg" style={pal.textLight}>
|
<Text testID="repostCount" type="lg" style={pal.textLight}>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<Text type="xl-bold" style={pal.text}>
|
||||||
{formatCount(item.post.repostCount)}
|
{formatCount(post.repostCount)}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
{pluralize(item.post.repostCount, 'repost')}
|
{pluralize(post.repostCount, 'repost')}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
{item.post.likeCount ? (
|
{post.likeCount ? (
|
||||||
<Link
|
<Link
|
||||||
style={styles.expandedInfoItem}
|
style={styles.expandedInfoItem}
|
||||||
href={likesHref}
|
href={likesHref}
|
||||||
title={likesTitle}>
|
title={likesTitle}>
|
||||||
<Text testID="likeCount" type="lg" style={pal.textLight}>
|
<Text testID="likeCount" type="lg" style={pal.textLight}>
|
||||||
<Text type="xl-bold" style={pal.text}>
|
<Text type="xl-bold" style={pal.text}>
|
||||||
{formatCount(item.post.likeCount)}
|
{formatCount(post.likeCount)}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
{pluralize(item.post.likeCount, 'like')}
|
{pluralize(post.likeCount, 'like')}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
@ -389,24 +408,9 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
<View style={[s.pl10, s.pb5]}>
|
<View style={[s.pl10, s.pb5]}>
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
big
|
big
|
||||||
itemUri={itemUri}
|
post={post}
|
||||||
itemCid={itemCid}
|
record={record}
|
||||||
itemHref={itemHref}
|
|
||||||
itemTitle={itemTitle}
|
|
||||||
author={item.post.author}
|
|
||||||
text={item.richText?.text || record.text}
|
|
||||||
indexedAt={item.post.indexedAt}
|
|
||||||
isAuthor={item.post.author.did === store.me.did}
|
|
||||||
isReposted={!!item.post.viewer?.repost}
|
|
||||||
isLiked={!!item.post.viewer?.like}
|
|
||||||
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
|
|
||||||
onPressReply={onPressReply}
|
onPressReply={onPressReply}
|
||||||
onPressToggleRepost={onPressToggleRepost}
|
|
||||||
onPressToggleLike={onPressToggleLike}
|
|
||||||
onCopyPostText={onCopyPostText}
|
|
||||||
onOpenTranslate={onOpenTranslate}
|
|
||||||
onToggleThreadMute={onToggleThreadMute}
|
|
||||||
onDeletePost={onDeletePost}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
@ -414,17 +418,19 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const isThreadedChild = treeView && item._depth > 1
|
const isThreadedChild = treeView && depth > 1
|
||||||
return (
|
return (
|
||||||
<PostOuterWrapper
|
<PostOuterWrapper
|
||||||
item={item}
|
post={post}
|
||||||
hasPrecedingItem={hasPrecedingItem}
|
depth={depth}
|
||||||
treeView={treeView}>
|
showParentReplyLine={!!showParentReplyLine}
|
||||||
|
treeView={treeView}
|
||||||
|
hasPrecedingItem={hasPrecedingItem}>
|
||||||
<PostHider
|
<PostHider
|
||||||
testID={`postThreadItem-by-${item.post.author.handle}`}
|
testID={`postThreadItem-by-${post.author.handle}`}
|
||||||
href={itemHref}
|
href={postHref}
|
||||||
style={[pal.view]}
|
style={[pal.view]}
|
||||||
moderation={item.moderation.content}>
|
moderation={moderation.content}>
|
||||||
<PostSandboxWarning />
|
<PostSandboxWarning />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
@ -435,7 +441,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
height: isThreadedChild ? 8 : 16,
|
height: isThreadedChild ? 8 : 16,
|
||||||
}}>
|
}}>
|
||||||
<View style={{width: 38}}>
|
<View style={{width: 38}}>
|
||||||
{!isThreadedChild && item._showParentReplyLine && (
|
{!isThreadedChild && showParentReplyLine && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
|
@ -454,21 +460,20 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
style={[
|
style={[
|
||||||
styles.layout,
|
styles.layout,
|
||||||
{
|
{
|
||||||
paddingBottom:
|
paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8,
|
||||||
item._showChildReplyLine && !isThreadedChild ? 0 : 8,
|
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
{!isThreadedChild && (
|
{!isThreadedChild && (
|
||||||
<View style={styles.layoutAvi}>
|
<View style={styles.layoutAvi}>
|
||||||
<PreviewableUserAvatar
|
<PreviewableUserAvatar
|
||||||
size={38}
|
size={38}
|
||||||
did={item.post.author.did}
|
did={post.author.did}
|
||||||
handle={item.post.author.handle}
|
handle={post.author.handle}
|
||||||
avatar={item.post.author.avatar}
|
avatar={post.author.avatar}
|
||||||
moderation={item.moderation.avatar}
|
moderation={moderation.avatar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{item._showChildReplyLine && (
|
{showChildReplyLine && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.replyLine,
|
styles.replyLine,
|
||||||
|
@ -485,10 +490,10 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
|
|
||||||
<View style={styles.layoutContent}>
|
<View style={styles.layoutContent}>
|
||||||
<PostMeta
|
<PostMeta
|
||||||
author={item.post.author}
|
author={post.author}
|
||||||
authorHasWarning={!!item.post.author.labels?.length}
|
authorHasWarning={!!post.author.labels?.length}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={post.indexedAt}
|
||||||
postHref={itemHref}
|
postHref={postHref}
|
||||||
showAvatar={isThreadedChild}
|
showAvatar={isThreadedChild}
|
||||||
avatarSize={26}
|
avatarSize={26}
|
||||||
displayNameType="md-bold"
|
displayNameType="md-bold"
|
||||||
|
@ -496,14 +501,14 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
style={isThreadedChild && s.mb5}
|
style={isThreadedChild && s.mb5}
|
||||||
/>
|
/>
|
||||||
<PostAlerts
|
<PostAlerts
|
||||||
moderation={item.moderation.content}
|
moderation={moderation.content}
|
||||||
style={styles.alert}
|
style={styles.alert}
|
||||||
/>
|
/>
|
||||||
{item.richText?.text ? (
|
{richText?.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
type="post-text"
|
type="post-text"
|
||||||
richText={item.richText}
|
richText={richText}
|
||||||
style={[pal.text, s.flex1]}
|
style={[pal.text, s.flex1]}
|
||||||
lineHeight={1.3}
|
lineHeight={1.3}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
|
@ -518,42 +523,24 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
href="#"
|
href="#"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{item.post.embed && (
|
{post.embed && (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
style={styles.contentHider}
|
style={styles.contentHider}
|
||||||
moderation={item.moderation.embed}>
|
moderation={moderation.embed}>
|
||||||
<PostEmbeds
|
<PostEmbeds
|
||||||
embed={item.post.embed}
|
embed={post.embed}
|
||||||
moderation={item.moderation.embed}
|
moderation={moderation.embed}
|
||||||
/>
|
/>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)}
|
)}
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
itemUri={itemUri}
|
post={post}
|
||||||
itemCid={itemCid}
|
record={record}
|
||||||
itemHref={itemHref}
|
|
||||||
itemTitle={itemTitle}
|
|
||||||
author={item.post.author}
|
|
||||||
text={item.richText?.text || record.text}
|
|
||||||
indexedAt={item.post.indexedAt}
|
|
||||||
isAuthor={item.post.author.did === store.me.did}
|
|
||||||
replyCount={item.post.replyCount}
|
|
||||||
repostCount={item.post.repostCount}
|
|
||||||
likeCount={item.post.likeCount}
|
|
||||||
isReposted={!!item.post.viewer?.repost}
|
|
||||||
isLiked={!!item.post.viewer?.like}
|
|
||||||
isThreadMuted={mutedThreads.includes(item.data.rootUri)}
|
|
||||||
onPressReply={onPressReply}
|
onPressReply={onPressReply}
|
||||||
onPressToggleRepost={onPressToggleRepost}
|
|
||||||
onPressToggleLike={onPressToggleLike}
|
|
||||||
onCopyPostText={onCopyPostText}
|
|
||||||
onOpenTranslate={onOpenTranslate}
|
|
||||||
onToggleThreadMute={onToggleThreadMute}
|
|
||||||
onDeletePost={onDeletePost}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{item._hasMore ? (
|
{hasMore ? (
|
||||||
<Link
|
<Link
|
||||||
style={[
|
style={[
|
||||||
styles.loadMore,
|
styles.loadMore,
|
||||||
|
@ -563,7 +550,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
paddingBottom: treeView ? 4 : 12,
|
paddingBottom: treeView ? 4 : 12,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
href={itemHref}
|
href={postHref}
|
||||||
title={itemTitle}
|
title={itemTitle}
|
||||||
noFeedback>
|
noFeedback>
|
||||||
<Text type="sm-medium" style={pal.textLight}>
|
<Text type="sm-medium" style={pal.textLight}>
|
||||||
|
@ -580,22 +567,26 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
</PostOuterWrapper>
|
</PostOuterWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
function PostOuterWrapper({
|
function PostOuterWrapper({
|
||||||
item,
|
post,
|
||||||
hasPrecedingItem,
|
|
||||||
treeView,
|
treeView,
|
||||||
|
depth,
|
||||||
|
showParentReplyLine,
|
||||||
|
hasPrecedingItem,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
item: PostThreadItemModel
|
post: AppBskyFeedDefs.PostView
|
||||||
hasPrecedingItem: boolean
|
|
||||||
treeView: boolean
|
treeView: boolean
|
||||||
|
depth: number
|
||||||
|
showParentReplyLine: boolean
|
||||||
|
hasPrecedingItem: boolean
|
||||||
}>) {
|
}>) {
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
if (treeView && item._depth > 1) {
|
if (treeView && depth > 1) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -605,13 +596,13 @@ function PostOuterWrapper({
|
||||||
{
|
{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingLeft: 20,
|
paddingLeft: 20,
|
||||||
borderTopWidth: item._depth === 1 ? 1 : 0,
|
borderTopWidth: depth === 1 ? 1 : 0,
|
||||||
paddingTop: item._depth === 1 ? 8 : 0,
|
paddingTop: depth === 1 ? 8 : 0,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
{Array.from(Array(item._depth - 1)).map((_, n: number) => (
|
{Array.from(Array(depth - 1)).map((_, n: number) => (
|
||||||
<View
|
<View
|
||||||
key={`${item.uri}-padding-${n}`}
|
key={`${post.uri}-padding-${n}`}
|
||||||
style={{
|
style={{
|
||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: pal.colors.border,
|
borderLeftColor: pal.colors.border,
|
||||||
|
@ -630,7 +621,7 @@ function PostOuterWrapper({
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.view,
|
pal.view,
|
||||||
pal.border,
|
pal.border,
|
||||||
item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
|
showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
|
||||||
styles.cursor,
|
styles.cursor,
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -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 React from 'react'
|
||||||
import {InteractionManager, StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import Animated from 'react-native-reanimated'
|
import Animated from 'react-native-reanimated'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
@ -9,79 +10,83 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
|
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
|
||||||
import {ComposePrompt} from 'view/com/composer/Prompt'
|
import {ComposePrompt} from 'view/com/composer/Prompt'
|
||||||
import {PostThreadModel} from 'state/models/content/post-thread'
|
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
|
import {
|
||||||
|
RQKEY as POST_THREAD_RQKEY,
|
||||||
|
ThreadNode,
|
||||||
|
} from '#/state/queries/post-thread'
|
||||||
import {clamp} from 'lodash'
|
import {clamp} from 'lodash'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {ErrorMessage} from '../com/util/error/ErrorMessage'
|
||||||
|
import {CenteredView} from '../com/util/Views'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
||||||
export const PostThreadScreen = withAuthRequired(
|
export const PostThreadScreen = withAuthRequired(
|
||||||
observer(function PostThreadScreenImpl({route}: Props) {
|
observer(function PostThreadScreenImpl({route}: Props) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const {fabMinimalShellTransform} = useMinimalShellMode()
|
const {fabMinimalShellTransform} = useMinimalShellMode()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
const {name, rkey} = route.params
|
const {name, rkey} = route.params
|
||||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
|
||||||
const view = useMemo<PostThreadModel>(
|
|
||||||
() => new PostThreadModel(store, {uri}),
|
|
||||||
[store, uri],
|
|
||||||
)
|
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||||
|
const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
const threadCleanup = view.registerListeners()
|
}, [setMinimalShellMode]),
|
||||||
|
|
||||||
InteractionManager.runAfterInteractions(() => {
|
|
||||||
if (!view.hasLoaded && !view.isLoading) {
|
|
||||||
view.setup().catch(err => {
|
|
||||||
logger.error('Failed to fetch thread', {error: err})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
threadCleanup()
|
|
||||||
}
|
|
||||||
}, [view, setMinimalShellMode]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = React.useCallback(() => {
|
||||||
if (!view.thread) {
|
if (!resolvedUri) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const thread = queryClient.getQueryData<ThreadNode>(
|
||||||
|
POST_THREAD_RQKEY(resolvedUri),
|
||||||
|
)
|
||||||
|
if (thread?.type !== 'post') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.shell.openComposer({
|
store.shell.openComposer({
|
||||||
replyTo: {
|
replyTo: {
|
||||||
uri: view.thread.post.uri,
|
uri: thread.post.uri,
|
||||||
cid: view.thread.post.cid,
|
cid: thread.post.cid,
|
||||||
text: view.thread.postRecord?.text as string,
|
text: thread.record.text,
|
||||||
author: {
|
author: {
|
||||||
handle: view.thread.post.author.handle,
|
handle: thread.post.author.handle,
|
||||||
displayName: view.thread.post.author.displayName,
|
displayName: thread.post.author.displayName,
|
||||||
avatar: view.thread.post.author.avatar,
|
avatar: thread.post.author.avatar,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onPost: () => view.refresh(),
|
onPost: () =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: POST_THREAD_RQKEY(resolvedUri || ''),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}, [view, store])
|
}, [store, queryClient, resolvedUri])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.hContentRegion}>
|
<View style={s.hContentRegion}>
|
||||||
{isMobile && <ViewHeader title="Post" />}
|
{isMobile && <ViewHeader title="Post" />}
|
||||||
<View style={s.flex1}>
|
<View style={s.flex1}>
|
||||||
<PostThreadComponent
|
{uriError ? (
|
||||||
uri={uri}
|
<CenteredView>
|
||||||
view={view}
|
<ErrorMessage message={String(uriError)} />
|
||||||
onPressReply={onPressReply}
|
</CenteredView>
|
||||||
treeView={!!store.preferences.thread.lab_treeViewEnabled}
|
) : (
|
||||||
/>
|
<PostThreadComponent
|
||||||
|
uri={resolvedUri}
|
||||||
|
onPressReply={onPressReply}
|
||||||
|
treeView={!!store.preferences.thread.lab_treeViewEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|
Loading…
Reference in New Issue