From b5af28c48bbcc84545cae5a598fd159e691873ef Mon Sep 17 00:00:00 2001 From: soxa <6390896+Soxasora@users.noreply.github.com> Date: Tue, 2 Sep 2025 20:13:44 +0200 Subject: [PATCH] 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 --- api/resolvers/item.js | 23 +++++- api/typeDefs/item.js | 4 +- components/comment.js | 22 ++++-- components/comments.js | 4 +- components/item-full.js | 22 +++++- components/reply.js | 20 ++++- components/use-live-comments.js | 79 +++++++++++++------ fragments/comments.js | 4 +- fragments/items.js | 10 +++ lib/new-comments.js | 9 ++- .../migration.sql | 34 ++++++++ prisma/schema.prisma | 13 +++ worker/index.js | 3 +- worker/untrackOldItems.js | 22 ++++++ 14 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 prisma/migrations/20250820165339_server_side_comments_view_at/migration.sql create mode 100644 worker/untrackOldItems.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index a34fc822..35c93077 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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: { diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a4e0a988..1824547e 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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 { diff --git a/components/comment.js b/components/comment.js index 633b855c..9267658b 100644 --- a/components/comment.js +++ b/components/comment.js @@ -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) => ( - + ))} {item.comments.comments.length < item.nDirectComments && (
diff --git a/components/comments.js b/components/comments.js index 9c6a6739..713f37c6 100644 --- a/components/comments.js +++ b/components/comments.js @@ -98,11 +98,11 @@ export default function Comments ({ : null} {pins.map(item => ( - + ))} {comments.filter(({ position }) => !position).map(item => ( - + ))} {ncomments > FULL_COMMENTS_THRESHOLD && { - 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'}` diff --git a/components/reply.js b/components/reply.js index 1d3ab77c..329a9382 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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 }) => { diff --git a/components/use-live-comments.js b/components/use-live-comments.js index e1ba735a..348aafe8 100644 --- a/components/use-live-comments.js +++ b/components/use-live-comments.js @@ -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 } } + + return injectedLatest } -// useLiveComments fetches new comments under an item (rootId), +// 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) + }) - // 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]) + // 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 + setLatest(injectedLatest) + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(latestKey, injectedLatest) + } + } + }, [data, cache, topLevelId, root.id, sort, latest, me?.id]) } const STORAGE_KEY = 'disableLiveComments' diff --git a/fragments/comments.js b/fragments/comments.js index c55283ea..e9ddfd16 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -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 } diff --git a/fragments/items.js b/fragments/items.js index 151587a2..1b7f8073 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -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) + } +` diff --git a/lib/new-comments.js b/lib/new-comments.js index cf84a387..a38145ae 100644 --- a/lib/new-comments.js +++ b/lib/new-comments.js @@ -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) diff --git a/prisma/migrations/20250820165339_server_side_comments_view_at/migration.sql b/prisma/migrations/20250820165339_server_side_comments_view_at/migration.sql new file mode 100644 index 00000000..7f79240f --- /dev/null +++ b/prisma/migrations/20250820165339_server_side_comments_view_at/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b4e6689..168d1a91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/worker/index.js b/worker/index.js index 20848e23..0ecd20d6 100644 --- a/worker/index.js +++ b/worker/index.js @@ -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') } diff --git a/worker/untrackOldItems.js b/worker/untrackOldItems.js new file mode 100644 index 00000000..cfc40d27 --- /dev/null +++ b/worker/untrackOldItems.js @@ -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 } } } + ] + } + ] + } + }) +}