Skip to content

Live updates to comment threads #2115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
},

Expand Down
2 changes: 2 additions & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -275,6 +277,9 @@ export default function Comment ({
: null}
{/* TODO: add link to more comments if they're limited */}
</div>
{item.newComments?.length > 0 && (
<ShowNewComments newComments={item.newComments} itemId={item.id} />
)}
</div>
)
)}
Expand Down Expand Up @@ -338,3 +343,61 @@ export function CommentSkeleton ({ skeletonChildren }) {
</div>
)
}

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 (
<span onClick={showNewComments}>
<div className={!topLevel ? styles.comments : 'pb-2'}>
<div className={`d-block fw-bold ${styles.comment} pb-2 ps-3 d-flex align-items-center gap-2 pointer`}>
load new comments
<div className={styles.newCommentDot} />
</div>
</div>
</span>
)
}
23 changes: 23 additions & 0 deletions components/comment.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
124 changes: 124 additions & 0 deletions components/comments-live.js
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 31 additions & 4 deletions components/comments.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)

Expand All @@ -28,6 +31,25 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
<Nav.Item className='text-muted'>
{numWithUnits(commentSats)}
</Nav.Item>
{livePolling
? (
<Nav.Item className='ps-2'>
<ActionTooltip notForm overlayText='comments are live'>
<div className={styles.newCommentDot} />
</ActionTooltip>
</Nav.Item>
)
: (
<Nav.Item className='ps-2'>
<ActionTooltip notForm overlayText='click to resume live comments'>
<div
className={classNames(styles.newCommentDot, styles.paused)}
onClick={() => setLivePolling(true)}
style={{ cursor: 'pointer' }}
/>
</ActionTooltip>
</Nav.Item>
)}
<div className='ms-auto d-flex'>
<Nav.Item>
<Nav.Link
Expand Down Expand Up @@ -64,9 +86,11 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm

export default function Comments ({
parentId, pinned, bio, parentCreatedAt,
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, ...props
commentSats, comments, commentsCursor, fetchMoreComments, ncomments, newComments, lastCommentAt, ...props
}) {
const router = useRouter()
// update item.newComments in cache
const { polling: livePolling, setPolling: setLivePolling } = useLiveComments(parentId, lastCommentAt || parentCreatedAt)

const pins = useMemo(() => comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position), [comments])

Expand All @@ -75,7 +99,7 @@ export default function Comments ({
{comments?.length > 0
? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
pinned={pinned} bio={bio} handleSort={sort => {
pinned={pinned} bio={bio} livePolling={livePolling} setLivePolling={setLivePolling} handleSort={sort => {
const { commentsViewedAt, commentId, ...query } = router.query
delete query.nodata
router.push({
Expand All @@ -88,6 +112,9 @@ export default function Comments ({
}}
/>
: null}
{newComments?.length > 0 && (
<ShowNewComments topLevel newComments={newComments} itemId={parentId} />
)}
{pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} {...props} pin />
Expand Down
30 changes: 29 additions & 1 deletion components/header.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,32 @@
padding-top: 1px;
background-color: var(--bs-body-bg);
z-index: 1000;
}
}

.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;
}
}
Loading