Re-org
This commit is contained in:
@@ -1,774 +0,0 @@
|
|||||||
import {useCallback, useMemo, useState} from 'react'
|
|
||||||
import {
|
|
||||||
$Typed,
|
|
||||||
AtUri,
|
|
||||||
AppBskyFeedDefs,
|
|
||||||
AppBskyUnspeccedDefs,
|
|
||||||
AppBskyFeedThreadgate,
|
|
||||||
AppBskyUnspeccedGetPostThreadV2,
|
|
||||||
ModerationOpts,
|
|
||||||
BskyThreadViewPreference,
|
|
||||||
moderatePost,
|
|
||||||
ModerationDecision,
|
|
||||||
AppBskyEmbedRecord,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
APP_BSKY_UNSPECCED,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {useAgent, useSession} from '#/state/session'
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
||||||
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
|
||||||
import * as bsky from '#/types/bsky'
|
|
||||||
import {
|
|
||||||
didOrHandleUriMatches,
|
|
||||||
embedViewRecordToPostView,
|
|
||||||
getEmbeddedPost,
|
|
||||||
} from './util'
|
|
||||||
import {
|
|
||||||
findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
|
|
||||||
findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData,
|
|
||||||
} from '#/state/queries/explore-feed-previews'
|
|
||||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
|
||||||
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
|
||||||
import {
|
|
||||||
findAllPostsInQueryData as findAllPostsInSearchQueryData,
|
|
||||||
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
|
|
||||||
} from '#/state/queries/search-posts'
|
|
||||||
import {
|
|
||||||
findAllPostsInQueryData as findAllPostsInNotifsQueryData,
|
|
||||||
findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
|
|
||||||
} from './notifications/feed'
|
|
||||||
import {
|
|
||||||
findAllPostsInQueryData as findAllPostsInFeedQueryData,
|
|
||||||
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
|
|
||||||
} from './post-feed'
|
|
||||||
|
|
||||||
export type PostThreadV2Params = {
|
|
||||||
view: 'tree' | 'linear'
|
|
||||||
sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string
|
|
||||||
prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers']
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPostThreadV2QueryKeyRoot = 'getPostThreadV2' as const
|
|
||||||
export const createGetPostThreadV2QueryKey = (
|
|
||||||
props: Pick<GetPostThreadV2QueryProps, 'uri' | 'params'>,
|
|
||||||
) => [getPostThreadV2QueryKeyRoot, props] as const
|
|
||||||
|
|
||||||
export type GetPostThreadV2QueryProps = {
|
|
||||||
uri?: string
|
|
||||||
enabled?: boolean
|
|
||||||
params: PostThreadV2Params
|
|
||||||
state: {
|
|
||||||
shownHiddenReplyKinds: Set<HiddenReplyKind>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GetPostThreadV2QueryData = {
|
|
||||||
slices: Slice[]
|
|
||||||
threadgate?: AppBskyFeedDefs.ThreadgateView
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapSortOptionsToSortID(sort: PostThreadV2Params['sort']) {
|
|
||||||
switch (sort) {
|
|
||||||
case 'hotness':
|
|
||||||
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
|
|
||||||
case 'oldest':
|
|
||||||
return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest
|
|
||||||
case 'newest':
|
|
||||||
return APP_BSKY_UNSPECCED.GetPostThreadV2Newest
|
|
||||||
case 'most-likes':
|
|
||||||
return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes
|
|
||||||
default:
|
|
||||||
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGetPostThreadV2({
|
|
||||||
uri,
|
|
||||||
enabled: isEnabled,
|
|
||||||
params,
|
|
||||||
state,
|
|
||||||
}: GetPostThreadV2QueryProps) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const agent = useAgent()
|
|
||||||
const {hasSession} = useSession()
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
|
|
||||||
|
|
||||||
const enabled = isEnabled !== false && !!uri && !!moderationOpts
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
enabled,
|
|
||||||
queryKey: createGetPostThreadV2QueryKey({
|
|
||||||
uri,
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
async queryFn() {
|
|
||||||
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
|
|
||||||
uri: uri!,
|
|
||||||
branchingFactor: params.view === 'linear' ? 1 : 10,
|
|
||||||
below: 10,
|
|
||||||
sorting: mapSortOptionsToSortID(params.sort),
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
placeholderData() {
|
|
||||||
if (!uri) return
|
|
||||||
const placeholder = getSlicePlaceholder(qc, uri)
|
|
||||||
if (placeholder) {
|
|
||||||
return {thread: [placeholder]}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
},
|
|
||||||
select(data) {
|
|
||||||
const threadgate = getThreadgate(data.threadgate)
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
threadgate: {
|
|
||||||
...data.threadgate,
|
|
||||||
record: threadgate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO map over pages, just like feeds
|
|
||||||
//
|
|
||||||
// - sort up just-posted
|
|
||||||
// - sort down hidden
|
|
||||||
// - sort down muted
|
|
||||||
// - sort down blurred?
|
|
||||||
|
|
||||||
const {items, insertReplies} = useThread(query.data?.thread || [], {
|
|
||||||
hasSession,
|
|
||||||
params,
|
|
||||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
|
|
||||||
query.data?.threadgate?.record,
|
|
||||||
),
|
|
||||||
moderationOpts: moderationOpts!,
|
|
||||||
shownHiddenReplyKinds: state.shownHiddenReplyKinds,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...query,
|
|
||||||
data: {
|
|
||||||
slices: items,
|
|
||||||
threadgate: query.data?.threadgate,
|
|
||||||
},
|
|
||||||
insertReplies,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const views = {
|
|
||||||
noUnauthenticated({
|
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
|
||||||
}): Extract<Slice, {type: 'threadSliceNoUnauthenticated'}> {
|
|
||||||
return {
|
|
||||||
type: 'threadSliceNoUnauthenticated',
|
|
||||||
key: item.uri,
|
|
||||||
slice: item,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
notFound({
|
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: AppBskyUnspeccedDefs.ThreadItemNotFound
|
|
||||||
}): Extract<Slice, {type: 'threadSliceNotFound'}> {
|
|
||||||
return {
|
|
||||||
type: 'threadSliceNotFound',
|
|
||||||
key: item.uri,
|
|
||||||
slice: item,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
blocked({
|
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: AppBskyUnspeccedDefs.ThreadItemBlocked
|
|
||||||
}): Extract<Slice, {type: 'threadSliceBlocked'}> {
|
|
||||||
return {
|
|
||||||
type: 'threadSliceBlocked',
|
|
||||||
key: item.uri,
|
|
||||||
slice: item,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
post({
|
|
||||||
item,
|
|
||||||
oneUp,
|
|
||||||
oneDown,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
item: AppBskyUnspeccedDefs.ThreadItemPost
|
|
||||||
oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
|
|
||||||
oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
|
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
}): Extract<Slice, {type: 'threadSlice'}> {
|
|
||||||
return {
|
|
||||||
type: 'threadSlice',
|
|
||||||
key: item.uri,
|
|
||||||
slice: {
|
|
||||||
...item,
|
|
||||||
post: {
|
|
||||||
...item.post,
|
|
||||||
record: item.post.record as AppBskyFeedPost.Record,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
moderation: moderatePost(item.post, moderationOpts),
|
|
||||||
ui: {
|
|
||||||
isAnchor: item.depth === 0,
|
|
||||||
showParentReplyLine:
|
|
||||||
!!oneUp && 'depth' in oneUp && oneUp.depth < item.depth,
|
|
||||||
showChildReplyLine:
|
|
||||||
!!oneDown && 'depth' in oneDown && oneDown.depth > item.depth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the start and end index of a "branch" of the thread. A "branch" is a
|
|
||||||
* parent and it's children (not siblings). Returned indices are inclusive of
|
|
||||||
* the parent and its last child.
|
|
||||||
*
|
|
||||||
* items[] (index, depth)
|
|
||||||
* ├── branch ───── (0, 1)
|
|
||||||
* ├─┬ branch ───── (1, 1) (start)
|
|
||||||
* │ ├──┬ leaf ──── (2, 2)
|
|
||||||
* │ │ └── leaf ── (3, 3)
|
|
||||||
* │ └── leaf ───── (4, 2) (end)
|
|
||||||
* ├── branch ───── (5, 1)
|
|
||||||
* ├── branch ───── (6, 1)
|
|
||||||
*
|
|
||||||
* const { start: 1, end: 3 } = getBranch(items, 1, 1)
|
|
||||||
*/
|
|
||||||
function getBranch(
|
|
||||||
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
|
|
||||||
branchStartIndex: number,
|
|
||||||
branchStartDepth: number,
|
|
||||||
) {
|
|
||||||
let end = branchStartIndex
|
|
||||||
|
|
||||||
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
|
|
||||||
const next = thread[ci]
|
|
||||||
// ignore unknowns
|
|
||||||
if (!('depth' in next)) continue
|
|
||||||
if (next.depth > branchStartDepth) {
|
|
||||||
end = ci
|
|
||||||
} else {
|
|
||||||
end = ci - 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
start: branchStartIndex,
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useThread(
|
|
||||||
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
|
|
||||||
{
|
|
||||||
hasSession,
|
|
||||||
params,
|
|
||||||
threadgateHiddenReplies,
|
|
||||||
moderationOpts,
|
|
||||||
shownHiddenReplyKinds,
|
|
||||||
}: {
|
|
||||||
hasSession: boolean
|
|
||||||
params: PostThreadV2Params
|
|
||||||
threadgateHiddenReplies: Set<string>
|
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
shownHiddenReplyKinds: Set<HiddenReplyKind>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const isTreeView = params.view === 'tree'
|
|
||||||
const [shadowSlices, setShadowSlices] = useState<
|
|
||||||
Record<string, AppBskyUnspeccedDefs.ThreadItemPost[][]>
|
|
||||||
>({})
|
|
||||||
const insertReplies = useCallback((belowUri: string, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
|
|
||||||
setShadowSlices(p => {
|
|
||||||
const prev = {...p}
|
|
||||||
if (belowUri in prev) {
|
|
||||||
prev[belowUri] = [posts, ...(prev[belowUri] || {})]
|
|
||||||
} else {
|
|
||||||
prev[belowUri] = [posts]
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}, [setShadowSlices])
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
return sortThread(thread, {
|
|
||||||
threadgateHiddenReplies,
|
|
||||||
moderationOpts,
|
|
||||||
})
|
|
||||||
}, [thread, threadgateHiddenReplies, moderationOpts])
|
|
||||||
|
|
||||||
const flattened = useMemo(() => {
|
|
||||||
const showMuted = shownHiddenReplyKinds.has(HiddenReplyKind.Muted)
|
|
||||||
const showHidden = shownHiddenReplyKinds.has(HiddenReplyKind.Hidden)
|
|
||||||
|
|
||||||
if (sorted.hidden.length) {
|
|
||||||
if (showHidden) {
|
|
||||||
sorted.slices.push(...sorted.hidden)
|
|
||||||
|
|
||||||
if (sorted.muted.length) {
|
|
||||||
if (showMuted) {
|
|
||||||
sorted.slices.push(...sorted.muted)
|
|
||||||
} else {
|
|
||||||
sorted.slices.push({
|
|
||||||
type: 'showHiddenReplies',
|
|
||||||
key: 'showMutedReplies',
|
|
||||||
kind: HiddenReplyKind.Muted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sorted.slices.push({
|
|
||||||
type: 'showHiddenReplies',
|
|
||||||
key: 'showHiddenReplies',
|
|
||||||
kind: HiddenReplyKind.Hidden,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (sorted.muted.length) {
|
|
||||||
if (showMuted) {
|
|
||||||
sorted.slices.push(...sorted.muted)
|
|
||||||
} else {
|
|
||||||
sorted.slices.push({
|
|
||||||
type: 'showHiddenReplies',
|
|
||||||
key: 'showMutedReplies',
|
|
||||||
kind: HiddenReplyKind.Muted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sorted.slices
|
|
||||||
}, [sorted, shownHiddenReplyKinds])
|
|
||||||
|
|
||||||
// TODO should probably done in query cache so deletes flow through the same way
|
|
||||||
const optimistic = useMemo(() => {
|
|
||||||
for (let i = 0; i < flattened.length; i++) {
|
|
||||||
const item = flattened[i]
|
|
||||||
if (item.type !== 'threadSlice') continue
|
|
||||||
if (!(item.slice.uri in shadowSlices)) continue
|
|
||||||
|
|
||||||
const replyThreads = shadowSlices[item.slice.uri]
|
|
||||||
console.log(replyThreads)
|
|
||||||
|
|
||||||
// TODO linear view will work
|
|
||||||
if (!isTreeView) continue
|
|
||||||
|
|
||||||
for (const thread of replyThreads!) {
|
|
||||||
for (let ri = 0; ri < thread.length; ri++) {
|
|
||||||
const post = thread[ri]
|
|
||||||
post.depth = item.slice.depth + 1 + ri
|
|
||||||
const view = views.post({
|
|
||||||
item: post,
|
|
||||||
oneUp: undefined,
|
|
||||||
oneDown: undefined,
|
|
||||||
moderationOpts,
|
|
||||||
})
|
|
||||||
const insertIndex = i + 1 + ri
|
|
||||||
flattened.splice(
|
|
||||||
insertIndex,
|
|
||||||
// if same post, replace in situ
|
|
||||||
flattened[insertIndex]?.key === view.key ? 1 : 0,
|
|
||||||
view
|
|
||||||
)
|
|
||||||
console.log('insert', {
|
|
||||||
post: post.post.record?.text,
|
|
||||||
depth: item.slice.depth + 1 + ri,
|
|
||||||
ri,
|
|
||||||
spliceIndex: i + 1 + ri,
|
|
||||||
tree: flattened.map(f => f.slice?.post?.record?.text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO may not need since inserts will be looped and ignored
|
|
||||||
// console.log('prev', i)
|
|
||||||
// i = i - thread.length
|
|
||||||
// console.log('next', i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...flattened]
|
|
||||||
}, [flattened, shadowSlices, isTreeView])
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
if (hasSession) {
|
|
||||||
for (let i = 0; i < optimistic.length; i++) {
|
|
||||||
const slice = optimistic[i]
|
|
||||||
if ('slice' in slice && slice.slice.depth === 0) {
|
|
||||||
optimistic.splice(i + 1, 0, {
|
|
||||||
type: 'replyComposer',
|
|
||||||
key: 'replyComposer',
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimistic
|
|
||||||
}, [optimistic, hasSession])
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
items,
|
|
||||||
insertReplies,
|
|
||||||
}),
|
|
||||||
[items, insertReplies],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortThread(
|
|
||||||
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
|
|
||||||
{
|
|
||||||
threadgateHiddenReplies,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
threadgateHiddenReplies: Set<string>
|
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const slices: Slice[] = []
|
|
||||||
const hidden: Slice[] = []
|
|
||||||
const muted: Slice[] = []
|
|
||||||
|
|
||||||
traversal: for (let i = 0; i < thread.length; i++) {
|
|
||||||
const item = thread[i]
|
|
||||||
|
|
||||||
// ignore unknowns
|
|
||||||
if (!('depth' in item)) continue
|
|
||||||
|
|
||||||
if (item.depth < 0) {
|
|
||||||
/*
|
|
||||||
* Parents are ignored until we find the highlighted post, then we walk
|
|
||||||
* _up_ from there.
|
|
||||||
*/
|
|
||||||
} else if (item.depth === 0) {
|
|
||||||
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) {
|
|
||||||
slices.push(views.noUnauthenticated({item}))
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) {
|
|
||||||
slices.push(views.notFound({item}))
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) {
|
|
||||||
slices.push(views.blocked({item}))
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
|
||||||
slices.push(
|
|
||||||
views.post({
|
|
||||||
item,
|
|
||||||
oneUp: thread[i - 1],
|
|
||||||
oneDown: thread[i + 1],
|
|
||||||
moderationOpts,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
|
|
||||||
const parentOneDown = thread[pi + 1]
|
|
||||||
const parent = thread[pi]
|
|
||||||
const parentOneUp = thread[pi - 1]
|
|
||||||
|
|
||||||
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) {
|
|
||||||
slices.unshift(views.noUnauthenticated({item: parent}))
|
|
||||||
break parentTraversal
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) {
|
|
||||||
slices.unshift(views.notFound({item: parent}))
|
|
||||||
break parentTraversal
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) {
|
|
||||||
slices.unshift(views.blocked({item: parent}))
|
|
||||||
break parentTraversal
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) {
|
|
||||||
slices.unshift(
|
|
||||||
views.post({
|
|
||||||
item: parent,
|
|
||||||
oneUp: parentOneUp,
|
|
||||||
oneDown: parentOneDown,
|
|
||||||
moderationOpts,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (item.depth > 0) {
|
|
||||||
/*
|
|
||||||
* The API does not send down any unavailable replies, so this will
|
|
||||||
* always be false (for now). If we ever wanted to tombstone them here,
|
|
||||||
* we could.
|
|
||||||
*/
|
|
||||||
const shouldBreak =
|
|
||||||
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item) ||
|
|
||||||
AppBskyUnspeccedDefs.isThreadItemNotFound(item) ||
|
|
||||||
AppBskyUnspeccedDefs.isThreadItemBlocked(item)
|
|
||||||
|
|
||||||
if (shouldBreak) {
|
|
||||||
const branch = getBranch(thread, i, item.depth)
|
|
||||||
// could insert tombstone
|
|
||||||
i = branch.end
|
|
||||||
continue traversal
|
|
||||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
|
||||||
const lastSlice = slices[slices.length - 1]
|
|
||||||
const isFirstReply =
|
|
||||||
lastSlice.type === 'replyComposer' ||
|
|
||||||
(lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0)
|
|
||||||
const parent = views.post({
|
|
||||||
item,
|
|
||||||
oneUp: isFirstReply ? undefined : thread[i - 1],
|
|
||||||
oneDown: thread[i + 1],
|
|
||||||
moderationOpts,
|
|
||||||
})
|
|
||||||
const parentMod = getModerationState(parent.moderation)
|
|
||||||
const parentIsHidden = threadgateHiddenReplies.has(item.uri)
|
|
||||||
const parentIsTopLevelReply = item.depth === 1
|
|
||||||
const parentIsModerated =
|
|
||||||
parentIsHidden || parentMod.blurred || parentMod.muted
|
|
||||||
|
|
||||||
if (!parentIsModerated) {
|
|
||||||
/*
|
|
||||||
* Not hidden, so show it
|
|
||||||
*/
|
|
||||||
slices.push(parent)
|
|
||||||
} else {
|
|
||||||
const branch = getBranch(thread, i, item.depth)
|
|
||||||
const sortArray = parentMod.muted ? muted : hidden
|
|
||||||
|
|
||||||
if (parentIsTopLevelReply) {
|
|
||||||
// push branch anchor into sorted array
|
|
||||||
sortArray.push(parent)
|
|
||||||
// skip branch anchor in branch traversal
|
|
||||||
const startIndex = branch.start + 1
|
|
||||||
|
|
||||||
for (let ci = startIndex; ci <= branch.end; ci++) {
|
|
||||||
const child = thread[ci]
|
|
||||||
|
|
||||||
if (AppBskyUnspeccedDefs.isThreadItemPost(child)) {
|
|
||||||
const childPost = views.post({
|
|
||||||
item: child,
|
|
||||||
oneUp: thread[ci - 1],
|
|
||||||
oneDown: thread[ci + 1],
|
|
||||||
moderationOpts,
|
|
||||||
})
|
|
||||||
const childMod = getModerationState(childPost.moderation)
|
|
||||||
const childIsHidden = threadgateHiddenReplies.has(child.uri)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If a child is hidden in any way, drop it an its sub-branch
|
|
||||||
* entirely. To reveal these, the user must navigate to the
|
|
||||||
* parent post directly.
|
|
||||||
*/
|
|
||||||
if (childMod.blurred || childMod.muted || childIsHidden) {
|
|
||||||
ci = getBranch(thread, ci, child.depth).end
|
|
||||||
} else {
|
|
||||||
sortArray.push(childPost)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
/*
|
|
||||||
* Drop the rest of the branch if we hit anything unexpected
|
|
||||||
*/
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Skip to next branch
|
|
||||||
*/
|
|
||||||
i = branch.end
|
|
||||||
continue traversal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
slices,
|
|
||||||
hidden,
|
|
||||||
muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getModerationState(moderation: ModerationDecision) {
|
|
||||||
const modui = moderation.ui('contentList')
|
|
||||||
const blurred = modui.blur || modui.filter
|
|
||||||
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
|
|
||||||
return {
|
|
||||||
blurred,
|
|
||||||
muted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum HiddenReplyKind {
|
|
||||||
Hidden = 'hidden',
|
|
||||||
Muted = 'muted',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Slice =
|
|
||||||
| {
|
|
||||||
type: 'threadSlice'
|
|
||||||
key: string
|
|
||||||
slice: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
|
|
||||||
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
|
||||||
record: AppBskyFeedPost.Record
|
|
||||||
}
|
|
||||||
}
|
|
||||||
moderation: ModerationDecision
|
|
||||||
ui: {
|
|
||||||
isAnchor: boolean
|
|
||||||
showParentReplyLine: boolean
|
|
||||||
showChildReplyLine: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'threadSliceNoUnauthenticated'
|
|
||||||
key: string
|
|
||||||
slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'threadSliceNotFound'
|
|
||||||
key: string
|
|
||||||
slice: AppBskyUnspeccedDefs.ThreadItemNotFound
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'threadSliceBlocked'
|
|
||||||
key: string
|
|
||||||
slice: AppBskyUnspeccedDefs.ThreadItemBlocked
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'replyComposer'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'showHiddenReplies'
|
|
||||||
key: string
|
|
||||||
kind: HiddenReplyKind
|
|
||||||
}
|
|
||||||
|
|
||||||
function getThreadgate(
|
|
||||||
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
|
|
||||||
) {
|
|
||||||
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
|
|
||||||
view?.record,
|
|
||||||
AppBskyFeedThreadgate.isRecord,
|
|
||||||
)
|
|
||||||
? view?.record
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSlicePlaceholder(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
uri: string,
|
|
||||||
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> | void {
|
|
||||||
let partial
|
|
||||||
for (let item of yieldPlaceholdersFromQueryCache(queryClient, uri)) {
|
|
||||||
/*
|
|
||||||
* Currently, the backend doesn't send full post info in some cases (for
|
|
||||||
* example, for quoted posts). We use missing `likeCount` as a way to
|
|
||||||
* detect that. In the future, we should fix this on the backend, which
|
|
||||||
* will let us always stop on the first result.
|
|
||||||
*
|
|
||||||
* TODO can we send in feeds and quotes?
|
|
||||||
*/
|
|
||||||
const hasAllInfo = item.post.likeCount != null
|
|
||||||
if (hasAllInfo) {
|
|
||||||
return item
|
|
||||||
} else {
|
|
||||||
// Keep searching, we might still find a full post in the cache.
|
|
||||||
partial = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return partial
|
|
||||||
}
|
|
||||||
|
|
||||||
export function* yieldPlaceholdersFromQueryCache(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
uri: string,
|
|
||||||
): Generator<$Typed<AppBskyUnspeccedDefs.ThreadItemPost>, void> {
|
|
||||||
const atUri = new AtUri(uri)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Check this thread in the cache first.
|
|
||||||
* TODO extract just this for shadowing
|
|
||||||
*/
|
|
||||||
const queryDatas =
|
|
||||||
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
|
||||||
queryKey: [getPostThreadV2QueryKeyRoot],
|
|
||||||
})
|
|
||||||
for (const [_queryKey, queryData] of queryDatas) {
|
|
||||||
if (!queryData) continue
|
|
||||||
|
|
||||||
const {thread} = queryData
|
|
||||||
|
|
||||||
for (const item of thread) {
|
|
||||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
|
||||||
if (didOrHandleUriMatches(atUri, item.post)) {
|
|
||||||
yield {
|
|
||||||
...item,
|
|
||||||
depth: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const qp = getEmbeddedPost(item.post.embed)
|
|
||||||
if (qp && didOrHandleUriMatches(atUri, qp)) {
|
|
||||||
yield embedViewToSlicePlaceholder(qp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Check notifications first. If you have a post in notifications, it's
|
|
||||||
* often due to a like or a repost, and we want to prioritize a post object
|
|
||||||
* with >0 likes/reposts over a stale version with no metrics in order to
|
|
||||||
* avoid a notification->post scroll jump.
|
|
||||||
*/
|
|
||||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
|
||||||
yield postViewToSlicePlaceholder(post)
|
|
||||||
}
|
|
||||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
|
||||||
yield postViewToSlicePlaceholder(post)
|
|
||||||
}
|
|
||||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
|
||||||
yield postViewToSlicePlaceholder(post)
|
|
||||||
}
|
|
||||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
|
||||||
yield postViewToSlicePlaceholder(post)
|
|
||||||
}
|
|
||||||
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
|
||||||
queryClient,
|
|
||||||
uri,
|
|
||||||
)) {
|
|
||||||
yield postViewToSlicePlaceholder(post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function postViewToSlicePlaceholder(
|
|
||||||
post: AppBskyFeedDefs.PostView,
|
|
||||||
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
|
|
||||||
return {
|
|
||||||
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
|
||||||
uri: post.uri,
|
|
||||||
post,
|
|
||||||
depth: 0, // reset to 0 for highlighted post
|
|
||||||
isOPThread: false, // unknown
|
|
||||||
hasOPLike: false, // unknown
|
|
||||||
hasUnhydratedReplies: false, // unknown
|
|
||||||
// TODO test
|
|
||||||
hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function embedViewToSlicePlaceholder(
|
|
||||||
record: AppBskyEmbedRecord.ViewRecord,
|
|
||||||
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
|
|
||||||
return {
|
|
||||||
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
|
||||||
uri: record.uri,
|
|
||||||
post: embedViewRecordToPostView(record),
|
|
||||||
depth: 0, // reset to 0 for highlighted post
|
|
||||||
isOPThread: false, // unknown
|
|
||||||
hasOPLike: false, // unknown
|
|
||||||
hasUnhydratedReplies: false, // unknown
|
|
||||||
// TODO test
|
|
||||||
hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {getThreadPlaceholder} from '#/state/queries/usePostThread/queryCache'
|
||||||
|
import {flatten,sort} from '#/state/queries/usePostThread/traversal'
|
||||||
|
import {
|
||||||
|
createPostThreadQueryKey,
|
||||||
|
HiddenReplyKind,
|
||||||
|
type UsePostThreadProps,
|
||||||
|
} from '#/state/queries/usePostThread/types'
|
||||||
|
import {
|
||||||
|
getThreadgateRecord,
|
||||||
|
mapSortOptionsToSortID,
|
||||||
|
} from '#/state/queries/usePostThread/utils'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||||
|
|
||||||
|
export * from '#/state/queries/usePostThread/types'
|
||||||
|
|
||||||
|
export function usePostThread({
|
||||||
|
uri,
|
||||||
|
enabled: isEnabled,
|
||||||
|
params,
|
||||||
|
state,
|
||||||
|
}: UsePostThreadProps) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
|
||||||
|
|
||||||
|
const enabled = isEnabled !== false && !!uri && !!moderationOpts
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
enabled,
|
||||||
|
queryKey: createPostThreadQueryKey({
|
||||||
|
uri,
|
||||||
|
params,
|
||||||
|
}),
|
||||||
|
async queryFn() {
|
||||||
|
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
|
||||||
|
uri: uri!,
|
||||||
|
branchingFactor: params.view === 'linear' ? 1 : 10,
|
||||||
|
below: 10,
|
||||||
|
sorting: mapSortOptionsToSortID(params.sort),
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
placeholderData() {
|
||||||
|
if (!uri) return
|
||||||
|
const placeholder = getThreadPlaceholder(qc, uri)
|
||||||
|
if (placeholder) {
|
||||||
|
return {thread: [placeholder]}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
},
|
||||||
|
select(data) {
|
||||||
|
const threadgate = getThreadgateRecord(data.threadgate)
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
threadgate: {
|
||||||
|
...data.threadgate,
|
||||||
|
record: threadgate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO map over pages, just like feeds
|
||||||
|
|
||||||
|
const items = flatten(
|
||||||
|
sort(query.data?.thread || [], {
|
||||||
|
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
|
||||||
|
query.data?.threadgate?.record,
|
||||||
|
),
|
||||||
|
moderationOpts: moderationOpts!,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
hasSession,
|
||||||
|
showMuted: state.shownHiddenReplyKinds.has(HiddenReplyKind.Muted),
|
||||||
|
showHidden: state.shownHiddenReplyKinds.has(HiddenReplyKind.Hidden),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
data: {
|
||||||
|
slices: items,
|
||||||
|
threadgate: query.data?.threadgate,
|
||||||
|
},
|
||||||
|
insertReplies: () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
type $Typed,
|
||||||
|
AppBskyUnspeccedDefs,
|
||||||
|
type AppBskyUnspeccedGetPostThreadV2,
|
||||||
|
AtUri,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {type QueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
|
||||||
|
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||||
|
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||||
|
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||||
|
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||||
|
import {postThreadQueryKeyRoot} from '#/state/queries/usePostThread/types'
|
||||||
|
import {
|
||||||
|
embedViewToThreadPlaceholder,
|
||||||
|
postViewToThreadPlaceholder,
|
||||||
|
} from '#/state/queries/usePostThread/views'
|
||||||
|
import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
|
||||||
|
|
||||||
|
export function getThreadPlaceholder(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
uri: string,
|
||||||
|
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> | void {
|
||||||
|
let partial
|
||||||
|
for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
|
||||||
|
/*
|
||||||
|
* Currently, the backend doesn't send full post info in some cases (for
|
||||||
|
* example, for quoted posts). We use missing `likeCount` as a way to
|
||||||
|
* detect that. In the future, we should fix this on the backend, which
|
||||||
|
* will let us always stop on the first result.
|
||||||
|
*
|
||||||
|
* TODO can we send in feeds and quotes?
|
||||||
|
*/
|
||||||
|
const hasAllInfo = item.post.likeCount != null
|
||||||
|
if (hasAllInfo) {
|
||||||
|
return item
|
||||||
|
} else {
|
||||||
|
// Keep searching, we might still find a full post in the cache.
|
||||||
|
partial = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return partial
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getThreadPlaceholderCandidates(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
uri: string,
|
||||||
|
): Generator<$Typed<AppBskyUnspeccedDefs.ThreadItemPost>, void> {
|
||||||
|
const atUri = new AtUri(uri)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check this thread in the cache first.
|
||||||
|
* TODO extract just this for shadowing
|
||||||
|
*/
|
||||||
|
const queryDatas =
|
||||||
|
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
||||||
|
queryKey: [postThreadQueryKeyRoot],
|
||||||
|
})
|
||||||
|
for (const [_queryKey, queryData] of queryDatas) {
|
||||||
|
if (!queryData) continue
|
||||||
|
|
||||||
|
const {thread} = queryData
|
||||||
|
|
||||||
|
for (const item of thread) {
|
||||||
|
if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
||||||
|
if (didOrHandleUriMatches(atUri, item.post)) {
|
||||||
|
yield {
|
||||||
|
...item,
|
||||||
|
depth: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = getEmbeddedPost(item.post.embed)
|
||||||
|
if (qp && didOrHandleUriMatches(atUri, qp)) {
|
||||||
|
yield embedViewToThreadPlaceholder(qp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check notifications first. If you have a post in notifications, it's
|
||||||
|
* often due to a like or a repost, and we want to prioritize a post object
|
||||||
|
* with >0 likes/reposts over a stale version with no metrics in order to
|
||||||
|
* avoid a notification->post scroll jump.
|
||||||
|
*/
|
||||||
|
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||||
|
yield postViewToThreadPlaceholder(post)
|
||||||
|
}
|
||||||
|
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||||
|
yield postViewToThreadPlaceholder(post)
|
||||||
|
}
|
||||||
|
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||||
|
yield postViewToThreadPlaceholder(post)
|
||||||
|
}
|
||||||
|
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||||
|
yield postViewToThreadPlaceholder(post)
|
||||||
|
}
|
||||||
|
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
||||||
|
queryClient,
|
||||||
|
uri,
|
||||||
|
)) {
|
||||||
|
yield postViewToThreadPlaceholder(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
AppBskyUnspeccedDefs,
|
||||||
|
type AppBskyUnspeccedGetPostThreadV2,
|
||||||
|
type ModerationDecision,
|
||||||
|
type ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {HiddenReplyKind,type Slice} from '#/state/queries/usePostThread/types'
|
||||||
|
import * as views from '#/state/queries/usePostThread/views'
|
||||||
|
|
||||||
|
export function flatten(
|
||||||
|
sorted: ReturnType<typeof sort>,
|
||||||
|
{
|
||||||
|
hasSession,
|
||||||
|
showMuted,
|
||||||
|
showHidden,
|
||||||
|
}: {
|
||||||
|
hasSession: boolean
|
||||||
|
showMuted: boolean
|
||||||
|
showHidden: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const flattened: Slice[] = sorted.slices
|
||||||
|
|
||||||
|
if (sorted.hidden.length) {
|
||||||
|
if (showHidden) {
|
||||||
|
flattened.push(...sorted.hidden)
|
||||||
|
|
||||||
|
if (sorted.muted.length) {
|
||||||
|
if (showMuted) {
|
||||||
|
flattened.push(...sorted.muted)
|
||||||
|
} else {
|
||||||
|
flattened.push({
|
||||||
|
type: 'showHiddenReplies',
|
||||||
|
key: 'showMutedReplies',
|
||||||
|
kind: HiddenReplyKind.Muted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flattened.push({
|
||||||
|
type: 'showHiddenReplies',
|
||||||
|
key: 'showHiddenReplies',
|
||||||
|
kind: HiddenReplyKind.Hidden,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (sorted.muted.length) {
|
||||||
|
if (showMuted) {
|
||||||
|
flattened.push(...sorted.muted)
|
||||||
|
} else {
|
||||||
|
flattened.push({
|
||||||
|
type: 'showHiddenReplies',
|
||||||
|
key: 'showMutedReplies',
|
||||||
|
kind: HiddenReplyKind.Muted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSession) {
|
||||||
|
for (let i = 0; i < flattened.length; i++) {
|
||||||
|
const slice = flattened[i]
|
||||||
|
if ('slice' in slice && slice.slice.depth === 0) {
|
||||||
|
flattened.splice(i + 1, 0, {
|
||||||
|
type: 'replyComposer',
|
||||||
|
key: 'replyComposer',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sort(
|
||||||
|
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
|
||||||
|
{
|
||||||
|
threadgateHiddenReplies,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
threadgateHiddenReplies: Set<string>
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const slices: Slice[] = []
|
||||||
|
const hidden: Slice[] = []
|
||||||
|
const muted: Slice[] = []
|
||||||
|
|
||||||
|
traversal: for (let i = 0; i < thread.length; i++) {
|
||||||
|
const item = thread[i]
|
||||||
|
|
||||||
|
// ignore unknowns
|
||||||
|
if (!('depth' in item)) continue
|
||||||
|
|
||||||
|
if (item.depth < 0) {
|
||||||
|
/*
|
||||||
|
* Parents are ignored until we find the highlighted post, then we walk
|
||||||
|
* _up_ from there.
|
||||||
|
*/
|
||||||
|
} else if (item.depth === 0) {
|
||||||
|
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) {
|
||||||
|
slices.push(views.noUnauthenticated({item}))
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) {
|
||||||
|
slices.push(views.notFound({item}))
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) {
|
||||||
|
slices.push(views.blocked({item}))
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
||||||
|
slices.push(
|
||||||
|
views.post({
|
||||||
|
item,
|
||||||
|
oneUp: thread[i - 1],
|
||||||
|
oneDown: thread[i + 1],
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
|
||||||
|
const parentOneDown = thread[pi + 1]
|
||||||
|
const parent = thread[pi]
|
||||||
|
const parentOneUp = thread[pi - 1]
|
||||||
|
|
||||||
|
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) {
|
||||||
|
slices.unshift(views.noUnauthenticated({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) {
|
||||||
|
slices.unshift(views.notFound({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) {
|
||||||
|
slices.unshift(views.blocked({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) {
|
||||||
|
slices.unshift(
|
||||||
|
views.post({
|
||||||
|
item: parent,
|
||||||
|
oneUp: parentOneUp,
|
||||||
|
oneDown: parentOneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item.depth > 0) {
|
||||||
|
/*
|
||||||
|
* The API does not send down any unavailable replies, so this will
|
||||||
|
* always be false (for now). If we ever wanted to tombstone them here,
|
||||||
|
* we could.
|
||||||
|
*/
|
||||||
|
const shouldBreak =
|
||||||
|
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item) ||
|
||||||
|
AppBskyUnspeccedDefs.isThreadItemNotFound(item) ||
|
||||||
|
AppBskyUnspeccedDefs.isThreadItemBlocked(item)
|
||||||
|
|
||||||
|
if (shouldBreak) {
|
||||||
|
const branch = getBranch(thread, i, item.depth)
|
||||||
|
// could insert tombstone
|
||||||
|
i = branch.end
|
||||||
|
continue traversal
|
||||||
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
|
||||||
|
const lastSlice = slices[slices.length - 1]
|
||||||
|
const isFirstReply =
|
||||||
|
lastSlice.type === 'replyComposer' ||
|
||||||
|
(lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0)
|
||||||
|
const parent = views.post({
|
||||||
|
item,
|
||||||
|
oneUp: isFirstReply ? undefined : thread[i - 1],
|
||||||
|
oneDown: thread[i + 1],
|
||||||
|
moderationOpts,
|
||||||
|
})
|
||||||
|
const parentMod = getModerationState(parent.moderation)
|
||||||
|
const parentIsHidden = threadgateHiddenReplies.has(item.uri)
|
||||||
|
const parentIsTopLevelReply = item.depth === 1
|
||||||
|
const parentIsModerated =
|
||||||
|
parentIsHidden || parentMod.blurred || parentMod.muted
|
||||||
|
|
||||||
|
if (!parentIsModerated) {
|
||||||
|
/*
|
||||||
|
* Not hidden, so show it
|
||||||
|
*/
|
||||||
|
slices.push(parent)
|
||||||
|
} else {
|
||||||
|
const branch = getBranch(thread, i, item.depth)
|
||||||
|
const sortArray = parentMod.muted ? muted : hidden
|
||||||
|
|
||||||
|
if (parentIsTopLevelReply) {
|
||||||
|
// push branch anchor into sorted array
|
||||||
|
sortArray.push(parent)
|
||||||
|
// skip branch anchor in branch traversal
|
||||||
|
const startIndex = branch.start + 1
|
||||||
|
|
||||||
|
for (let ci = startIndex; ci <= branch.end; ci++) {
|
||||||
|
const child = thread[ci]
|
||||||
|
|
||||||
|
if (AppBskyUnspeccedDefs.isThreadItemPost(child)) {
|
||||||
|
const childPost = views.post({
|
||||||
|
item: child,
|
||||||
|
oneUp: thread[ci - 1],
|
||||||
|
oneDown: thread[ci + 1],
|
||||||
|
moderationOpts,
|
||||||
|
})
|
||||||
|
const childMod = getModerationState(childPost.moderation)
|
||||||
|
const childIsHidden = threadgateHiddenReplies.has(child.uri)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If a child is hidden in any way, drop it an its sub-branch
|
||||||
|
* entirely. To reveal these, the user must navigate to the
|
||||||
|
* parent post directly.
|
||||||
|
*/
|
||||||
|
if (childMod.blurred || childMod.muted || childIsHidden) {
|
||||||
|
ci = getBranch(thread, ci, child.depth).end
|
||||||
|
} else {
|
||||||
|
sortArray.push(childPost)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* Drop the rest of the branch if we hit anything unexpected
|
||||||
|
*/
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Skip to next branch
|
||||||
|
*/
|
||||||
|
i = branch.end
|
||||||
|
continue traversal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
slices,
|
||||||
|
hidden,
|
||||||
|
muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the start and end index of a "branch" of the thread. A "branch" is a
|
||||||
|
* parent and it's children (not siblings). Returned indices are inclusive of
|
||||||
|
* the parent and its last child.
|
||||||
|
*
|
||||||
|
* items[] (index, depth)
|
||||||
|
* ├── branch ───── (0, 1)
|
||||||
|
* ├─┬ branch ───── (1, 1) (start)
|
||||||
|
* │ ├──┬ leaf ──── (2, 2)
|
||||||
|
* │ │ └── leaf ── (3, 3)
|
||||||
|
* │ └── leaf ───── (4, 2) (end)
|
||||||
|
* ├── branch ───── (5, 1)
|
||||||
|
* ├── branch ───── (6, 1)
|
||||||
|
*
|
||||||
|
* const { start: 1, end: 3 } = getBranch(items, 1, 1)
|
||||||
|
*/
|
||||||
|
function getBranch(
|
||||||
|
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
|
||||||
|
branchStartIndex: number,
|
||||||
|
branchStartDepth: number,
|
||||||
|
) {
|
||||||
|
let end = branchStartIndex
|
||||||
|
|
||||||
|
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
|
||||||
|
const next = thread[ci]
|
||||||
|
// ignore unknowns
|
||||||
|
if (!('depth' in next)) continue
|
||||||
|
if (next.depth > branchStartDepth) {
|
||||||
|
end = ci
|
||||||
|
} else {
|
||||||
|
end = ci - 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: branchStartIndex,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModerationState(moderation: ModerationDecision) {
|
||||||
|
const modui = moderation.ui('contentList')
|
||||||
|
const blurred = modui.blur || modui.filter
|
||||||
|
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
|
||||||
|
return {
|
||||||
|
blurred,
|
||||||
|
muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
type AppBskyFeedPost,
|
||||||
|
type AppBskyUnspeccedDefs,
|
||||||
|
type BskyThreadViewPreference,
|
||||||
|
type ModerationDecision,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
export const postThreadQueryKeyRoot = 'getPostThreadV2' as const
|
||||||
|
|
||||||
|
export const createPostThreadQueryKey = (
|
||||||
|
props: Pick<UsePostThreadProps, 'uri' | 'params'>,
|
||||||
|
) => [postThreadQueryKeyRoot, props] as const
|
||||||
|
|
||||||
|
export type PostThreadParams = {
|
||||||
|
view: 'tree' | 'linear'
|
||||||
|
sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string
|
||||||
|
prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers']
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsePostThreadProps = {
|
||||||
|
uri?: string
|
||||||
|
enabled?: boolean
|
||||||
|
params: PostThreadParams
|
||||||
|
state: {
|
||||||
|
shownHiddenReplyKinds: Set<HiddenReplyKind>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum HiddenReplyKind {
|
||||||
|
Hidden = 'hidden',
|
||||||
|
Muted = 'muted',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Slice =
|
||||||
|
| {
|
||||||
|
type: 'threadSlice'
|
||||||
|
key: string
|
||||||
|
slice: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
|
||||||
|
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
||||||
|
record: AppBskyFeedPost.Record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moderation: ModerationDecision
|
||||||
|
ui: {
|
||||||
|
isAnchor: boolean
|
||||||
|
showParentReplyLine: boolean
|
||||||
|
showChildReplyLine: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'threadSliceNoUnauthenticated'
|
||||||
|
key: string
|
||||||
|
slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'threadSliceNotFound'
|
||||||
|
key: string
|
||||||
|
slice: AppBskyUnspeccedDefs.ThreadItemNotFound
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'threadSliceBlocked'
|
||||||
|
key: string
|
||||||
|
slice: AppBskyUnspeccedDefs.ThreadItemBlocked
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'replyComposer'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'showHiddenReplies'
|
||||||
|
key: string
|
||||||
|
kind: HiddenReplyKind
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
APP_BSKY_UNSPECCED,
|
||||||
|
AppBskyFeedThreadgate,
|
||||||
|
type AppBskyUnspeccedGetPostThreadV2,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type PostThreadParams} from '#/state/queries/usePostThread/types'
|
||||||
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
export function mapSortOptionsToSortID(sort: PostThreadParams['sort']) {
|
||||||
|
switch (sort) {
|
||||||
|
case 'hotness':
|
||||||
|
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
|
||||||
|
case 'oldest':
|
||||||
|
return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest
|
||||||
|
case 'newest':
|
||||||
|
return APP_BSKY_UNSPECCED.GetPostThreadV2Newest
|
||||||
|
case 'most-likes':
|
||||||
|
return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes
|
||||||
|
default:
|
||||||
|
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThreadgateRecord(
|
||||||
|
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
|
||||||
|
) {
|
||||||
|
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
|
||||||
|
view?.record,
|
||||||
|
AppBskyFeedThreadgate.isRecord,
|
||||||
|
)
|
||||||
|
? view?.record
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
type $Typed,
|
||||||
|
type AppBskyEmbedRecord,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
type AppBskyFeedPost,
|
||||||
|
type AppBskyUnspeccedDefs,
|
||||||
|
type AppBskyUnspeccedGetPostThreadV2,
|
||||||
|
moderatePost,
|
||||||
|
type ModerationOpts,
|
||||||
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type Slice} from '#/state/queries/usePostThread/types'
|
||||||
|
import {embedViewRecordToPostView} from '#/state/queries/util'
|
||||||
|
|
||||||
|
export function noUnauthenticated({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
||||||
|
}): Extract<Slice, {type: 'threadSliceNoUnauthenticated'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceNoUnauthenticated',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notFound({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyUnspeccedDefs.ThreadItemNotFound
|
||||||
|
}): Extract<Slice, {type: 'threadSliceNotFound'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceNotFound',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blocked({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyUnspeccedDefs.ThreadItemBlocked
|
||||||
|
}): Extract<Slice, {type: 'threadSliceBlocked'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceBlocked',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function post({
|
||||||
|
item,
|
||||||
|
oneUp,
|
||||||
|
oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
item: AppBskyUnspeccedDefs.ThreadItemPost
|
||||||
|
oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
|
||||||
|
oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}): Extract<Slice, {type: 'threadSlice'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSlice',
|
||||||
|
key: item.uri,
|
||||||
|
slice: {
|
||||||
|
...item,
|
||||||
|
post: {
|
||||||
|
...item.post,
|
||||||
|
record: item.post.record as AppBskyFeedPost.Record,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderation: moderatePost(item.post, moderationOpts),
|
||||||
|
ui: {
|
||||||
|
isAnchor: item.depth === 0,
|
||||||
|
showParentReplyLine:
|
||||||
|
!!oneUp && 'depth' in oneUp && oneUp.depth < item.depth,
|
||||||
|
showChildReplyLine:
|
||||||
|
!!oneDown && 'depth' in oneDown && oneDown.depth > item.depth,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postViewToThreadPlaceholder(
|
||||||
|
post: AppBskyFeedDefs.PostView,
|
||||||
|
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
||||||
|
uri: post.uri,
|
||||||
|
post,
|
||||||
|
depth: 0, // reset to 0 for highlighted post
|
||||||
|
isOPThread: false, // unknown
|
||||||
|
hasOPLike: false, // unknown
|
||||||
|
hasUnhydratedReplies: false, // unknown
|
||||||
|
// TODO test
|
||||||
|
hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function embedViewToThreadPlaceholder(
|
||||||
|
record: AppBskyEmbedRecord.ViewRecord,
|
||||||
|
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
||||||
|
uri: record.uri,
|
||||||
|
post: embedViewRecordToPostView(record),
|
||||||
|
depth: 0, // reset to 0 for highlighted post
|
||||||
|
isOPThread: false, // unknown
|
||||||
|
hasOPLike: false, // unknown
|
||||||
|
hasUnhydratedReplies: false, // unknown
|
||||||
|
// TODO test
|
||||||
|
hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
import {useWindowDimensions, View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {AppBskyUnspeccedDefs} from '@atproto/api'
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
import {type CommonNavigatorParams, type NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
|
{
|
||||||
import * as Layout from '#/components/Layout'
|
/* import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' */
|
||||||
import {useSession} from '#/state/session'
|
}
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {useGetPostThreadV2, Slice, HiddenReplyKind} from '#/state/queries/useGetPostThreadV2'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
import * as Menu from '#/components/Menu'
|
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
|
import {
|
||||||
|
HiddenReplyKind,
|
||||||
|
type Slice,
|
||||||
|
usePostThread,
|
||||||
|
} from '#/state/queries/usePostThread'
|
||||||
|
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
|
||||||
|
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
|
||||||
|
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
|
||||||
|
import {List, type ListMethods} from '#/view/com/util/List'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
|
||||||
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {ListFooter} from '#/components/Lists'
|
||||||
|
import * as Menu from '#/components/Menu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
|
||||||
import {List, ListMethods} from '#/view/com/util/List'
|
|
||||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
|
||||||
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
|
|
||||||
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
|
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
|
||||||
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
|
|
||||||
|
|
||||||
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
|
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
|
||||||
// We don't insert any elements before the root row while loading.
|
// We don't insert any elements before the root row while loading.
|
||||||
@@ -91,23 +96,26 @@ function useThreadPreferences() {
|
|||||||
|
|
||||||
const isLoaded = !!prevServerPrefs
|
const isLoaded = !!prevServerPrefs
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(
|
||||||
isLoaded,
|
() => ({
|
||||||
sortReplies,
|
isLoaded,
|
||||||
setSortReplies,
|
sortReplies,
|
||||||
prioritizeFollowedUsers,
|
setSortReplies,
|
||||||
setPrioritizeFollowedUsers,
|
prioritizeFollowedUsers,
|
||||||
treeViewEnabled,
|
setPrioritizeFollowedUsers,
|
||||||
setTreeViewEnabled,
|
treeViewEnabled,
|
||||||
}), [
|
setTreeViewEnabled,
|
||||||
isLoaded,
|
}),
|
||||||
sortReplies,
|
[
|
||||||
setSortReplies,
|
isLoaded,
|
||||||
prioritizeFollowedUsers,
|
sortReplies,
|
||||||
setPrioritizeFollowedUsers,
|
setSortReplies,
|
||||||
treeViewEnabled,
|
prioritizeFollowedUsers,
|
||||||
setTreeViewEnabled,
|
setPrioritizeFollowedUsers,
|
||||||
])
|
treeViewEnabled,
|
||||||
|
setTreeViewEnabled,
|
||||||
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inner({uri}: {uri: string | undefined}) {
|
export function Inner({uri}: {uri: string | undefined}) {
|
||||||
@@ -127,39 +135,42 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
setTreeViewEnabled,
|
setTreeViewEnabled,
|
||||||
} = useThreadPreferences()
|
} = useThreadPreferences()
|
||||||
|
|
||||||
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<Set<HiddenReplyKind>>(new Set())
|
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
|
||||||
|
Set<HiddenReplyKind>
|
||||||
|
>(new Set())
|
||||||
|
|
||||||
const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} = useGetPostThreadV2({
|
const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} =
|
||||||
uri,
|
usePostThread({
|
||||||
enabled: isThreadPreferencesLoaded,
|
uri,
|
||||||
params: {
|
enabled: isThreadPreferencesLoaded,
|
||||||
sort: sortReplies,
|
params: {
|
||||||
view: treeViewEnabled ? 'tree' : 'linear',
|
sort: sortReplies,
|
||||||
prioritizeFollows: prioritizeFollowedUsers,
|
view: treeViewEnabled ? 'tree' : 'linear',
|
||||||
},
|
prioritizeFollows: prioritizeFollowedUsers,
|
||||||
state: {
|
},
|
||||||
shownHiddenReplyKinds,
|
state: {
|
||||||
}
|
shownHiddenReplyKinds,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const ref = useRef<ListMethods>(null)
|
const ref = useRef<ListMethods>(null)
|
||||||
const layoutHeaderRef = useRef<View | null>(null)
|
const layoutHeaderRef = useRef<View | null>(null)
|
||||||
const anchorPostRef = useRef<View | null>(null)
|
const anchorPostRef = useRef<View | null>(null)
|
||||||
|
|
||||||
const optimisticOnPostReply = ({
|
const optimisticOnPostReply =
|
||||||
post,
|
({post}: {post: AppBskyUnspeccedDefs.ThreadItemPost}) =>
|
||||||
}: {
|
(_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
|
||||||
post: AppBskyUnspeccedDefs.ThreadItemPost,
|
if (posts.length) {
|
||||||
}) => (_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
|
// TODO get parent and update reply count?
|
||||||
if (posts.length) {
|
insertReplies(post.uri, posts)
|
||||||
// TODO get parent and update reply count?
|
}
|
||||||
insertReplies(post.uri, posts)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const {openComposer} = useOpenComposer()
|
const {openComposer} = useOpenComposer()
|
||||||
const onReplyToAnchor = () => {
|
const onReplyToAnchor = () => {
|
||||||
const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor)
|
const anchorPost = data?.slices.find(
|
||||||
|
slice => slice.type === 'threadSlice' && slice.ui.isAnchor,
|
||||||
|
)
|
||||||
if (anchorPost?.type !== 'threadSlice') {
|
if (anchorPost?.type !== 'threadSlice') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,7 +185,7 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
moderation: anchorPost.moderation,
|
moderation: anchorPost.moderation,
|
||||||
},
|
},
|
||||||
// @ts-expect-error TODO
|
// @ts-expect-error TODO
|
||||||
onPost: optimisticOnPostReply({post:anchorPost.slice}),
|
onPost: optimisticOnPostReply({post: anchorPost.slice}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +211,11 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
item.ui.showParentReplyLine || !!item.slice.hasUnhydratedParents
|
item.ui.showParentReplyLine || !!item.slice.hasUnhydratedParents
|
||||||
} // !!hasUnrevealedParents // TODO
|
} // !!hasUnrevealedParents // TODO
|
||||||
overrideBlur={
|
overrideBlur={
|
||||||
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && item.slice.depth > 0
|
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) &&
|
||||||
|
item.slice.depth > 0
|
||||||
}
|
}
|
||||||
// @ts-expect-error TODO
|
// @ts-expect-error TODO
|
||||||
onPostReply={optimisticOnPostReply({post:item.slice})}
|
onPostReply={optimisticOnPostReply({post: item.slice})}
|
||||||
hideTopBorder={index === 0} // && !item.slice.isParentLoading} // TODO
|
hideTopBorder={index === 0} // && !item.slice.isParentLoading} // TODO
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -239,14 +251,18 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
} else if (item.type === 'replyComposer') {
|
} else if (item.type === 'replyComposer') {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{gtPhone && <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />}
|
{gtPhone && (
|
||||||
|
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
} else if (item.type === 'showHiddenReplies') {
|
} else if (item.type === 'showHiddenReplies') {
|
||||||
return (
|
return (
|
||||||
<PostThreadShowHiddenReplies
|
<PostThreadShowHiddenReplies
|
||||||
type={item.kind === 'muted' ? 'muted' : 'hidden'}
|
type={item.kind === 'muted' ? 'muted' : 'hidden'}
|
||||||
onPress={() => setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))}
|
onPress={() =>
|
||||||
|
setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -324,9 +340,9 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
|
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
|
||||||
*/
|
*/
|
||||||
maintainVisibleContentPosition={
|
maintainVisibleContentPosition={
|
||||||
isNative // && hasParents // TODO not sure we need this
|
isNative // && hasParents // TODO not sure we need this
|
||||||
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
? MAINTAIN_VISIBLE_CONTENT_POSITION
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
// removeClippedSubviews={isAndroid ? false : undefined}
|
// removeClippedSubviews={isAndroid ? false : undefined}
|
||||||
@@ -361,7 +377,7 @@ function PostThreadError({error}: {error: Error}) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
// TODO use new cleanError hook
|
// TODO use new cleanError hook
|
||||||
const {title, message} = useMemo(() => {
|
const {title: _title, message: _message} = useMemo(() => {
|
||||||
let title = _(msg`An error occurred`)
|
let title = _(msg`An error occurred`)
|
||||||
let message = cleanError(error)
|
let message = cleanError(error)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user