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 } } }
+ ]
+ }
+ ]
+ }
+ })
+}