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", 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", "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) 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 ( FROM (
${query} ${query}
) "Item" ) "Item"
@ -192,6 +193,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} 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 "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 ( LEFT JOIN LATERAL (
SELECT "itemId", 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 NOT NULL AND (act = 'FEE' OR act = 'TIP')) AS "meMsats",
@ -741,7 +743,7 @@ export default {
subMaxBoost: subAgg?._max.boost || 0 subMaxBoost: subAgg?._max.boost || 0
} }
}, },
newComments: async (parent, { rootId, after }, { models, me }) => { newComments: async (parent, { topLevelId, after }, { models, me }) => {
const comments = await itemQueryWithMeta({ const comments = await itemQueryWithMeta({
me, me,
models, models,
@ -755,7 +757,7 @@ export default {
'"Item"."created_at" > $2' '"Item"."created_at" > $2'
)} )}
ORDER BY "Item"."created_at" ASC` ORDER BY "Item"."created_at" ASC`
}, Number(rootId), after) }, Number(topLevelId), after)
return { comments } return { comments }
} }
@ -1073,6 +1075,21 @@ export default {
]) ])
return result 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: { ItemAct: {

View File

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

View File

@ -96,7 +96,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
} }
export default function Comment ({ export default function Comment ({
item, children, replyOpen, includeParent, topLevel, rootLastCommentAt, item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry, rootText, noComments, noReply, truncate, depth, pin, setDisableRetry, disableRetry,
navigator navigator
}) { }) {
@ -157,13 +157,19 @@ export default function Comment ({
}, [item.id, cache, router.query.commentId]) }, [item.id, cache, router.query.commentId])
useEffect(() => { 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 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 // 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 = me?.id && rootViewedAt
const isNewComment = (router.query.commentsViewedAt && itemCreatedAt > router.query.commentsViewedAt) || ? itemCreatedAt > rootViewedAt
(rootLastCommentAt && itemCreatedAt > new Date(rootLastCommentAt).getTime()) // 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 (!isNewComment) return
if (item.injected) { if (item.injected) {
@ -180,8 +186,8 @@ export default function Comment ({
ref.current.classList.add('outline-new-comment') ref.current.classList.add('outline-new-comment')
} }
navigator?.trackNewComment(ref, itemCreatedAt) navigator.trackNewComment(ref, itemCreatedAt)
}, [item.id, rootLastCommentAt]) }, [item.id, root.lastCommentAt, root.meCommentsViewedAt])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT || (item.comments?.comments.length === 0 && item.nDirectComments > 0) 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 // 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.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 && ( {item.comments.comments.length < item.nDirectComments && (
<div className={`d-block ${styles.comment} pb-2 ps-3`}> <div className={`d-block ${styles.comment} pb-2 ps-3`}>

View File

@ -98,11 +98,11 @@ export default function Comments ({
: null} : null}
{pins.map(item => ( {pins.map(item => (
<Fragment key={item.id}> <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> </Fragment>
))} ))}
{comments.filter(({ position }) => !position).map(item => ( {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 && {ncomments > FULL_COMMENTS_THRESHOLD &&
<MoreFooter <MoreFooter

View File

@ -27,6 +27,8 @@ import classNames from 'classnames'
import { CarouselProvider } from './carousel' import { CarouselProvider } from './carousel'
import Embed from './embed' import Embed from './embed'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useMutation } from '@apollo/client'
import { UPDATE_ITEM_USER_VIEW } from '@/fragments/items'
function BioItem ({ item, handleClick }) { function BioItem ({ item, handleClick }) {
const { me } = useMe() const { me } = useMe()
@ -162,9 +164,25 @@ function ItemText ({ item }) {
} }
export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props }) { 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(() => { useEffect(() => {
commentsViewed(item) if (item.parentId) return
}, [item.lastCommentAt]) // 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 router = useRouter()
const carouselKey = `${item.id}-${router.query?.sort || 'default'}` const carouselKey = `${item.id}-${router.query?.sort || 'default'}`

View File

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

View File

@ -1,16 +1,18 @@
import preserveScroll from './preserve-scroll' import preserveScroll from './preserve-scroll'
import { GET_NEW_COMMENTS } from '../fragments/comments' import { GET_NEW_COMMENTS } from '../fragments/comments'
import { UPDATE_ITEM_USER_VIEW } from '../fragments/items'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { SSR, COMMENT_DEPTH_LIMIT } from '../lib/constants' 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 { commentsViewedAfterComment } from '../lib/new-comments'
import { import {
updateItemQuery, updateItemQuery,
updateCommentFragment, updateCommentFragment,
getLatestCommentCreatedAt,
updateAncestorsCommentCount, updateAncestorsCommentCount,
calculateDepth calculateDepth
} from '../lib/comments' } from '../lib/comments'
import { useMe } from './me'
import { useRoot } from './root'
const POLL_INTERVAL = 1000 * 5 // 5 seconds 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 // update all ancestors comment count, but not the item itself
const ancestors = itemHierarchy.slice(0, -1) const ancestors = itemHierarchy.slice(0, -1)
updateAncestorsCommentCount(cache, ancestors, totalNComments) 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 // add a flag to the new comment to indicate it was injected
const injectedComment = { ...newComment, injected: true } const injectedComment = { ...newComment, injected: true }
@ -58,29 +57,50 @@ function prepareComments (item, cache, newComment) {
return payload return payload
} }
function cacheNewComments (cache, rootId, newComments, sort) { function cacheNewComments (cache, latest, topLevelId, newComments, sort) {
let injectedLatest = latest
for (const newComment of newComments) { for (const newComment of newComments) {
const { parentId } = newComment 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 the comment is a top level comment, update the item, else update the parent comment
if (topLevel) { if (topLevel) {
updateItemQuery(cache, rootId, sort, (item) => prepareComments(item, cache, newComment)) updateItemQuery(cache, topLevelId, sort, (item) => prepareComments(item, cache, newComment))
injected = true
} else { } else {
// if the comment is too deep, we can skip it // 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 if (depth > COMMENT_DEPTH_LIMIT) continue
// inject the new comment into the parent comment's comments field // 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. // that are newer than the latest comment createdAt (after), and injects them into the cache.
export default function useLiveComments (rootId, after, sort) { export default function useLiveComments (topLevelId, after, sort) {
const latestKey = `liveCommentsLatest:${rootId}` const latestKey = `liveCommentsLatest:${topLevelId}`
const { cache } = useApolloClient() 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 [disableLiveComments] = useLiveCommentsToggle()
const [latest, setLatest] = useState(after) const [latest, setLatest] = useState(after)
const [initialized, setInitialized] = useState(false) 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 // 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 // this prevents GET_NEW_COMMENTS from producing results before the page has fully rendered
setInitialized(true) setInitialized(true)
}, [after]) }, [topLevelId, after])
const { data } = useQuery(GET_NEW_COMMENTS, { const { data } = useQuery(GET_NEW_COMMENTS, {
pollInterval: POLL_INTERVAL, pollInterval: POLL_INTERVAL,
// only get comments newer than the passed latest timestamp // only get comments newer than the passed latest timestamp
variables: { rootId, after: latest }, variables: { topLevelId, after: latest },
nextFetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-and-network',
skip: SSR || !initialized || disableLiveComments 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 // 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 // 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 // sync view time if we successfully injected new comments
// save it to session storage, to persist between client-side navigations if (new Date(injectedLatest).getTime() > new Date(latest).getTime()) {
const newLatest = getLatestCommentCreatedAt(data.newComments.comments, latest) if (me?.id) {
setLatest(newLatest) // server-tracked view
window.sessionStorage.setItem(latestKey, newLatest) updateCommentsViewAt({ variables: { id: root.id, meCommentsViewedAt: injectedLatest } })
}, [data, cache, rootId, sort, latest]) } 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' const STORAGE_KEY = 'disableLiveComments'

View File

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

View File

@ -81,6 +81,7 @@ export const ITEM_FIELDS = gql`
confirmedAt confirmedAt
} }
cost cost
meCommentsViewedAt
}` }`
export const ITEM_FULL_FIELDS = gql` export const ITEM_FULL_FIELDS = gql`
@ -91,12 +92,15 @@ export const ITEM_FULL_FIELDS = gql`
text text
root { root {
id id
createdAt
title title
bounty bounty
bountyPaidTo bountyPaidTo
subName subName
mine mine
ncomments ncomments
lastCommentAt
meCommentsViewedAt
user { user {
id id
name 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) { 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 viewedAt = commentsViewedAt(item.id)
const viewNum = commentsViewedNum(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") DirectPaymentReceived DirectPayment[] @relation("DirectPaymentReceived")
DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent") DirectPaymentSent DirectPayment[] @relation("DirectPaymentSent")
UserSubTrust UserSubTrust[] UserSubTrust UserSubTrust[]
CommentsViewAt CommentsViewAt[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -490,6 +491,7 @@ model Item {
ItemUserAgg ItemUserAgg[] ItemUserAgg ItemUserAgg[]
AutoSocialPost AutoSocialPost[] AutoSocialPost AutoSocialPost[]
randPollOptions Boolean @default(false) randPollOptions Boolean @default(false)
CommentsViewAt CommentsViewAt[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -540,6 +542,17 @@ model ItemUserAgg {
@@index([createdAt]) @@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 // record auto social posts so we can avoid duplicates
model AutoSocialPost { model AutoSocialPost {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())

View File

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