diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-09 15:35:25 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-09 15:35:25 -0800 |
commit | fb4f5709c43c070653c917e3196b9b1c120418a6 (patch) | |
tree | 74e6ff954441b6da3044853e16ebf5dd12213c87 | |
parent | 625cbc435f15bc0d611661b44dbf8add990dff7d (diff) | |
download | voidsky-fb4f5709c43c070653c917e3196b9b1c120418a6.tar.zst |
Refactor post threads to use react query (#1851)
* Add post and post-thread queries * Update PostThread components to use new queries * Move from normalized cache to shadow cache model * Merge post shadow into the post automatically * Remove dead code * Remove old temporary session * Fix: set agent on session creation * Temporarily double-login * Handle post-thread uri resolution errors
-rw-r--r-- | src/App.web.tsx | 2 | ||||
-rw-r--r-- | src/state/cache/post-shadow.ts | 90 | ||||
-rw-r--r-- | src/state/queries/post-thread.ts | 177 | ||||
-rw-r--r-- | src/state/queries/post.ts | 156 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 17 | ||||
-rw-r--r-- | src/state/session/index.tsx | 2 | ||||
-rw-r--r-- | src/view/com/auth/login/LoginForm.tsx | 8 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 470 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 447 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn2.tsx | 210 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls2.tsx | 200 | ||||
-rw-r--r-- | src/view/screens/PostThread.tsx | 81 |
12 files changed, 1385 insertions, 475 deletions
diff --git a/src/App.web.tsx b/src/App.web.tsx index fc76afce1..81e03d079 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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 diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts new file mode 100644 index 000000000..c06ed60c4 --- /dev/null +++ b/src/state/cache/post-shadow.ts @@ -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, + }, + } +} diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts new file mode 100644 index 000000000..4dea8aaf1 --- /dev/null +++ b/src/state/queries/post-thread.ts @@ -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: ''} + } +} diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts new file mode 100644 index 000000000..f62190c67 --- /dev/null +++ b/src/state/queries/post.ts @@ -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}) + }, + }, + ) +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts new file mode 100644 index 000000000..770be5cf8 --- /dev/null +++ b/src/state/queries/resolve-uri.ts @@ -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() + }) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 0f3118168..8e1f9c1a1 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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], diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx index 9779b939a..166a7cbd8 100644 --- a/src/view/com/auth/login/LoginForm.tsx +++ b/src/view/com/auth/login/LoginForm.tsx @@ -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, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index f868c3dca..1e85b3e31 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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} + /> + ) +} + +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) - const onRefresh = React.useCallback(async () => { - setIsRefreshing(true) - try { - view?.refresh() - } catch (err) { - logger.error('Failed to refresh posts thread', {error: err}) + // 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 isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} -function* flattenThread( - post: PostThreadItemModel, - isAscending = false, +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 } } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 49b769e13..a8e0c0f93 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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 + 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} diff --git a/src/view/com/util/forms/PostDropdownBtn2.tsx b/src/view/com/util/forms/PostDropdownBtn2.tsx new file mode 100644 index 000000000..c457e0a46 --- /dev/null +++ b/src/view/com/util/forms/PostDropdownBtn2.tsx @@ -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> + ) +} diff --git a/src/view/com/util/post-ctrls/PostCtrls2.tsx b/src/view/com/util/post-ctrls/PostCtrls2.tsx new file mode 100644 index 000000000..7c8ebaee7 --- /dev/null +++ b/src/view/com/util/post-ctrls/PostCtrls2.tsx @@ -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, + }, +}) diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 0abce45fa..b254c1eca 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -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 |