Sorting working
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
APP_BSKY_FEED,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
|
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
@@ -64,6 +65,21 @@ export type GetPostThreadV2QueryData = {
|
|||||||
threadgate?: AppBskyFeedDefs.ThreadgateView
|
threadgate?: AppBskyFeedDefs.ThreadgateView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapSortOptionsToSortID(sort: PostThreadV2Options['sort']) {
|
||||||
|
switch (sort) {
|
||||||
|
case 'hotness':
|
||||||
|
return APP_BSKY_FEED.GetPostThreadV2Hotness
|
||||||
|
case 'oldest':
|
||||||
|
return APP_BSKY_FEED.GetPostThreadV2Oldest
|
||||||
|
case 'newest':
|
||||||
|
return APP_BSKY_FEED.GetPostThreadV2Newest
|
||||||
|
case 'most-likes':
|
||||||
|
return APP_BSKY_FEED.GetPostThreadV2MostLikes
|
||||||
|
default:
|
||||||
|
return APP_BSKY_FEED.GetPostThreadV2Hotness
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useGetPostThreadV2({
|
export function useGetPostThreadV2({
|
||||||
uri,
|
uri,
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
@@ -77,26 +93,7 @@ export function useGetPostThreadV2({
|
|||||||
|
|
||||||
const enabled = isEnabled !== false && !!uri && !!moderationOpts
|
const enabled = isEnabled !== false && !!uri && !!moderationOpts
|
||||||
|
|
||||||
const select = useCallback(
|
const query = useQuery({
|
||||||
(data: AppBskyFeedGetPostThreadV2.OutputSchema) => {
|
|
||||||
const threadgate = getThreadgate(data.threadgate)
|
|
||||||
return {
|
|
||||||
slices: buildSlices(data.thread, {
|
|
||||||
hasSession,
|
|
||||||
options,
|
|
||||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate),
|
|
||||||
moderationOpts: moderationOpts!,
|
|
||||||
}),
|
|
||||||
threadgate: {
|
|
||||||
...data.threadgate,
|
|
||||||
record: threadgate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[hasSession, options, moderationOpts, mergeThreadgateHiddenReplies],
|
|
||||||
)
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
enabled,
|
enabled,
|
||||||
queryKey: createGetPostThreadV2QueryKey({
|
queryKey: createGetPostThreadV2QueryKey({
|
||||||
uri,
|
uri,
|
||||||
@@ -105,7 +102,8 @@ export function useGetPostThreadV2({
|
|||||||
async queryFn() {
|
async queryFn() {
|
||||||
const {data} = await agent.app.bsky.feed.getPostThreadV2({
|
const {data} = await agent.app.bsky.feed.getPostThreadV2({
|
||||||
uri: uri!,
|
uri: uri!,
|
||||||
depth: 10,
|
below: 10,
|
||||||
|
sorting: mapSortOptionsToSortID(options.sort),
|
||||||
})
|
})
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
@@ -117,8 +115,304 @@ export function useGetPostThreadV2({
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
select,
|
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 filtered = filterAndSort(query.data?.thread || [], {
|
||||||
|
hasSession,
|
||||||
|
options,
|
||||||
|
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
|
||||||
|
query.data?.threadgate?.record,
|
||||||
|
),
|
||||||
|
moderationOpts: moderationOpts!,
|
||||||
|
showHiddenReplies: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
data: {
|
||||||
|
slices: filtered,
|
||||||
|
threadgate: query.data?.threadgate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const views = {
|
||||||
|
noUnauthenticated({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyFeedDefs.ThreadItemNoUnauthenticated
|
||||||
|
}): Extract<Slice, {type: 'threadSliceNoUnauthenticated'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceNoUnauthenticated',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notFound({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyFeedDefs.ThreadItemNotFound
|
||||||
|
}): Extract<Slice, {type: 'threadSliceNotFound'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceNotFound',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
blocked({
|
||||||
|
item,
|
||||||
|
}: {
|
||||||
|
item: AppBskyFeedDefs.ThreadItemBlocked
|
||||||
|
}): Extract<Slice, {type: 'threadSliceBlocked'}> {
|
||||||
|
return {
|
||||||
|
type: 'threadSliceBlocked',
|
||||||
|
key: item.uri,
|
||||||
|
slice: item,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
post({
|
||||||
|
item,
|
||||||
|
oneUp,
|
||||||
|
oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}: {
|
||||||
|
item: AppBskyFeedDefs.ThreadItemPost
|
||||||
|
oneUp?: AppBskyFeedGetPostThreadV2.OutputSchema['thread'][number]
|
||||||
|
oneDown?: AppBskyFeedGetPostThreadV2.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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSubBranch(
|
||||||
|
thread: AppBskyFeedGetPostThreadV2.OutputSchema['thread'],
|
||||||
|
currentIndex: number,
|
||||||
|
depth: number,
|
||||||
|
) {
|
||||||
|
let nextIndex = currentIndex
|
||||||
|
|
||||||
|
for (let ci = currentIndex + 1; ci < thread.length; ci++) {
|
||||||
|
const next = thread[ci]
|
||||||
|
// ignore unknowns
|
||||||
|
if (!('depth' in next)) continue
|
||||||
|
if (next.depth > depth) {
|
||||||
|
nextIndex = ci
|
||||||
|
} else {
|
||||||
|
nextIndex = ci - 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: currentIndex,
|
||||||
|
end: nextIndex,
|
||||||
|
nextIndex,
|
||||||
|
subTreeLength: nextIndex - currentIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAndSort(
|
||||||
|
thread: AppBskyFeedGetPostThreadV2.OutputSchema['thread'],
|
||||||
|
{
|
||||||
|
hasSession,
|
||||||
|
options,
|
||||||
|
threadgateHiddenReplies,
|
||||||
|
moderationOpts,
|
||||||
|
showHiddenReplies = false,
|
||||||
|
}: {
|
||||||
|
hasSession: boolean
|
||||||
|
options: PostThreadV2Options
|
||||||
|
threadgateHiddenReplies: Set<string>
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
showHiddenReplies: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const sorted: Slice[] = []
|
||||||
|
const hidden: Slice[] = []
|
||||||
|
|
||||||
|
traversal: for (let i = 0; i < thread.length; i++) {
|
||||||
|
const item = thread[i]
|
||||||
|
|
||||||
|
// ignore unknowns
|
||||||
|
if (!('depth' in item)) continue
|
||||||
|
|
||||||
|
const oneUp = thread[i - 1]
|
||||||
|
const oneDown = thread[i + 1]
|
||||||
|
|
||||||
|
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 (AppBskyFeedDefs.isThreadItemNoUnauthenticated(item)) {
|
||||||
|
sorted.push(views.noUnauthenticated({item}))
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemNotFound(item)) {
|
||||||
|
sorted.push(views.notFound({item}))
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemBlocked(item)) {
|
||||||
|
sorted.push(views.blocked({item}))
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemPost(item)) {
|
||||||
|
sorted.push(
|
||||||
|
views.post({
|
||||||
|
item,
|
||||||
|
oneUp: oneUp,
|
||||||
|
oneDown: oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasSession) {
|
||||||
|
sorted.push({
|
||||||
|
type: 'replyComposer',
|
||||||
|
key: 'replyComposer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
parentTraversal: for (let pi = i - 1; pi >= i * -1; pi--) {
|
||||||
|
const parentOneDown = thread[pi + 1]
|
||||||
|
const parent = thread[pi]
|
||||||
|
const parentOneUp = thread[pi - 1]
|
||||||
|
|
||||||
|
if (AppBskyFeedDefs.isThreadItemNoUnauthenticated(parent)) {
|
||||||
|
sorted.unshift(views.noUnauthenticated({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemNotFound(parent)) {
|
||||||
|
sorted.unshift(views.notFound({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemBlocked(parent)) {
|
||||||
|
sorted.unshift(views.blocked({item: parent}))
|
||||||
|
break parentTraversal
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemPost(parent)) {
|
||||||
|
sorted.unshift(
|
||||||
|
views.post({
|
||||||
|
item,
|
||||||
|
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 =
|
||||||
|
AppBskyFeedDefs.isThreadItemNoUnauthenticated(item) ||
|
||||||
|
AppBskyFeedDefs.isThreadItemNotFound(item) ||
|
||||||
|
AppBskyFeedDefs.isThreadItemBlocked(item)
|
||||||
|
|
||||||
|
if (shouldBreak) {
|
||||||
|
const branch = getSubBranch(thread, i, item.depth)
|
||||||
|
// could insert tombstone
|
||||||
|
i = branch.end
|
||||||
|
continue traversal
|
||||||
|
} else if (AppBskyFeedDefs.isThreadItemPost(item)) {
|
||||||
|
const isHidden = threadgateHiddenReplies.has(item.uri)
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
const branch = getSubBranch(thread, i, item.depth)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Top-level replies we re-sort to bottom
|
||||||
|
*/
|
||||||
|
if (item.depth === 1) {
|
||||||
|
for (let ci = branch.start; ci <= branch.end; ci++) {
|
||||||
|
const next = thread[ci]
|
||||||
|
|
||||||
|
if (AppBskyFeedDefs.isThreadItemPost(next)) {
|
||||||
|
hidden.push(
|
||||||
|
views.post({
|
||||||
|
item: next,
|
||||||
|
oneUp: oneUp,
|
||||||
|
oneDown: oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (showHiddenReplies) {
|
||||||
|
/*
|
||||||
|
* Nested hidden replies either filter entirely or show in situ
|
||||||
|
*/
|
||||||
|
sorted.push(
|
||||||
|
views.post({
|
||||||
|
item,
|
||||||
|
oneUp,
|
||||||
|
oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Skip to next branch
|
||||||
|
*/
|
||||||
|
i = branch.end
|
||||||
|
continue traversal
|
||||||
|
} else {
|
||||||
|
/*
|
||||||
|
* Not hidden, so show it
|
||||||
|
*/
|
||||||
|
sorted.push(
|
||||||
|
views.post({
|
||||||
|
item,
|
||||||
|
oneUp,
|
||||||
|
oneDown,
|
||||||
|
moderationOpts,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showHiddenReplies) {
|
||||||
|
return sorted.concat(hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Slice =
|
export type Slice =
|
||||||
@@ -166,82 +460,6 @@ export type Slice =
|
|||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSlices(
|
|
||||||
thread: AppBskyFeedGetPostThreadV2.OutputSchema['thread'],
|
|
||||||
{
|
|
||||||
hasSession,
|
|
||||||
options,
|
|
||||||
threadgateHiddenReplies,
|
|
||||||
moderationOpts,
|
|
||||||
}: {
|
|
||||||
hasSession: boolean
|
|
||||||
options: PostThreadV2Options
|
|
||||||
threadgateHiddenReplies: Set<string>
|
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
},
|
|
||||||
): Slice[] {
|
|
||||||
const slices: Slice[] = []
|
|
||||||
|
|
||||||
for (let i = 0; i < thread.length; i++) {
|
|
||||||
const prev = thread[i - 1]
|
|
||||||
const slice = thread[i]
|
|
||||||
const next = thread[i + 1]
|
|
||||||
|
|
||||||
if (AppBskyFeedDefs.isThreadItemNoUnauthenticated(slice)) {
|
|
||||||
slices.push({
|
|
||||||
type: 'threadSliceNoUnauthenticated',
|
|
||||||
key: slice.uri,
|
|
||||||
slice,
|
|
||||||
})
|
|
||||||
} else if (AppBskyFeedDefs.isThreadItemNotFound(slice)) {
|
|
||||||
slices.push({
|
|
||||||
type: 'threadSliceNotFound',
|
|
||||||
key: slice.uri,
|
|
||||||
slice,
|
|
||||||
})
|
|
||||||
} else if (AppBskyFeedDefs.isThreadItemBlocked(slice)) {
|
|
||||||
slices.push({
|
|
||||||
type: 'threadSliceBlocked',
|
|
||||||
key: slice.uri,
|
|
||||||
slice,
|
|
||||||
})
|
|
||||||
} else if (AppBskyFeedDefs.isThreadItemPost(slice)) {
|
|
||||||
slices.push({
|
|
||||||
type: 'threadSlice',
|
|
||||||
key: slice.uri,
|
|
||||||
slice: {
|
|
||||||
...slice,
|
|
||||||
post: {
|
|
||||||
...slice.post,
|
|
||||||
record: slice.post.record as AppBskyFeedPost.Record,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
moderation: moderatePost(slice.post, moderationOpts),
|
|
||||||
ui: {
|
|
||||||
isAnchor: slice.depth === 0,
|
|
||||||
showParentReplyLine:
|
|
||||||
!!prev &&
|
|
||||||
AppBskyFeedDefs.isThreadItemPost(prev) &&
|
|
||||||
prev.depth < slice.depth,
|
|
||||||
showChildReplyLine:
|
|
||||||
!!next &&
|
|
||||||
AppBskyFeedDefs.isThreadItemPost(next) &&
|
|
||||||
next.depth > slice.depth,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (slice.depth === 0 && hasSession) {
|
|
||||||
slices.push({
|
|
||||||
type: 'replyComposer',
|
|
||||||
key: 'replyComposer',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return slices
|
|
||||||
}
|
|
||||||
|
|
||||||
function getThreadgate(
|
function getThreadgate(
|
||||||
view: AppBskyFeedGetPostThreadV2.OutputSchema['threadgate'],
|
view: AppBskyFeedGetPostThreadV2.OutputSchema['threadgate'],
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
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 anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor)
|
|
||||||
// TODO
|
// TODO
|
||||||
const [justPostedUris, setJustPostedUris] = useState(
|
const [justPostedUris, setJustPostedUris] = useState(
|
||||||
() => new Set<string>(),
|
() => new Set<string>(),
|
||||||
@@ -161,6 +160,7 @@ export function Inner({uri}: {uri: string | undefined}) {
|
|||||||
|
|
||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
const onReplyToAnchor = () => {
|
const onReplyToAnchor = () => {
|
||||||
|
const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor)
|
||||||
if (anchorPost?.type !== 'threadSlice') {
|
if (anchorPost?.type !== 'threadSlice') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user