comment pagination with limit/offset (#1824)
* basic query with limit/offset * roughly working increment * working limiting/pageable queries * denormalize direct comments + full comments below threshold * notifications in megathread + working nest view more buttons * fix empty comment footer * make comments nested resolver again * use time in cursor to avoid duplicates * squash migrations * do not need item.comments undefined checks
This commit is contained in:
parent
bd84b8bf88
commit
01b021a337
@ -235,7 +235,9 @@ export async function onPaid ({ invoice, id }, context) {
|
|||||||
SET ncomments = "Item".ncomments + 1,
|
SET ncomments = "Item".ncomments + 1,
|
||||||
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
|
"lastCommentAt" = GREATEST("Item"."lastCommentAt", comment.created_at),
|
||||||
"weightedComments" = "Item"."weightedComments" +
|
"weightedComments" = "Item"."weightedComments" +
|
||||||
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END
|
CASE WHEN comment."userId" = "Item"."userId" THEN 0 ELSE comment.trust END,
|
||||||
|
"nDirectComments" = "Item"."nDirectComments" +
|
||||||
|
CASE WHEN comment."parentId" = "Item".id THEN 1 ELSE 0 END
|
||||||
FROM comment
|
FROM comment
|
||||||
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
WHERE "Item".path @> comment.path AND "Item".id <> comment.id
|
||||||
RETURNING "Item".*
|
RETURNING "Item".*
|
||||||
|
@ -9,7 +9,10 @@ import {
|
|||||||
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
|
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
|
||||||
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
|
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
|
||||||
BOOST_MULT,
|
BOOST_MULT,
|
||||||
ITEM_EDIT_SECONDS
|
ITEM_EDIT_SECONDS,
|
||||||
|
COMMENTS_LIMIT,
|
||||||
|
COMMENTS_OF_COMMENT_LIMIT,
|
||||||
|
FULL_COMMENTS_THRESHOLD
|
||||||
} from '@/lib/constants'
|
} from '@/lib/constants'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
import { parse } from 'tldts'
|
import { parse } from 'tldts'
|
||||||
@ -25,39 +28,76 @@ import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
|||||||
import { verifyHmac } from './wallet'
|
import { verifyHmac } from './wallet'
|
||||||
|
|
||||||
function commentsOrderByClause (me, models, sort) {
|
function commentsOrderByClause (me, models, sort) {
|
||||||
|
const sharedSortsArray = []
|
||||||
|
sharedSortsArray.push('("Item"."pinId" IS NOT NULL) DESC')
|
||||||
|
sharedSortsArray.push('("Item"."deletedAt" IS NULL) DESC')
|
||||||
|
const sharedSorts = sharedSortsArray.join(', ')
|
||||||
|
|
||||||
if (sort === 'recent') {
|
if (sort === 'recent') {
|
||||||
return 'ORDER BY ("Item"."deletedAt" IS NULL) DESC, ("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC, COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC'
|
return `ORDER BY ${sharedSorts},
|
||||||
|
("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC,
|
||||||
|
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (me && sort === 'hot') {
|
if (me && sort === 'hot') {
|
||||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, COALESCE(
|
return `ORDER BY ${sharedSorts},
|
||||||
personal_hot_score,
|
"personal_hot_score" DESC NULLS LAST,
|
||||||
${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
|
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||||
"Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
|
||||||
} else {
|
} else {
|
||||||
if (sort === 'top') {
|
if (sort === 'top') {
|
||||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0 })} DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||||
} else {
|
} else {
|
||||||
return `ORDER BY ("Item"."deletedAt" IS NULL) DESC, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
return `ORDER BY ${sharedSorts}, ${orderByNumerator({ models, commentScaler: 0, considerBoost: true })}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".cost > 0) DESC, "Item".id DESC`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function comments (me, models, id, sort) {
|
async function comments (me, models, item, sort, cursor) {
|
||||||
const orderBy = commentsOrderByClause(me, models, sort)
|
const orderBy = commentsOrderByClause(me, models, sort)
|
||||||
|
|
||||||
if (me) {
|
if (item.nDirectComments === 0) {
|
||||||
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) `
|
return {
|
||||||
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
|
comments: [],
|
||||||
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
|
cursor: null
|
||||||
Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
}
|
||||||
return comments
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = ' AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = \'PAID\') '
|
const decodedCursor = decodeCursor(cursor)
|
||||||
const [{ item_comments: comments }] = await models.$queryRawUnsafe(
|
const offset = decodedCursor.offset
|
||||||
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
|
||||||
return comments
|
// XXX what a mess
|
||||||
|
let comments
|
||||||
|
if (me) {
|
||||||
|
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID' OR "Item"."userId" = ${me.id}) AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
|
||||||
|
if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
|
||||||
|
const [{ item_comments_zaprank_with_me_limited: limitedComments }] = await models.$queryRawUnsafe(
|
||||||
|
'SELECT item_comments_zaprank_with_me_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6::INTEGER, $7::INTEGER, $8, $9)',
|
||||||
|
Number(item.id), GLOBAL_SEED, Number(me.id), COMMENTS_LIMIT, offset, COMMENTS_OF_COMMENT_LIMIT, COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||||
|
comments = limitedComments
|
||||||
|
} else {
|
||||||
|
const [{ item_comments_zaprank_with_me: fullComments }] = await models.$queryRawUnsafe(
|
||||||
|
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)',
|
||||||
|
Number(item.id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||||
|
comments = fullComments
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const filter = ` AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID') AND "Item".created_at <= '${decodedCursor.time.toISOString()}'::TIMESTAMP(3) `
|
||||||
|
if (item.ncomments > FULL_COMMENTS_THRESHOLD) {
|
||||||
|
const [{ item_comments_limited: limitedComments }] = await models.$queryRawUnsafe(
|
||||||
|
'SELECT item_comments_limited($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5::INTEGER, $6, $7)',
|
||||||
|
Number(item.id), COMMENTS_LIMIT, offset, COMMENTS_OF_COMMENT_LIMIT, COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||||
|
comments = limitedComments
|
||||||
|
} else {
|
||||||
|
const [{ item_comments: fullComments }] = await models.$queryRawUnsafe(
|
||||||
|
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(item.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
|
||||||
|
comments = fullComments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments,
|
||||||
|
cursor: comments.length + offset < item.nDirectComments ? nextCursorEncoded(decodedCursor, COMMENTS_LIMIT) : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getItem (parent, { id }, { me, models }) {
|
export async function getItem (parent, { id }, { me, models }) {
|
||||||
@ -1173,11 +1213,25 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
comments: async (item, { sort }, { me, models }) => {
|
comments: async (item, { sort, cursor }, { me, models }) => {
|
||||||
if (typeof item.comments !== 'undefined') return item.comments
|
if (typeof item.comments !== 'undefined') {
|
||||||
if (item.ncomments === 0) return []
|
if (Array.isArray(item.comments)) {
|
||||||
|
return {
|
||||||
|
comments: item.comments,
|
||||||
|
cursor: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item.comments
|
||||||
|
}
|
||||||
|
|
||||||
return comments(me, models, item.id, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt))
|
if (item.ncomments === 0) {
|
||||||
|
return {
|
||||||
|
comments: [],
|
||||||
|
cursor: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments(me, models, item, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt), cursor)
|
||||||
},
|
},
|
||||||
freedFreebie: async (item) => {
|
freedFreebie: async (item) => {
|
||||||
return item.weightedVotes - item.weightedDownVotes > 0
|
return item.weightedVotes - item.weightedDownVotes > 0
|
||||||
|
@ -145,7 +145,8 @@ export default gql`
|
|||||||
bio: Boolean!
|
bio: Boolean!
|
||||||
paidImgLink: Boolean
|
paidImgLink: Boolean
|
||||||
ncomments: Int!
|
ncomments: Int!
|
||||||
comments(sort: String): [Item!]!
|
nDirectComments: Int!
|
||||||
|
comments(sort: String, cursor: String): Comments!
|
||||||
path: String
|
path: String
|
||||||
position: Int
|
position: Int
|
||||||
prior: Int
|
prior: Int
|
||||||
|
@ -254,11 +254,17 @@ export default function Comment ({
|
|||||||
</Reply>}
|
</Reply>}
|
||||||
{children}
|
{children}
|
||||||
<div className={styles.comments}>
|
<div className={styles.comments}>
|
||||||
{item.comments && !noComments
|
{item.comments.comments && !noComments
|
||||||
? item.comments.map((item) => (
|
? (
|
||||||
<Comment depth={depth + 1} key={item.id} item={item} />
|
<>
|
||||||
))
|
{item.comments.comments.map((item) => (
|
||||||
|
<Comment depth={depth + 1} key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
{item.comments.comments.length < item.nDirectComments && <ViewAllReplies id={item.id} nshown={item.comments.comments.length} nhas={item.nDirectComments} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
: null}
|
: null}
|
||||||
|
{/* TODO: add link to more comments if they're limited */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -267,6 +273,18 @@ export default function Comment ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ViewAllReplies ({ id, nshown, nhas }) {
|
||||||
|
const text = `view all ${nhas} replies`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3`}>
|
||||||
|
<Link href={`/items/${id}`} as={`/items/${id}`} className='text-muted'>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function CommentSkeleton ({ skeletonChildren }) {
|
export function CommentSkeleton ({ skeletonChildren }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.comment}>
|
<div className={styles.comment}>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Fragment } from 'react'
|
import { Fragment, useMemo } from 'react'
|
||||||
import Comment, { CommentSkeleton } from './comment'
|
import Comment, { CommentSkeleton } from './comment'
|
||||||
import styles from './header.module.css'
|
import styles from './header.module.css'
|
||||||
import Nav from 'react-bootstrap/Nav'
|
import Nav from 'react-bootstrap/Nav'
|
||||||
@ -6,6 +6,8 @@ import Navbar from 'react-bootstrap/Navbar'
|
|||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { defaultCommentSort } from '@/lib/item'
|
import { defaultCommentSort } from '@/lib/item'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import MoreFooter from './more-footer'
|
||||||
|
import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants'
|
||||||
|
|
||||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -60,10 +62,13 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
|
export default function Comments ({
|
||||||
|
parentId, pinned, bio, parentCreatedAt,
|
||||||
|
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
|
||||||
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
|
const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -91,6 +96,12 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
|
|||||||
{comments.filter(({ position }) => !position).map(item => (
|
{comments.filter(({ position }) => !position).map(item => (
|
||||||
<Comment depth={1} key={item.id} item={item} {...props} />
|
<Comment depth={1} key={item.id} item={item} {...props} />
|
||||||
))}
|
))}
|
||||||
|
{ncomments > FULL_COMMENTS_THRESHOLD &&
|
||||||
|
<MoreFooter
|
||||||
|
cursor={commentsCursor} fetchMore={fetchMoreComments} noMoreText=' '
|
||||||
|
count={comments?.length}
|
||||||
|
Skeleton={CommentsSkeleton}
|
||||||
|
/>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -160,7 +160,7 @@ function ItemText ({ item }) {
|
|||||||
: <Text itemId={item.id} topLevel rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>{item.text}</Text>
|
: <Text itemId={item.id} topLevel rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>{item.text}</Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ItemFull ({ item, bio, rank, ...props }) {
|
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
commentsViewed(item)
|
commentsViewed(item)
|
||||||
}, [item.lastCommentAt])
|
}, [item.lastCommentAt])
|
||||||
@ -186,7 +186,11 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
|
|||||||
<div className={styles.comments}>
|
<div className={styles.comments}>
|
||||||
<Comments
|
<Comments
|
||||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
pinned={item.position} bio={bio} commentSats={item.commentSats}
|
||||||
|
ncomments={item.ncomments}
|
||||||
|
comments={item.comments.comments}
|
||||||
|
commentsCursor={item.comments.cursor}
|
||||||
|
fetchMoreComments={fetchMoreComments}
|
||||||
/>
|
/>
|
||||||
</div>}
|
</div>}
|
||||||
</CarouselProvider>
|
</CarouselProvider>
|
||||||
|
@ -55,9 +55,9 @@ export default forwardRef(function Reply ({
|
|||||||
|
|
||||||
const placeholder = useMemo(() => {
|
const placeholder = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
'comment for currency?',
|
'comment for currency',
|
||||||
'fractions of a penny for your thoughts?',
|
'fractions of a penny for your thoughts?',
|
||||||
'put your money where your mouth is?'
|
'put your money where your mouth is'
|
||||||
][parentId % 3]
|
][parentId % 3]
|
||||||
}, [parentId])
|
}, [parentId])
|
||||||
|
|
||||||
@ -70,13 +70,16 @@ export default forwardRef(function Reply ({
|
|||||||
cache.modify({
|
cache.modify({
|
||||||
id: `Item:${parentId}`,
|
id: `Item:${parentId}`,
|
||||||
fields: {
|
fields: {
|
||||||
comments (existingCommentRefs = []) {
|
comments (existingComments = {}) {
|
||||||
const newCommentRef = cache.writeFragment({
|
const newCommentRef = cache.writeFragment({
|
||||||
data: result,
|
data: result,
|
||||||
fragment: COMMENTS,
|
fragment: COMMENTS,
|
||||||
fragmentName: 'CommentsRecursive'
|
fragmentName: 'CommentsRecursive'
|
||||||
})
|
})
|
||||||
return [newCommentRef, ...existingCommentRefs]
|
return {
|
||||||
|
cursor: existingComments.cursor,
|
||||||
|
comments: [newCommentRef, ...(existingComments?.comments || [])]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
optimistic: true
|
optimistic: true
|
||||||
|
@ -46,6 +46,7 @@ export const COMMENT_FIELDS = gql`
|
|||||||
mine
|
mine
|
||||||
otsHash
|
otsHash
|
||||||
ncomments
|
ncomments
|
||||||
|
nDirectComments
|
||||||
imgproxyUrls
|
imgproxyUrls
|
||||||
rel
|
rel
|
||||||
apiKey
|
apiKey
|
||||||
@ -66,6 +67,7 @@ export const COMMENTS_ITEM_EXT_FIELDS = gql`
|
|||||||
id
|
id
|
||||||
title
|
title
|
||||||
bounty
|
bounty
|
||||||
|
ncomments
|
||||||
bountyPaidTo
|
bountyPaidTo
|
||||||
subName
|
subName
|
||||||
sub {
|
sub {
|
||||||
@ -89,19 +91,23 @@ export const COMMENTS = gql`
|
|||||||
fragment CommentsRecursive on Item {
|
fragment CommentsRecursive on Item {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentFields
|
comments {
|
||||||
|
...CommentFields
|
||||||
|
comments {
|
||||||
|
comments {
|
||||||
|
...CommentFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,7 @@ export const ITEM_FIELDS = gql`
|
|||||||
freebie
|
freebie
|
||||||
bio
|
bio
|
||||||
ncomments
|
ncomments
|
||||||
|
nDirectComments
|
||||||
commentSats
|
commentSats
|
||||||
commentCredits
|
commentCredits
|
||||||
lastCommentAt
|
lastCommentAt
|
||||||
@ -94,6 +95,7 @@ export const ITEM_FULL_FIELDS = gql`
|
|||||||
bountyPaidTo
|
bountyPaidTo
|
||||||
subName
|
subName
|
||||||
mine
|
mine
|
||||||
|
ncomments
|
||||||
user {
|
user {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@ -166,13 +168,16 @@ export const ITEM_FULL = gql`
|
|||||||
${ITEM_FULL_FIELDS}
|
${ITEM_FULL_FIELDS}
|
||||||
${POLL_FIELDS}
|
${POLL_FIELDS}
|
||||||
${COMMENTS}
|
${COMMENTS}
|
||||||
query Item($id: ID!, $sort: String) {
|
query Item($id: ID!, $sort: String, $cursor: String) {
|
||||||
item(id: $id) {
|
item(id: $id) {
|
||||||
...ItemFullFields
|
...ItemFullFields
|
||||||
prior
|
prior
|
||||||
...PollFields
|
...PollFields
|
||||||
comments(sort: $sort) {
|
comments(sort: $sort, cursor: $cursor) {
|
||||||
...CommentsRecursive
|
cursor
|
||||||
|
comments {
|
||||||
|
...CommentsRecursive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@ -25,7 +25,9 @@ const ITEM_PAID_ACTION_FIELDS = gql`
|
|||||||
reminderScheduledAt
|
reminderScheduledAt
|
||||||
...CommentFields
|
...CommentFields
|
||||||
comments {
|
comments {
|
||||||
...CommentsRecursive
|
comments {
|
||||||
|
...CommentsRecursive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@ -297,17 +297,20 @@ export const USER_FULL = gql`
|
|||||||
${USER_FIELDS}
|
${USER_FIELDS}
|
||||||
${ITEM_FULL_FIELDS}
|
${ITEM_FULL_FIELDS}
|
||||||
${COMMENTS}
|
${COMMENTS}
|
||||||
query User($name: String!, $sort: String) {
|
query User($name: String!, $sort: String, $cursor: String) {
|
||||||
user(name: $name) {
|
user(name: $name) {
|
||||||
...UserFields
|
...UserFields
|
||||||
bio {
|
bio {
|
||||||
...ItemFullFields
|
...ItemFullFields
|
||||||
comments(sort: $sort) {
|
comments(sort: $sort, cursor: $cursor) {
|
||||||
...CommentsRecursive
|
cursor
|
||||||
|
comments {
|
||||||
|
...CommentsRecursive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
export const USER = gql`
|
export const USER = gql`
|
||||||
${USER_FIELDS}
|
${USER_FIELDS}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ApolloClient, InMemoryCache, HttpLink, makeVar, split } from '@apollo/client'
|
import { ApolloClient, InMemoryCache, HttpLink, makeVar, split } from '@apollo/client'
|
||||||
import { BatchHttpLink } from '@apollo/client/link/batch-http'
|
import { BatchHttpLink } from '@apollo/client/link/batch-http'
|
||||||
import { decodeCursor, LIMIT } from './cursor'
|
import { decodeCursor, LIMIT } from './cursor'
|
||||||
import { SSR } from './constants'
|
import { COMMENTS_LIMIT, SSR } from './constants'
|
||||||
|
|
||||||
function isFirstPage (cursor, existingThings, limit = LIMIT) {
|
function isFirstPage (cursor, existingThings, limit = LIMIT) {
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@ -201,12 +201,6 @@ function getClient (uri) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
comments: {
|
|
||||||
keyArgs: ['id', 'sort'],
|
|
||||||
merge (existing, incoming) {
|
|
||||||
return incoming
|
|
||||||
}
|
|
||||||
},
|
|
||||||
related: {
|
related: {
|
||||||
keyArgs: ['id', 'title', 'minMatch', 'limit'],
|
keyArgs: ['id', 'title', 'minMatch', 'limit'],
|
||||||
merge (existing, incoming, { args }) {
|
merge (existing, incoming, { args }) {
|
||||||
@ -277,6 +271,19 @@ function getClient (uri) {
|
|||||||
},
|
},
|
||||||
Item: {
|
Item: {
|
||||||
fields: {
|
fields: {
|
||||||
|
comments: {
|
||||||
|
keyArgs: ['sort'],
|
||||||
|
merge (existing, incoming) {
|
||||||
|
if (isFirstPage(incoming.cursor, existing?.comments, COMMENTS_LIMIT)) {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: incoming.cursor,
|
||||||
|
comments: [...(existing?.comments || []), ...incoming.comments]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
meAnonSats: {
|
meAnonSats: {
|
||||||
read (existingAmount, { readField }) {
|
read (existingAmount, { readField }) {
|
||||||
if (SSR) return null
|
if (SSR) return null
|
||||||
|
@ -40,7 +40,10 @@ export const BOUNTY_MAX = 10000000
|
|||||||
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
|
export const POST_TYPES = ['LINK', 'DISCUSSION', 'BOUNTY', 'POLL']
|
||||||
export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
|
export const TERRITORY_BILLING_TYPES = ['MONTHLY', 'YEARLY', 'ONCE']
|
||||||
export const TERRITORY_GRACE_DAYS = 5
|
export const TERRITORY_GRACE_DAYS = 5
|
||||||
export const COMMENT_DEPTH_LIMIT = 8
|
export const COMMENT_DEPTH_LIMIT = 6
|
||||||
|
export const COMMENTS_LIMIT = 50
|
||||||
|
export const FULL_COMMENTS_THRESHOLD = 200
|
||||||
|
export const COMMENTS_OF_COMMENT_LIMIT = 2
|
||||||
export const MAX_TITLE_LENGTH = 80
|
export const MAX_TITLE_LENGTH = 80
|
||||||
export const MIN_TITLE_LENGTH = 5
|
export const MIN_TITLE_LENGTH = 5
|
||||||
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
|
export const MAX_POST_TEXT_LENGTH = 100000 // 100k
|
||||||
|
@ -5,6 +5,7 @@ export function decodeCursor (cursor) {
|
|||||||
return { offset: 0, time: new Date() }
|
return { offset: 0, time: new Date() }
|
||||||
} else {
|
} else {
|
||||||
const res = JSON.parse(Buffer.from(cursor, 'base64'))
|
const res = JSON.parse(Buffer.from(cursor, 'base64'))
|
||||||
|
res.offset = Number(res.offset)
|
||||||
res.time = new Date(res.time)
|
res.time = new Date(res.time)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { COMMENT_DEPTH_LIMIT, OLD_ITEM_DAYS } from './constants'
|
import { COMMENT_DEPTH_LIMIT, FULL_COMMENTS_THRESHOLD, OLD_ITEM_DAYS } from './constants'
|
||||||
import { datePivot } from './time'
|
import { datePivot } from './time'
|
||||||
|
|
||||||
export const defaultCommentSort = (pinned, bio, createdAt) => {
|
export const defaultCommentSort = (pinned, bio, createdAt) => {
|
||||||
@ -106,6 +106,10 @@ export const deleteReminders = async ({ id, userId, models }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const commentSubTreeRootId = (item) => {
|
export const commentSubTreeRootId = (item) => {
|
||||||
|
if (item.root?.ncomments > FULL_COMMENTS_THRESHOLD) {
|
||||||
|
return item.id
|
||||||
|
}
|
||||||
|
|
||||||
const path = item.path.split('.')
|
const path = item.path.split('.')
|
||||||
return path.slice(-(COMMENT_DEPTH_LIMIT - 1))[0]
|
return path.slice(-(COMMENT_DEPTH_LIMIT - 1))[0]
|
||||||
}
|
}
|
||||||
|
@ -14,15 +14,19 @@ export const getServerSideProps = getGetServerSideProps({
|
|||||||
export default function Item ({ ssrData }) {
|
export default function Item ({ ssrData }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { data } = useQuery(ITEM_FULL, { variables: { ...router.query } })
|
const { data, fetchMore } = useQuery(ITEM_FULL, { variables: { ...router.query } })
|
||||||
if (!data && !ssrData) return <PageLoading />
|
if (!data && !ssrData) return <PageLoading />
|
||||||
|
|
||||||
const { item } = data || ssrData
|
const { item } = data || ssrData
|
||||||
const sub = item.subName || item.root?.subName
|
const sub = item.subName || item.root?.subName
|
||||||
|
|
||||||
|
const fetchMoreComments = async () => {
|
||||||
|
await fetchMore({ variables: { ...router.query, cursor: item.comments.cursor } })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout sub={sub} item={item}>
|
<Layout sub={sub} item={item}>
|
||||||
<ItemFull item={item} />
|
<ItemFull item={item} fetchMoreComments={fetchMoreComments} />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
147
prisma/migrations/20250118010433_comment_pages/migration.sql
Normal file
147
prisma/migrations/20250118010433_comment_pages/migration.sql
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "nDirectComments" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Update nDirectComments
|
||||||
|
UPDATE "Item"
|
||||||
|
SET "nDirectComments" = "DirectComments"."nDirectComments"
|
||||||
|
FROM (
|
||||||
|
SELECT "Item"."parentId" AS "id", COUNT(*) AS "nDirectComments"
|
||||||
|
FROM "Item"
|
||||||
|
WHERE "Item"."parentId" IS NOT NULL
|
||||||
|
GROUP BY "Item"."parentId"
|
||||||
|
) AS "DirectComments"
|
||||||
|
WHERE "Item"."id" = "DirectComments"."id";
|
||||||
|
|
||||||
|
-- add limit and offset
|
||||||
|
CREATE OR REPLACE FUNCTION item_comments_zaprank_with_me_limited(
|
||||||
|
_item_id int, _global_seed int, _me_id int, _limit int, _offset int, _grandchild_limit int,
|
||||||
|
_level int, _where text, _order_by text)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
result jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF _level < 1 THEN
|
||||||
|
RETURN '[]'::jsonb;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||||
|
|| 'WITH RECURSIVE base AS ( '
|
||||||
|
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn, '
|
||||||
|
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, '
|
||||||
|
|| ' GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|
||||||
|
|| ' FROM "Item" '
|
||||||
|
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $2 AND g.id = "Item".id '
|
||||||
|
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $3 AND l.id = g.id '
|
||||||
|
|| ' WHERE "Item"."parentId" = $1 '
|
||||||
|
|| _order_by || ' '
|
||||||
|
|| ' LIMIT $4 '
|
||||||
|
|| ' OFFSET $5) '
|
||||||
|
|| ' UNION ALL '
|
||||||
|
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') as rn, '
|
||||||
|
|| ' GREATEST(g.tf_hot_score, l.tf_hot_score) AS personal_hot_score, '
|
||||||
|
|| ' GREATEST(g.tf_top_score, l.tf_top_score) AS personal_top_score '
|
||||||
|
|| ' FROM "Item" '
|
||||||
|
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||||
|
|| ' LEFT JOIN zap_rank_personal_view g ON g."viewerId" = $2 AND g.id = "Item".id '
|
||||||
|
|| ' LEFT JOIN zap_rank_personal_view l ON l."viewerId" = $3 AND l.id = g.id '
|
||||||
|
|| ' WHERE b.level < $7 AND (b.level = 1 OR b.rn <= $6)) '
|
||||||
|
|| ') '
|
||||||
|
|| 'SELECT "Item".*, '
|
||||||
|
|| ' "Item".created_at at time zone ''UTC'' AS "createdAt", '
|
||||||
|
|| ' "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||||
|
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||||
|
|| ' to_jsonb(users.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) AS user, '
|
||||||
|
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", '
|
||||||
|
|| ' COALESCE("ItemAct"."mePendingMsats", 0) as "mePendingMsats", '
|
||||||
|
|| ' COALESCE("ItemAct"."meDontLikeMsats", 0) AS "meDontLikeMsats", '
|
||||||
|
|| ' COALESCE("ItemAct"."meMcredits", 0) AS "meMcredits", '
|
||||||
|
|| ' COALESCE("ItemAct"."mePendingMcredits", 0) as "mePendingMcredits", '
|
||||||
|
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", '
|
||||||
|
|| ' "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription" '
|
||||||
|
|| 'FROM base "Item" '
|
||||||
|
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||||
|
|| ' LEFT JOIN "Mute" ON "Mute"."muterId" = $3 AND "Mute"."mutedId" = "Item"."userId" '
|
||||||
|
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."userId" = $3 AND "Bookmark"."itemId" = "Item".id '
|
||||||
|
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."userId" = $3 AND "ThreadSubscription"."itemId" = "Item".id '
|
||||||
|
|| 'LEFT JOIN LATERAL ( '
|
||||||
|
|| ' SELECT "itemId", '
|
||||||
|
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMsats", '
|
||||||
|
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "meMcredits", '
|
||||||
|
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NOT NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMsats", '
|
||||||
|
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS NOT DISTINCT FROM ''PENDING'' AND "InvoiceForward".id IS NULL AND (act = ''FEE'' OR act = ''TIP'')) AS "mePendingMcredits", '
|
||||||
|
|| ' sum("ItemAct".msats) FILTER (WHERE "invoiceActionState" IS DISTINCT FROM ''FAILED'' AND act = ''DONT_LIKE_THIS'') AS "meDontLikeMsats" '
|
||||||
|
|| ' FROM "ItemAct" '
|
||||||
|
|| ' LEFT JOIN "Invoice" ON "Invoice".id = "ItemAct"."invoiceId" '
|
||||||
|
|| ' LEFT JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Invoice"."id" '
|
||||||
|
|| ' WHERE "ItemAct"."userId" = $3 '
|
||||||
|
|| ' AND "ItemAct"."itemId" = "Item".id '
|
||||||
|
|| ' GROUP BY "ItemAct"."itemId" '
|
||||||
|
|| ') "ItemAct" ON true '
|
||||||
|
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $6 - "Item".level + 2) ' || _where || ' '
|
||||||
|
USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||||
|
|
||||||
|
EXECUTE ''
|
||||||
|
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||||
|
|| 'FROM ( '
|
||||||
|
|| ' SELECT "Item".*, item_comments_zaprank_with_me("Item".id, $2, $3, $4, $5, $6, $7 - 1, $8, $9) AS comments '
|
||||||
|
|| ' FROM t_item "Item" '
|
||||||
|
|| ' WHERE "Item"."parentId" = $1 '
|
||||||
|
|| _order_by
|
||||||
|
|| ' ) sub'
|
||||||
|
INTO result USING _item_id, _global_seed, _me_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- add limit and offset
|
||||||
|
CREATE OR REPLACE FUNCTION item_comments_limited(
|
||||||
|
_item_id int, _limit int, _offset int, _grandchild_limit int,
|
||||||
|
_level int, _where text, _order_by text)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql VOLATILE PARALLEL SAFE AS
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
result jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF _level < 1 THEN
|
||||||
|
RETURN '[]'::jsonb;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
EXECUTE 'CREATE TEMP TABLE IF NOT EXISTS t_item ON COMMIT DROP AS '
|
||||||
|
|| 'WITH RECURSIVE base AS ( '
|
||||||
|
|| ' (SELECT "Item".*, 1 as level, ROW_NUMBER() OVER () as rn '
|
||||||
|
|| ' FROM "Item" '
|
||||||
|
|| ' WHERE "Item"."parentId" = $1 '
|
||||||
|
|| _order_by || ' '
|
||||||
|
|| ' LIMIT $2 '
|
||||||
|
|| ' OFFSET $3) '
|
||||||
|
|| ' UNION ALL '
|
||||||
|
|| ' (SELECT "Item".*, b.level + 1, ROW_NUMBER() OVER (PARTITION BY "Item"."parentId" ' || _order_by || ') '
|
||||||
|
|| ' FROM "Item" '
|
||||||
|
|| ' JOIN base b ON "Item"."parentId" = b.id '
|
||||||
|
|| ' WHERE b.level < $5 AND (b.level = 1 OR b.rn <= $4)) '
|
||||||
|
|| ') '
|
||||||
|
|| 'SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|
||||||
|
|| ' "Item"."invoicePaidAt" at time zone ''UTC'' AS "invoicePaidAtUTC", '
|
||||||
|
|| ' to_jsonb(users.*) as user '
|
||||||
|
|| 'FROM base "Item" '
|
||||||
|
|| 'JOIN users ON users.id = "Item"."userId" '
|
||||||
|
|| 'WHERE ("Item".level = 1 OR "Item".rn <= $4) ' || _where
|
||||||
|
USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||||
|
|
||||||
|
|
||||||
|
EXECUTE ''
|
||||||
|
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|
||||||
|
|| 'FROM ( '
|
||||||
|
|| ' SELECT "Item".*, item_comments("Item".id, $2, $3, $4, $5 - 1, $6, $7) AS comments '
|
||||||
|
|| ' FROM t_item "Item" '
|
||||||
|
|| ' WHERE "Item"."parentId" = $1 '
|
||||||
|
|| _order_by
|
||||||
|
|| ' ) sub'
|
||||||
|
INTO result USING _item_id, _limit, _offset, _grandchild_limit, _level, _where, _order_by;
|
||||||
|
RETURN result;
|
||||||
|
END
|
||||||
|
$$;
|
@ -528,6 +528,7 @@ model Item {
|
|||||||
lastCommentAt DateTime?
|
lastCommentAt DateTime?
|
||||||
lastZapAt DateTime?
|
lastZapAt DateTime?
|
||||||
ncomments Int @default(0)
|
ncomments Int @default(0)
|
||||||
|
nDirectComments Int @default(0)
|
||||||
msats BigInt @default(0)
|
msats BigInt @default(0)
|
||||||
mcredits BigInt @default(0)
|
mcredits BigInt @default(0)
|
||||||
cost Int @default(0)
|
cost Int @default(0)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user