diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 781409e09..35875993c 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -727,6 +727,21 @@ export default { homeMaxBoost: homeAgg._max.boost || 0, subMaxBoost: subAgg?._max.boost || 0 } + }, + newComments: async (parent, { rootId, after }, { models, me }) => { + const comments = await itemQueryWithMeta({ + me, + models, + query: ` + ${SELECT} + FROM "Item" + -- comments can be nested, so we need to get all comments that are descendants of the root + WHERE "Item".path <@ (SELECT path FROM "Item" WHERE id = $1) + AND "Item"."created_at" > $2 + ORDER BY "Item"."created_at" ASC` + }, Number(rootId), after) + + return { comments } } }, diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index a40a99ae1..4d19c7440 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -11,6 +11,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! } type BoostPositions { @@ -148,6 +149,7 @@ export default gql` ncomments: Int! nDirectComments: Int! comments(sort: String, cursor: String): Comments! + newComments(rootId: ID, after: Date): Comments! path: String position: Int prior: Int diff --git a/components/comment.js b/components/comment.js index 73e64d0c3..8f17e06d6 100644 --- a/components/comment.js +++ b/components/comment.js @@ -28,6 +28,8 @@ import LinkToContext from './link-to-context' import Boost from './boost-button' import { gql, useApolloClient } from '@apollo/client' import classNames from 'classnames' +import { ITEM_FULL } from '@/fragments/items' +import { COMMENT_WITH_NEW } from '@/fragments/comments' function Parent ({ item, rootText }) { const root = useRoot() @@ -275,6 +277,9 @@ export default function Comment ({ : null} {/* TODO: add link to more comments if they're limited */} + {item.newComments?.length > 0 && ( + + )} ) )} @@ -338,3 +343,61 @@ export function CommentSkeleton ({ skeletonChildren }) { ) } + +export function ShowNewComments ({ newComments = [], itemId, topLevel = false }) { + const client = useApolloClient() + + const showNewComments = () => { + if (topLevel) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: itemId } + }, (data) => { + if (!data) return data + const { item } = data + + return { + item: { + ...item, + comments: dedupeComments(item.comments, newComments), + newComments: [] + } + } + }) + } else { + client.cache.updateFragment({ + id: `Item:${itemId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + + return { + ...data, + comments: dedupeComments(data.comments, newComments), + newComments: [] + } + }) + } + } + + const dedupeComments = (existingComments = [], newComments = []) => { + const existingIds = new Set(existingComments.comments?.map(c => c.id)) + const filteredNew = newComments.filter(c => !existingIds.has(c.id)) + return { + ...existingComments, + comments: [...filteredNew, ...(existingComments.comments || [])] + } + } + + return ( + +
+
+ load new comments +
+
+
+ + ) +} diff --git a/components/comment.module.css b/components/comment.module.css index 6f24ab615..215993d64 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -135,4 +135,27 @@ .comment:has(.comment) + .comment{ padding-top: .5rem; +} + +.newCommentDot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--bs-primary); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + background-color: #FADA5E; + opacity: 0.7; + } + 50% { + background-color: #F6911D; + opacity: 1; + } + 100% { + background-color: #FADA5E; + opacity: 0.7; + } } \ No newline at end of file diff --git a/components/comments-live.js b/components/comments-live.js new file mode 100644 index 000000000..020e4011c --- /dev/null +++ b/components/comments-live.js @@ -0,0 +1,124 @@ +import { useQuery, useApolloClient } from '@apollo/client' +import { SSR } from '../lib/constants' +import { GET_NEW_COMMENTS, COMMENT_WITH_NEW } from '../fragments/comments' +import { ITEM_FULL } from '../fragments/items' +import { useEffect, useState } from 'react' + +export default function useLiveComments (rootId, after) { + const client = useApolloClient() + const [lastChecked, setLastChecked] = useState(after) + const [polling, setPolling] = useState(true) + const [engagedAt, setEngagedAt] = useState(new Date()) + + // reset engagedAt when polling is toggled + useEffect(() => { + if (polling) { + setEngagedAt(new Date()) + } + }, [polling]) + + useEffect(() => { + const checkActivity = () => { + const now = new Date() + const timeSinceEngaged = now.getTime() - engagedAt.getTime() + const isActive = document.visibilityState === 'visible' + + // poll only if the user is active and has been active in the last 30 minutes + if (timeSinceEngaged < 1000 * 60 * 30) { + setPolling(isActive) + } else { + setPolling(false) + } + } + + // check activity every minute + const interval = setInterval(checkActivity, 1000 * 60) + // check activity also on visibility change + document.addEventListener('visibilitychange', checkActivity) + + return () => { + document.removeEventListener('visibilitychange', checkActivity) + clearInterval(interval) + } + }, [engagedAt]) + + const { data } = useQuery(GET_NEW_COMMENTS, SSR + ? {} + : { + pollInterval: polling ? 10000 : null, + variables: { rootId, after: lastChecked } + }) + + if (data && data.newComments) { + saveNewComments(client, rootId, data.newComments.comments) + const latestCommentCreatedAt = getLastCommentCreatedAt(data.newComments.comments) + if (latestCommentCreatedAt) { + setLastChecked(latestCommentCreatedAt) + } + } + + return { polling, setPolling } +} + +function saveNewComments (client, rootId, newComments) { + for (const comment of newComments) { + const { parentId } = comment + const topLevel = Number(parentId) === Number(rootId) + + // if the comment is a top level comment, update the item + if (topLevel) { + client.cache.updateQuery({ + query: ITEM_FULL, + variables: { id: rootId } + }, (data) => { + if (!data) return data + // we return the entire item, not just the newComments + return { item: dedupeComment(data?.item, comment) } + }) + } else { + // if the comment is a reply, update the parent comment + client.cache.updateFragment({ + id: `Item:${parentId}`, + fragment: COMMENT_WITH_NEW, + fragmentName: 'CommentWithNew' + }, (data) => { + if (!data) return data + // here we return the parent comment with the new comment added + return dedupeComment(data, comment) + }) + } + } +} + +function dedupeComment (item, newComment) { + // get the existing comment ids for faster lookup + const existingCommentIds = new Set( + (item.comments?.comments || []).map(c => c.id) + ) + const existingNewComments = item.newComments || [] + + // is the incoming new comment already in item's new comments? + if (existingNewComments.some(c => c.id === newComment.id)) { + return item + } + + // if the incoming new comment is not in item's new comments, add it + // sanity check: and if somehow the incoming new comment is in + // item's new comments, remove it + const updatedNewComments = !existingCommentIds.has(newComment.id) + ? [...existingNewComments, newComment] + : existingNewComments.filter(c => c.id !== newComment.id) + + return { ...item, newComments: updatedNewComments } +} + +function getLastCommentCreatedAt (comments) { + if (comments.length === 0) return null + let latest = comments[0].createdAt + for (const comment of comments) { + if (comment.createdAt > latest) { + latest = comment.createdAt + } + } + return latest +} diff --git a/components/comments.js b/components/comments.js index cb5d86416..a5696c3ec 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,5 +1,5 @@ import { Fragment, useMemo } from 'react' -import Comment, { CommentSkeleton } from './comment' +import Comment, { CommentSkeleton, ShowNewComments } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' import Navbar from 'react-bootstrap/Navbar' @@ -8,8 +8,11 @@ import { defaultCommentSort } from '@/lib/item' import { useRouter } from 'next/router' import MoreFooter from './more-footer' import { FULL_COMMENTS_THRESHOLD } from '@/lib/constants' +import useLiveComments from './comments-live' +import ActionTooltip from './action-tooltip' +import classNames from 'classnames' -export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { +export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats, livePolling, setLivePolling }) { const router = useRouter() const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) @@ -28,6 +31,25 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm {numWithUnits(commentSats)} + {livePolling + ? ( + + +
+ + + ) + : ( + + +
setLivePolling(true)} + style={{ cursor: 'pointer' }} + /> + + + )}
comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments]) @@ -75,7 +99,7 @@ export default function Comments ({ {comments?.length > 0 ? { + pinned={pinned} bio={bio} livePolling={livePolling} setLivePolling={setLivePolling} handleSort={sort => { const { commentsViewedAt, commentId, ...query } = router.query delete query.nodata router.push({ @@ -88,6 +112,9 @@ export default function Comments ({ }} /> : null} + {newComments?.length > 0 && ( + + )} {pins.map(item => ( diff --git a/components/header.module.css b/components/header.module.css index 1134e8480..33cf61032 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -109,4 +109,32 @@ padding-top: 1px; background-color: var(--bs-body-bg); z-index: 1000; -} \ No newline at end of file +} + +.newCommentDot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: var(--bs-primary); + animation: pulse 2s infinite; +} + +.newCommentDot.paused { + background-color: var(--bs-grey-darkmode); + animation: none; +} + +@keyframes pulse { + 0% { + background-color: #FADA5E; + opacity: 0.7; + } + 50% { + background-color: #F6911D; + opacity: 1; + } + 100% { + background-color: #FADA5E; + opacity: 0.7; + } +} diff --git a/components/item-full.js b/components/item-full.js index 72c60b9c6..02621981b 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -182,17 +182,18 @@ export default function ItemFull ({ item, fetchMoreComments, bio, rank, ...props ? : }
)} - {item.comments && -
- -
} +
+ +
diff --git a/fragments/comments.js b/fragments/comments.js index 2fd28d0f1..f7e325c4c 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -47,6 +47,7 @@ export const COMMENT_FIELDS = gql` otsHash ncomments nDirectComments + newComments @client imgproxyUrls rel apiKey @@ -116,3 +117,30 @@ export const COMMENTS = gql` } } }` + +export const COMMENT_WITH_NEW = gql` + ${COMMENT_FIELDS} + ${COMMENTS} + + fragment CommentWithNew on Item { + ...CommentFields + comments { + comments { + ...CommentsRecursive + } + } + newComments @client + } +` + +export const GET_NEW_COMMENTS = gql` + ${COMMENTS} + + query GetNewComments($rootId: ID, $after: Date) { + newComments(rootId: $rootId, after: $after) { + comments { + ...CommentsRecursive + } + } + } +` diff --git a/fragments/items.js b/fragments/items.js index 151587a20..95eed0421 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -59,6 +59,7 @@ export const ITEM_FIELDS = gql` bio ncomments nDirectComments + newComments @client commentSats commentCredits lastCommentAt diff --git a/lib/apollo.js b/lib/apollo.js index 3739ba3fd..dc7b8127b 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -313,6 +313,11 @@ function getClient (uri) { } } }, + newComments: { + read (newComments) { + return newComments || [] + } + }, meAnonSats: { read (existingAmount, { readField }) { if (SSR) return null