Server-side tracking of comments view time (#2432)

* server-side comments view tracking, model structure, mutation

* full commentsViewedAt refactor, adjust comment creation and injection, adjust item navigation

* update server-side tracking only if there's a change, light cleanup

* coalesce meCommentsViewedAt to the item's createdAt, wip PoC comment outlining

* don't update cache on item visit, use useRoot hook for outlining

* add meCommentsViewedAt to root, better naming, light cleanup

* better timestamp logic and comparisons, add lastCommentAt to root item object, added TODOs

* fix: track commentsViewedAt only for root item, use topLevelId to fetch live comments only for the current item

* only track commentsViewedAt for root item, light cleanup

* light cleanup, correct live comments timestamp deps

* worker: on midnight, untrack items that were never viewed and had no comments in the last 21 days
This commit is contained in:
soxa 2025-09-02 20:13:44 +02:00 committed by GitHub
parent b0f01c1dd4
commit b5af28c48b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 225 additions and 44 deletions

View File

@ -180,7 +180,8 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL)
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub
|| jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub,
COALESCE("CommentsViewAt"."last_viewed_at", "Item"."created_at") as "meCommentsViewedAt"
FROM (
${query}
) "Item"
@ -192,6 +193,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id}
LEFT JOIN "CommentsViewAt" ON "CommentsViewAt"."itemId" = "Item".id AND "CommentsViewAt"."userId" = ${me.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",
@ -741,7 +743,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0
}
},
newComments: async (parent, { rootId, after }, { models, me }) => {
newComments: async (parent, { topLevelId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({
me,
models,
@ -755,7 +757,7 @@ export default {
'"Item"."created_at" > $2'
)}
ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after)
}, Number(topLevelId), after)
return { comments }
}
@ -1073,6 +1075,21 @@ export default {
])
return result
},
updateCommentsViewAt: async (parent, { id, meCommentsViewedAt }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const result = await models.commentsViewAt.upsert({
where: {
userId_itemId: { userId: Number(me.id), itemId: Number(id) }
},
update: { lastViewedAt: new Date(meCommentsViewedAt) },
create: { userId: Number(me.id), itemId: Number(id), lastViewedAt: new Date(meCommentsViewedAt) }
})
return result.lastViewedAt
}
},
ItemAct: {

View File

@ -12,7 +12,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
newComments(rootId: ID, after: Date): Comments!
newComments(topLevelId: ID, after: Date): Comments!
}
type BoostPositions {
@ -65,6 +65,7 @@ export default gql`
act(id: ID!, sats: Int, act: String, hasSendWallet: Boolean): ItemActPaidAction!
pollVote(id: ID!): PollVotePaidAction!
toggleOutlaw(id: ID!): Item!
updateCommentsViewAt(id: ID!, meCommentsViewedAt: Date!): Date
}
type PollVoteResult {
@ -173,6 +174,7 @@ export default gql`
apiKey: Boolean
invoice: Invoice
cost: Int!
meCommentsViewedAt: Date
}
input ItemForwardInput {

View File

@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
}
export default function Comment ({
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt,
item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry,
navigator
}) {
@ -157,13 +157,19 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId])
useEffect(() => {
if (me?.id === item.user?.id) return
// checking navigator because outlining should happen only on item pages
if (!navigator || me?.id === item.user?.id) return
const itemCreatedAt = new Date(item.createdAt).getTime()
const rootViewedAt = new Date(root.meCommentsViewedAt).getTime()
const rootLast = new Date(root.lastCommentAt || root.createdAt).getTime()
// it's a new comment if it was created after the last comment was viewed
// or, in the case of live comments, after the last comment was created
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime())
const isNewComment = me?.id && rootViewedAt
? itemCreatedAt > rootViewedAt
// anon fallback is based on the commentsViewedAt query param or the last comment createdAt
: ((router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) ||
(itemCreatedAt > rootLast))
if (!isNewComment) return
if (item.injected) {
@ -180,8 +186,8 @@ export default function Comment ({
ref.current.classList.add('outline-new-comment')
}
navigator?.trackNewComment(ref, itemCreatedAt)
}, [item.id, rootLastCommentAt])
navigator.trackNewComment(ref, itemCreatedAt)
}, [item.id, root.lastCommentAt, root.meCommentsViewedAt])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0)
// Don't show OP badge when anon user comments on anon user posts
@ -303,7 +309,7 @@ export default function Comment ({
? (
<>
{item.comments.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={rootLastCommentAt} />
<Comment depth={depth + 1} key={item.id} item={item} navigator={navigator} />
))}
{item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}>

View File

@ -98,11 +98,11 @@ export default function Comments ({
: null}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} pin />
<Comment depth={1} item={item} navigator={navigator} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} navigator={navigator} rootLastCommentAt={lastCommentAt || parentCreatedAt} {...props} />
<Comment depth={1} key={item.id} item={item} navigator={navigator} {...props} />
))}
{ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter

View File

@ -27,6 +27,8 @@ import classNames from 'classnames'
import { CarouselProvider } from './carousel'
import Embed from './embed'
import { useRouter } from 'next/router'
import { useMutation } from '@apollo/client'
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
function BioItem ({ item, handleClick }) {
const { me } = useMe()
@ -162,9 +164,25 @@ function ItemText ({ item }) {
}
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) {
const { me } = useMe()
// no cache update here because we need to preserve the initial value
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW)
useEffect(() => {
commentsViewed(item)
}, [item.lastCommentAt])
if (item.parentId) return
// local comments viewed (anon fallback)
if (!me?.id) return commentsViewed(item)
const last = new Date(item.lastCommentAt || item.createdAt)
const viewedAt = new Date(item.meCommentsViewedAt)
if (viewedAt.getTime() >= last.getTime()) return
// me server comments viewed
updateCommentsViewAt({
variables: { id: item.id, meCommentsViewedAt: last }
})
}, [item.id, item.lastCommentAt, item.createdAt, item.meCommentsViewedAt, me?.id])
const router = useRouter()
const carouselKey = `${item.id}-${router.query?.sort || 'default'}`

View File

@ -1,6 +1,7 @@
import { Form, MarkdownInput } from '@/components/form'
import styles from './reply.module.css'
import { COMMENTS } from '@/fragments/comments'
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useMemo } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
@ -14,6 +15,7 @@ import { CREATE_COMMENT } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'
import gql from 'graphql-tag'
import { updateAncestorsCommentCount } from '@/lib/comments'
import { useMutation } from '@apollo/client'
export default forwardRef(function Reply ({
item,
@ -30,6 +32,14 @@ export default forwardRef(function Reply ({
const showModal = useShowModal()
const root = useRoot()
const sub = item?.sub || root?.sub
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
update (cache, { data: { updateCommentsViewAt } }) {
cache.modify({
id: `Item:${root?.id}`,
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
})
}
})
useEffect(() => {
if (replyOpen || quote || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) {
@ -87,8 +97,14 @@ export default forwardRef(function Reply ({
// so that we don't see indicator for our own comments, we record this comments as the latest time
// but we also have record num comments, in case someone else commented when we did
const root = ancestors[0]
commentsViewedAfterComment(root, result.createdAt)
const rootId = ancestors[0]
if (me?.id) {
// server-tracked view
updateCommentsViewAt({ variables: { id: rootId, meCommentsViewedAt: result.createdAt } })
} else {
// anon fallback
commentsViewedAfterComment(rootId, result.createdAt)
}
}
},
onSuccessfulSubmit: (data, { resetForm }) => {

View File

@ -1,16 +1,18 @@
import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments'
import { UPDATE_ITEM_USER_VIEW } from '../fragments/items'
import { useEffect, useState, useCallback } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useQuery, useApolloClient } from '@apollo/client'
import { useQuery, useApolloClient, useMutation } from '@apollo/client'
import { commentsViewedAfterComment } from '../lib/new-comments'
import {
updateItemQuery,
updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount,
calculateDepth
} from '../lib/comments'
import { useMe } from './me'
import { useRoot } from './root'
const POLL_INTERVAL = 1000 * 5 // 5 seconds
@ -30,9 +32,6 @@ function prepareComments (item, cache, newComment) {
// update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments)
// update commentsViewedAt to now, and add the number of new comments
const rootId = itemHierarchy[0]
commentsViewedAfterComment(rootId, Date.now(), totalNComments)
// add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true }
@ -58,29 +57,50 @@ function prepareComments (item, cache, newComment) {
return payload
}
function cacheNewComments (cache, rootId, newComments, sort) {
function cacheNewComments (cache, latest, topLevelId, newComments, sort) {
let injectedLatest = latest
for (const newComment of newComments) {
const { parentId } = newComment
const topLevel = Number(parentId) === Number(rootId)
const topLevel = Number(parentId) === Number(topLevelId)
let injected = false
// if the comment is a top level comment, update the item, else update the parent comment
if (topLevel) {
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment))
updateItemQuery(cache, topLevelId, sort, (item) => prepareComments(item, cache, newComment))
injected = true
} else {
// if the comment is too deep, we can skip it
const depth = calculateDepth(newComment.path, rootId, parentId)
const depth = calculateDepth(newComment.path, topLevelId, parentId)
if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field
updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
const updated = updateCommentFragment(cache, parentId, (parent) => prepareComments(parent, cache, newComment))
injected = !!updated
}
// update latest timestamp to the latest comment created at
if (injected && new Date(newComment.createdAt).getTime() > new Date(injectedLatest).getTime()) {
injectedLatest = newComment.createdAt
}
}
// useLiveComments fetches new comments under an item (rootId),
return injectedLatest
}
// useLiveComments fetches new comments under an item (topLevelId),
// that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}`
export default function useLiveComments (topLevelId, after, sort) {
const latestKey = `liveCommentsLatest:${topLevelId}`
const { cache } = useApolloClient()
const { me } = useMe()
const root = useRoot()
const [updateCommentsViewAt] = useMutation(UPDATE_ITEM_USER_VIEW, {
update (cache, { data: { updateCommentsViewAt } }) {
cache.modify({
id: `Item:${root.id}`,
fields: { meCommentsViewedAt: () => updateCommentsViewAt }
})
}
})
const [disableLiveComments] = useLiveCommentsToggle()
const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false)
@ -96,12 +116,12 @@ export default function useLiveComments (rootId, after, sort) {
// Apollo might update the cache before the page has fully rendered, causing reads of stale cached data
// this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true)
}, [after])
}, [topLevelId, after])
const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp
variables: { rootId, after: latest },
variables: { topLevelId, after: latest },
nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments
})
@ -111,14 +131,29 @@ export default function useLiveComments (rootId, after, sort) {
// directly inject new comments into the cache, preserving scroll position
// quirk: scroll is preserved even if we are not injecting new comments due to dedupe
preserveScroll(() => cacheNewComments(cache, rootId, data.newComments.comments, sort))
let injectedLatest = latest
preserveScroll(() => {
injectedLatest = cacheNewComments(cache, injectedLatest, topLevelId, data.newComments.comments, sort)
})
// sync view time if we successfully injected new comments
if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) {
if (me?.id) {
// server-tracked view
updateCommentsViewAt({ variables: { id: root.id, meCommentsViewedAt: injectedLatest } })
} else {
// anon fallback
commentsViewedAfterComment(root.id, injectedLatest)
}
// update latest timestamp to the latest comment created at
// save it to session storage, to persist between client-side navigations
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest)
setLatest(newLatest)
window.sessionStorage.setItem(latestKey, newLatest)
}, [data, cache, rootId, sort, latest])
setLatest(injectedLatest)
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(latestKey, injectedLatest)
}
}
}, [data, cache, topLevelId, root.id, sort, latest, me?.id])
}
const STORAGE_KEY = 'disableLiveComments'

View File

@ -212,8 +212,8 @@ export const COMMENT_WITH_NEW_MINIMAL = gql`
export const GET_NEW_COMMENTS = gql`
${COMMENTS}
query GetNewComments($rootId: ID, $after: Date) {
newComments(rootId: $rootId, after: $after) {
query GetNewComments($topLevelId: ID, $after: Date) {
newComments(topLevelId: $topLevelId, after: $after) {
comments {
...CommentsRecursive
}

View File

@ -81,6 +81,7 @@ export const ITEM_FIELDS = gql`
confirmedAt
}
cost
meCommentsViewedAt
}`
export const ITEM_FULL_FIELDS = gql`
@ -91,12 +92,15 @@ export const ITEM_FULL_FIELDS = gql`
text
root {
id
createdAt
title
bounty
bountyPaidTo
subName
mine
ncomments
lastCommentAt
meCommentsViewedAt
user {
id
name
@ -211,3 +215,9 @@ export const RELATED_ITEMS_WITH_ITEM = gql`
}
}
`
export const UPDATE_ITEM_USER_VIEW = gql`
mutation updateCommentsViewAt($id: ID!, $meCommentsViewedAt: Date!) {
updateCommentsViewAt(id: $id, meCommentsViewedAt: $meCommentsViewedAt)
}
`

View File

@ -22,7 +22,14 @@ export function commentsViewedAfterComment (rootId, createdAt, ncomments = 1) {
}
export function newComments (item) {
if (!item.parentId) {
if (!item.parentId && item.lastCommentAt) {
// if logged, prefer server-tracked view
if (item.meCommentsViewedAt) {
const viewedAt = new Date(item.meCommentsViewedAt).getTime()
return viewedAt < new Date(item.lastCommentAt).getTime()
}
// anon fallback
const viewedAt = commentsViewedAt(item.id)
const viewNum = commentsViewedNum(item.id)

View File

@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "CommentsViewAt" (
"userId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"last_viewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommentsViewAt_pkey" PRIMARY KEY ("userId","itemId")
);
-- CreateIndex
CREATE INDEX "CommentsViewAt_userId_idx" ON "CommentsViewAt"("userId");
-- AddForeignKey
ALTER TABLE "CommentsViewAt" ADD CONSTRAINT "CommentsViewAt_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommentsViewAt" ADD CONSTRAINT "CommentsViewAt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE OR REPLACE FUNCTION schedule_untrack_old_items()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
INSERT INTO pgboss.schedule (name, cron, timezone)
VALUES ('untrackOldItems', '0 0 * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
return 0;
EXCEPTION WHEN OTHERS THEN
return 0;
END;
$$;
SELECT schedule_untrack_old_items();
DROP FUNCTION IF EXISTS schedule_untrack_old_items;

View File

@ -152,6 +152,7 @@ model User {
DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
UserSubTrust UserSubTrust[]
CommentsViewAt CommentsViewAt[]
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@ -490,6 +491,7 @@ model Item {
ItemUserAgg ItemUserAgg[]
AutoSocialPost AutoSocialPost[]
randPollOptions Boolean @default(false)
CommentsViewAt CommentsViewAt[]
@@index([uploadId])
@@index([lastZapAt])
@ -540,6 +542,17 @@ model ItemUserAgg {
@@index([createdAt])
}
model CommentsViewAt {
userId Int
itemId Int
lastViewedAt DateTime @default(now()) @map("last_viewed_at")
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, itemId])
@@index([userId])
}
// record auto social posts so we can avoid duplicates
model AutoSocialPost {
id Int @id @default(autoincrement())

View File

@ -37,7 +37,7 @@ import { expireBoost } from './expireBoost'
import { payingActionConfirmed, payingActionFailed } from './payingAction'
import { autoDropBolt11s } from './autoDropBolt11'
import { postToSocial } from './socialPoster'
import { untrackOldItems } from './untrackOldItems'
// WebSocket polyfill
import ws from 'isomorphic-ws'
if (typeof WebSocket === 'undefined') {
@ -143,6 +143,7 @@ async function work () {
await boss.work('reminder', jobWrapper(remindUser))
await boss.work('thisDay', jobWrapper(thisDay))
await boss.work('socialPoster', jobWrapper(postToSocial))
await boss.work('untrackOldItems', jobWrapper(untrackOldItems))
console.log('working jobs')
}

22
worker/untrackOldItems.js Normal file
View File

@ -0,0 +1,22 @@
import { datePivot } from '@/lib/time'
// deletes from commentsViewAt items that were never viewed and had no comments in the last 21 days
export async function untrackOldItems ({ models }) {
const pivot = datePivot(new Date(), { days: -21 })
await models.commentsViewAt.deleteMany({
where: {
AND: [
// delete if never viewed in the last 21 days
{ lastViewedAt: { lt: pivot } },
// AND if the item had no comments in the last 21 days
{
OR: [
{ item: { lastCommentAt: { lt: pivot } } },
{ item: { lastCommentAt: null, createdAt: { lt: pivot } } }
]
}
]
}
})
}