Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions api/resolvers/comment-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,42 @@ async function fetchLimitedCommentRows ({ itemId, me, models, sortClause, decode
)
}

async function fetchPinnedCommentRows ({ itemId, me, models, itemQueryWithMeta, payInJoinFilter, select }) {
const query = `
WITH root AS (
SELECT path
FROM "Item"
WHERE id = $1
)
${select}
FROM "Item"
JOIN "Pin" ON "Item"."pinId" = "Pin".id
JOIN root ON true
${payInJoinFilter(me)}
WHERE "Item".id <> $1
AND "Item"."path" <@ root.path
AND "Item"."pinId" IS NOT NULL
ORDER BY "Item".id DESC
`

return await itemQueryWithMeta(
{ me, models, query, orderBy: 'ORDER BY "Item".id DESC' },
itemId
)
}

async function fetchComments ({ item, me, models, sortClause, decodedCursor, itemQueryWithMeta, payInJoinFilter, select }) {
const rows = item.ncomments > FULL_COMMENTS_THRESHOLD
? await fetchLimitedCommentRows({ itemId: Number(item.id), me, models, sortClause, decodedCursor, itemQueryWithMeta, payInJoinFilter, select })
: await fetchFullCommentRows({ itemId: Number(item.id), me, models, sortClause, decodedCursor, itemQueryWithMeta, payInJoinFilter, select })

return buildCommentTree(rows, { rootId: Number(item.id) })
// only fetch pins on root post page, not on thread view (comment view)
const pins = !item.parentId ? await fetchPinnedCommentRows({ itemId: Number(item.id), me, models, itemQueryWithMeta, payInJoinFilter, select }) : []

return {
comments: buildCommentTree(rows, { rootId: Number(item.id) }),
pins
}
}

export async function resolveItemComments (item, sort, cursor, { me, models, userLoader, itemQueryWithMeta, payInJoinFilter, select }) {
Expand All @@ -151,15 +181,17 @@ export async function resolveItemComments (item, sort, cursor, { me, models, use
if (!me && item.nDirectComments === 0) {
return {
comments: [],
pins: [],
cursor: null
}
}

const decodedCursor = decodeCursor(cursor)
const comments = await fetchComments({ item, me, models, sortClause, decodedCursor, itemQueryWithMeta, payInJoinFilter, select })
const { comments, pins } = await fetchComments({ item, me, models, sortClause, decodedCursor, itemQueryWithMeta, payInJoinFilter, select })

return {
comments,
pins,
cursor: comments.length + decodedCursor.offset < item.nDirectComments
? nextCursorEncoded(decodedCursor, COMMENTS_LIMIT)
: null
Expand Down
7 changes: 2 additions & 5 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -661,11 +661,6 @@ export default {
})

if (item.parentId) {
// OPs can only pin top level replies
if (item.parentId !== item.rootId) {
throw new GqlInputError('can only pin root replies')
}

if (item.root.userId !== Number(me.id)) {
throw new GqlInputError('not your post')
}
Expand Down Expand Up @@ -1172,6 +1167,7 @@ export default {
if (Array.isArray(item.comments)) {
return {
comments: item.comments,
pins: [],
cursor: null
}
}
Expand All @@ -1182,6 +1178,7 @@ export default {
if (!me && item.ncomments === 0) {
return {
comments: [],
pins: [],
cursor: null
}
}
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default gql`
type Comments {
cursor: String
comments: [Item!]!
pins: [Item!]
}

type ItemAct {
Expand Down
59 changes: 45 additions & 14 deletions components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,29 +137,38 @@ export default function Comment ({
}

useEffect(() => {
const comment = cache.readFragment({
id: `Item:${router.query.commentId}`,
fragment: gql`
fragment CommentPath on Item {
path
}`
})
const targetCommentId = Number(router.query.commentId)
const focusBehavior = router.query.focus === 'smooth' ? 'smooth' : 'instant'
const comment = targetCommentId
? cache.readFragment({
id: `Item:${targetCommentId}`,
fragment: gql`
fragment CommentPath on Item {
path
}`
})
: null
if (comment?.path.split('.').includes(item.id)) {
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}
setCollapse(window.localStorage.getItem(`commentCollapse:${item.id}`) || collapse)
if (Number(router.query.commentId) === Number(item.id)) {
if (targetCommentId === Number(item.id)) {
// HACK wait for other comments to uncollapse if they're collapsed
setTimeout(() => {
ref.current.scrollIntoView({ behavior: 'instant', block: 'start' })
if (!ref.current) return
ref.current.scrollIntoView({ behavior: focusBehavior, block: 'start' })
// make sure we can outline a comment again if it was already outlined before
ref.current.addEventListener('animationend', () => {
ref.current.classList.remove('outline-it')
}, { once: true })
ref.current.classList.add('outline-it')
const { commentId, focus, ...query } = router.query
if (commentId || focus) {
router.replace({ pathname: router.pathname, query }, undefined, { shallow: true, scroll: false })
}
}, 100)
}
}, [item.id, cache, router.query.commentId])
}, [item.id, cache, router.query.commentId, router.query.focus])

useEffect(() => {
// checking navigator because outlining should happen only on item pages
Expand Down Expand Up @@ -198,14 +207,17 @@ export default function Comment ({
}
}, [item.id, root.lastCommentAt, root.meCommentsViewedAt])

const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
const directRepliesCount = item.nDirectComments ?? 0
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && directRepliesCount > 0)
// Don't show OP badge when anon user comments on anon user posts
const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
? 'OP'
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== USER_ID.anon
? 'fwd'
: null
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
const isHoistedPin = Boolean(pin) && Number(item.parentId) !== Number(root.id)
const isSameRootPage = Number(router.query.id) === Number(root.id)

return (
<div
Expand Down Expand Up @@ -245,6 +257,25 @@ export default function Comment ({
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{isHoistedPin && (
<>
<span> \ </span>
<Link
href={`/items/${root.id}?commentId=${item.parentId}`}
as={`/items/${root.id}`}
className='text-reset fw-bold'
onClick={e => {
e.preventDefault()
router.push({
pathname: '/items/[id]',
query: { id: root.id, commentId: item.parentId, focus: 'smooth' }
}, undefined, { scroll: false, shallow: isSameRootPage })
}}
>
original context
</Link>
</>
)}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
Expand Down Expand Up @@ -310,10 +341,10 @@ export default function Comment ({
{!noComments && item.comments?.comments
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} />
{item.comments.comments.map((child) => (
<Comment depth={depth + 1} key={child.id} item={child} navigator={navigator} pin={!!child.position} />
))}
{item.comments.comments.length < item.nDirectComments && (
{item.comments.comments.length < directRepliesCount && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}>
<ViewMoreReplies item={item} />
</div>
Expand Down
66 changes: 66 additions & 0 deletions components/comments-pin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export function hoistNestedPins (comments, rootId) {
const hoisted = []
const directChildrenCount = (item) => {
if (typeof item.nDirectComments === 'number') return item.nDirectComments
return item.comments?.comments?.length ?? 0
}

const withCommentsShape = (item) => ({
...item,
comments: item.comments || { comments: [] }
})

function walk (nodes) {
return (nodes || []).map(node => {
const children = node.comments?.comments || []
const keptChildren = []
let removedPinnedChildren = 0
let adoptedChildren = 0
for (const child of children) {
const isPinnedNestedChild = Boolean(child.position) && child.parentId !== rootId
if (isPinnedNestedChild) {
removedPinnedChildren += 1
const adopted = child.comments?.comments || []
adoptedChildren += directChildrenCount(child)
hoisted.push({
...child,
nDirectComments: 0,
comments: {
...(child.comments || {}),
comments: []
}
})
const preservedAdopted = adopted.map(a => ({
...withCommentsShape(a),
nDirectComments: directChildrenCount(a)
}))
keptChildren.push(...preservedAdopted)
continue
}
keptChildren.push(child)
}
const walkedChildren = walk(keptChildren)
const nodeComments = node.comments || { comments: [] }
const adjustedNDirectComments = typeof node.nDirectComments === 'number'
? Math.max(0, node.nDirectComments - removedPinnedChildren + adoptedChildren)
: node.nDirectComments
const adjustedNComments = typeof node.ncomments === 'number'
? Math.max(0, node.ncomments - removedPinnedChildren)
: node.ncomments
const fixedWalkedChildren = walkedChildren.map(child => ({
...child,
nDirectComments: directChildrenCount(child)
}))
return {
...node,
nDirectComments: adjustedNDirectComments,
ncomments: adjustedNComments,
comments: {
...nodeComments,
comments: fixedWalkedChildren
}
}
})
}
return { comments: walk(comments || []), hoisted }
}
29 changes: 25 additions & 4 deletions components/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import MoreFooter from './more-footer'
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
import useLiveComments from './use-live-comments'
import { useCommentsNavigatorContext } from './use-comments-navigator'
import { hoistNestedPins } from './comments-pin'

export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, commentCost, commentBoost }) {
const router = useRouter()
Expand Down Expand Up @@ -66,7 +67,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm

export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, commentCost, commentBoost, comments, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
commentSats, commentCost, commentBoost, comments, commentsPins, commentsCursor, fetchMoreComments, ncomments, lastCommentAt, item, ...props
}) {
const router = useRouter()

Expand All @@ -76,8 +77,28 @@ export default function Comments ({
// new comments navigator, tracks new comments and provides navigation controls
const { navigator } = useCommentsNavigatorContext()

const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])

const rootId = Number(item?.root?.id || parentId)
const isRootThread = Number(parentId) === rootId
const { comments: displayComments, hoisted } = useMemo(() => {
if (!isRootThread) return { comments: comments || [], hoisted: [] }
return hoistNestedPins(comments, rootId)
}, [comments, isRootThread, rootId])
const pins = useMemo(
() => {
if (!isRootThread) return []
const fromApi = commentsPins || []
const fallback = [...displayComments.filter(({ position }) => Boolean(position)), ...hoisted]
const source = fromApi.length ? fromApi : fallback
return source
.reduce((acc, pin) => {
if (acc.some(({ id }) => Number(id) === Number(pin.id))) return acc
acc.push(pin)
return acc
}, [])
.sort((a, b) => (a.position - b.position) || (a.id - b.id))
},
[commentsPins, displayComments, hoisted, isRootThread]
)
return (
<>
{comments?.length > 0
Expand All @@ -101,7 +122,7 @@ export default function Comments ({
<Comment depth={1} item={item} navigator={navigator} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
{displayComments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} navigator={navigator} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
Expand Down
1 change: 1 addition & 0 deletions components/item-full.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props
pinned={item.position} bio={bio}
commentSats={item.commentSats} commentCost={item.commentCost} commentBoost={item.commentBoost}
ncomments={item.ncomments}
commentsPins={item.comments.pins}
comments={item.comments.comments}
commentsCursor={item.comments.cursor}
fetchMoreComments={fetchMoreComments}
Expand Down
5 changes: 2 additions & 3 deletions components/item-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,11 @@ export default function ItemInfo ({
}, [item])

// territory founders can pin any post in their territory
// and OPs can pin any root reply in their post
// and OPs can pin replies in their post
const isPost = !item.parentId
const mySub = (me && sub && Number(me.id) === sub.userId)
const myPost = (me && root && Number(me.id) === Number(root.user.id))
const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply)
const canPin = (isPost && mySub) || (myPost && !isPost)
const isPinnedPost = isPost && item.position && (pinnable || !item.subNames)
const isPinnedSubReply = !isPost && item.position && !item.subNames
const meSats = (me ? item.meSats : item.meAnonSats) || 0
Expand Down
Loading
Loading