From fb4f5709c43c070653c917e3196b9b1c120418a6 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 9 Nov 2023 15:35:25 -0800 Subject: [PATCH] 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 --- src/App.web.tsx | 2 +- src/state/cache/post-shadow.ts | 90 ++++ src/state/queries/post-thread.ts | 177 +++++++ src/state/queries/post.ts | 156 ++++++ src/state/queries/resolve-uri.ts | 17 + src/state/session/index.tsx | 2 + src/view/com/auth/login/LoginForm.tsx | 8 + src/view/com/post-thread/PostThread.tsx | 470 +++++++++++-------- src/view/com/post-thread/PostThreadItem.tsx | 449 +++++++++--------- src/view/com/util/forms/PostDropdownBtn2.tsx | 210 +++++++++ src/view/com/util/post-ctrls/PostCtrls2.tsx | 200 ++++++++ src/view/screens/PostThread.tsx | 81 ++-- 12 files changed, 1386 insertions(+), 476 deletions(-) create mode 100644 src/state/cache/post-shadow.ts create mode 100644 src/state/queries/post-thread.ts create mode 100644 src/state/queries/post.ts create mode 100644 src/state/queries/resolve-uri.ts create mode 100644 src/view/com/util/forms/PostDropdownBtn2.tsx create mode 100644 src/view/com/util/post-ctrls/PostCtrls2.tsx diff --git a/src/App.web.tsx b/src/App.web.tsx index fc76afce..81e03d07 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 00000000..c06ed60c --- /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({ + ts: Date.now(), + value: fromPost(post), + }) + const firstRun = useRef(true) + + const onUpdate = useCallback( + (value: Partial) => { + 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) { + 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 00000000..4dea8aaf --- /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( + 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 00000000..f62190c6 --- /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( + 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( + 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 00000000..770be5cf --- /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(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 0f311816..8e1f9c1a 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 9779b939..166a7cbd 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(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 f868c3dc..1e85b3e3 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(null) - const hasScrolledIntoView = useRef(false) - const [isRefreshing, setIsRefreshing] = React.useState(false) - const [maxVisible, setMaxVisible] = React.useState(100) - const navigation = useNavigation() - 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 ( + + ) + } + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return + } + if (!thread || isLoading) { + return ( + + + + + + ) + } + return ( + + ) +} - const onRefresh = React.useCallback(async () => { - setIsRefreshing(true) - try { - view?.refresh() - } catch (err) { - logger.error('Failed to refresh posts thread', {error: err}) +function PostThreadLoaded({ + thread, + isRefetching, + dataUpdatedAt, + treeView, + onRefresh, + onPressReply, +}: { + thread: ThreadNode + isRefetching: boolean + dataUpdatedAt: number + treeView: boolean + onRefresh: () => void + onPressReply: () => void +}) { + const {_} = useLingui() + const pal = usePalette('default') + const store = useStores() + const {isTablet, isDesktop} = useWebMediaQueries() + const ref = useRef(null) + // const hasScrolledIntoView = useRef(false) TODO + const [maxVisible, setMaxVisible] = React.useState(100) + + // TODO + // const posts = React.useMemo(() => { + // if (view.thread) { + // let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) + // if (arr.length > maxVisible) { + // arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) + // } + // if (view.isLoadingFromCache) { + // if (view.thread?.postRecord?.reply) { + // arr.unshift(PARENT_SPINNER) + // } + // arr.push(CHILD_SPINNER) + // } else { + // arr.push(BOTTOM_COMPONENT) + // } + // return arr + // } + // return [] + // }, [view.isLoadingFromCache, view.thread, maxVisible]) + // const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) + const posts = React.useMemo(() => { + let arr = [TOP_COMPONENT].concat( + Array.from( + flattenThreadSkeleton(sortThread(thread, store.preferences.thread)), + ), + ) + if (arr.length > maxVisible) { + arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } - setIsRefreshing(false) - }, [view, setIsRefreshing]) + arr.push(BOTTOM_COMPONENT) + return arr + }, [thread, maxVisible, store.preferences.thread]) - const onContentSizeChange = React.useCallback(() => { + // TODO + /*const onContentSizeChange = React.useCallback(() => { // only run once if (hasScrolledIntoView.current) { return @@ -157,7 +203,7 @@ export const PostThread = observer(function PostThread({ view.isFromCache, view.isLoadingFromCache, view.isLoading, - ]) + ])*/ const onScrollToIndexFailed = React.useCallback( (info: { index: number @@ -172,14 +218,6 @@ export const PostThread = observer(function PostThread({ [ref], ) - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { if (item === TOP_COMPONENT) { @@ -250,20 +288,27 @@ export const PostThread = observer(function PostThread({ ) - } 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 ( ) } - 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 ( - - - - - - ) - } + return ( + item._reactKey} + renderItem={renderItem} + refreshControl={ + + } + 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 ( - - - - Post not found - - - The post may have been deleted. - - - - - Back - - - - - ) +function PostThreadBlocked() { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') } - return ( - - - - ) - } - if (view.isBlocked) { + }, [navigation]) + + return ( + + + + Post hidden + + + + You have blocked the author or you have been blocked by the author. + + + + + + Back + + + + + ) +} + +function PostThreadError({ + onRefresh, + notFound, + error, +}: { + onRefresh: () => void + notFound: boolean + error: Error | null +}) { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (notFound) { return ( - Post hidden + Post not found - - You have blocked the author or you have been blocked by the - author. - + The post may have been deleted. ) } - - // loaded - // = return ( - item._reactKey} - renderItem={renderItem} - refreshControl={ - - } - onContentSizeChange={ - isNative && view.isFromCache ? undefined : onContentSizeChange - } - onScrollToIndexFailed={onScrollToIndexFailed} - style={s.hContentRegion} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + + + ) -}) +} -function* flattenThread( - post: PostThreadItemModel, - isAscending = false, +function isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} + +function* flattenThreadSkeleton( + node: ThreadNode, ): Generator { - 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 49b769e1..a8e0c0f9 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 - hasPrecedingItem: boolean + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + dataUpdatedAt: number treeView: boolean + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + hasPrecedingItem: boolean + onPostReply: () => void +}) { + const store = useStores() + const postShadowed = usePostShadow(post, dataUpdatedAt) + const richText = useMemo( + () => + post && + AppBskyFeedPost.isRecord(post?.record) && + AppBskyFeedPost.validateRecord(post?.record).success + ? new RichTextAPI({ + text: post.record.text, + facets: post.record.facets, + }) + : undefined, + [post], + ) + const moderation = useMemo( + () => + post ? moderatePost(post, store.preferences.moderationOpts) : undefined, + [post, store], + ) + if (postShadowed === POST_TOMBSTONE) { + return + } + if (richText && moderation) { + return ( + + ) + } + return null +} + +function PostThreadItemDeleted() { + const styles = useStyles() + const pal = usePalette('default') + return ( + + + + This post has been deleted. + + + ) +} + +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 } - if (deleted) { - return ( - - - - This post has been deleted. - - - ) - } - - if (item._isHighlightedPost) { + if (isHighlightedPost) { return ( <> - {item.rootUri !== item.uri && ( + {rootUri !== post.uri && ( @@ -213,10 +243,10 @@ export const PostThreadItem = observer(function PostThreadItem({ @@ -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), )} - + {({timeElapsed}) => ( + title={niceDate(post.indexedAt)}> · {timeElapsed} )} @@ -280,23 +310,15 @@ export const PostThreadItem = observer(function PostThreadItem({ href={authorHref} title={authorTitle}> - {sanitizeHandle(item.post.author.handle, '@')} + {sanitizeHandle(post.author.handle, '@')} - {item.richText?.text ? ( + {richText?.text ? ( ) : undefined} - {item.post.embed && ( + {post.embed && ( )} {hasEngagement ? ( - {item.post.repostCount ? ( + {post.repostCount ? ( - {formatCount(item.post.repostCount)} + {formatCount(post.repostCount)} {' '} - {pluralize(item.post.repostCount, 'repost')} + {pluralize(post.repostCount, 'repost')} ) : ( <> )} - {item.post.likeCount ? ( + {post.likeCount ? ( - {formatCount(item.post.likeCount)} + {formatCount(post.likeCount)} {' '} - {pluralize(item.post.likeCount, 'like')} + {pluralize(post.likeCount, 'like')} ) : ( @@ -389,24 +408,9 @@ export const PostThreadItem = observer(function PostThreadItem({ @@ -414,17 +418,19 @@ export const PostThreadItem = observer(function PostThreadItem({ ) } else { - const isThreadedChild = treeView && item._depth > 1 + const isThreadedChild = treeView && depth > 1 return ( + post={post} + depth={depth} + showParentReplyLine={!!showParentReplyLine} + treeView={treeView} + hasPrecedingItem={hasPrecedingItem}> + moderation={moderation.content}> - {!isThreadedChild && item._showParentReplyLine && ( + {!isThreadedChild && showParentReplyLine && ( {!isThreadedChild && ( - {item._showChildReplyLine && ( + {showChildReplyLine && ( - {item.richText?.text ? ( + {richText?.text ? ( ) : undefined} - {item.post.embed && ( + {post.embed && ( + moderation={moderation.embed}> )} - {item._hasMore ? ( + {hasMore ? ( @@ -580,22 +567,26 @@ export const PostThreadItem = observer(function PostThreadItem({ ) } -}) +} 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 ( - {Array.from(Array(item._depth - 1)).map((_, n: number) => ( + {Array.from(Array(depth - 1)).map((_, n: number) => ( {children} diff --git a/src/view/com/util/forms/PostDropdownBtn2.tsx b/src/view/com/util/forms/PostDropdownBtn2.tsx new file mode 100644 index 00000000..c457e0a4 --- /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 +}) { + 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 ( + + + + + + + + ) +} 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 00000000..7c8ebaee --- /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 + 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 + + 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 ( + + + + {typeof post.replyCount !== 'undefined' ? ( + + {post.replyCount} + + ) : undefined} + + + + {post.viewer?.like ? ( + + ) : ( + + )} + {typeof post.likeCount !== 'undefined' ? ( + + {post.likeCount} + + ) : undefined} + + {big ? undefined : ( + + )} + {/* used for adding pad to the right side */} + + + ) +} + +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 0abce45f..b254c1ec 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 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( - () => 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( + 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 ( {isMobile && } - + {uriError ? ( + + + + ) : ( + + )} {isMobile && (