+ {t('pageflow_scrolled.review.activity.no_activity_yet')} +
+ ); + } + + const shown = entries.slice(0, pages * pageSize); + + return ( +{summary(t, entry, day)}
+{comment.quote}} @@ -107,19 +113,3 @@ function EditForm({comment, threadId, onDone}) { ); } - -function formatDate(isoString, locale, options) { - const date = new Date(isoString); - const fromCurrentYear = date.getFullYear() === new Date().getFullYear(); - - return date.toLocaleString(locale, { - month: 'short', - day: 'numeric', - ...(!fromCurrentYear && {year: 'numeric'}), - ...options - }); -} - -function formatDateTime(isoString, locale) { - return formatDate(isoString, locale, {hour: 'numeric', minute: '2-digit'}); -} diff --git a/entry_types/scrolled/package/src/review/Comment.module.css b/entry_types/scrolled/package/src/review/Comment.module.css index 2bcb0711e9..e2e24a36b5 100644 --- a/entry_types/scrolled/package/src/review/Comment.module.css +++ b/entry_types/scrolled/package/src/review/Comment.module.css @@ -3,8 +3,6 @@ align-items: center; gap: space(2); margin-bottom: space(1); - /* Keep clear of the chevron button the thread places in its corner. */ - padding-right: space(6); } /* Wraps as a block below the avatar, aligning with quote and body. */ @@ -22,11 +20,8 @@ color: var(--ui-on-surface-color); } -/* Keeps the menu button from ever wrapping onto a line of its own. */ -.meta { - display: flex; - align-items: center; - gap: space(1); +.headerMenu { + margin-left: auto; } .timestamp { diff --git a/entry_types/scrolled/package/src/review/CommentMenu.js b/entry_types/scrolled/package/src/review/CommentMenu.js index d122a6d526..c4b99f1d01 100644 --- a/entry_types/scrolled/package/src/review/CommentMenu.js +++ b/entry_types/scrolled/package/src/review/CommentMenu.js @@ -5,23 +5,17 @@ import { offset, flip, shift, autoUpdate } from '@floating-ui/react'; -import {useI18n, useFloatingPortalRoot} from 'pageflow-scrolled/frontend'; +import {useFloatingPortalRoot} from 'pageflow-scrolled/frontend'; import EllipsisIcon from './images/ellipsis.svg'; -import EditIcon from './images/edit.svg'; import styles from './CommentMenu.module.css'; -export function CommentMenu({onEdit}) { - const {t} = useI18n({locale: 'ui'}); +export function CommentMenu({label, items}) { const portalRoot = useFloatingPortalRoot(); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); - const items = [ - {icon: EditIcon, label: t('pageflow_scrolled.review.edit_comment'), onSelect: onEdit} - ]; - const elementsRef = useRef([]); const labelsRef = useRef([]); labelsRef.current = items.map(item => item.label); @@ -84,7 +78,7 @@ export function CommentMenu({onEdit}) { diff --git a/entry_types/scrolled/package/src/review/ReplyForm.js b/entry_types/scrolled/package/src/review/ReplyForm.js index 66ed67d3be..53a6898af2 100644 --- a/entry_types/scrolled/package/src/review/ReplyForm.js +++ b/entry_types/scrolled/package/src/review/ReplyForm.js @@ -10,7 +10,7 @@ import SendIcon from './images/send.svg'; import SpinnerIcon from './images/spinner.svg'; import styles from './ReplyForm.module.css'; -export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) { +export function ReplyForm({threadId, subjectType, subjectId, subjectRange, onSubmit}) { const {t} = useI18n({locale: 'ui'}); const {body, setBody, submitting} = useDraftedBody({threadId}); @@ -41,6 +41,8 @@ export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) { if (!hasText || submitting) return; createComment(body); + + if (onSubmit) onSubmit(); } return ( diff --git a/entry_types/scrolled/package/src/review/ReviewStateProvider.js b/entry_types/scrolled/package/src/review/ReviewStateProvider.js index d20d7348ec..e89eee4dc7 100644 --- a/entry_types/scrolled/package/src/review/ReviewStateProvider.js +++ b/entry_types/scrolled/package/src/review/ReviewStateProvider.js @@ -182,7 +182,9 @@ export function useCommentThread(threadId) { return context?.commentThreads.find(t => t.id === threadId); } -export function useCommentThreads({subjectType, subjectId, subjectRange, resolution = 'all'} = {}) { +export function useCommentThreads({ + subjectType, subjectId, subjectRange, resolution = 'all', revealedThreadId +} = {}) { const context = useContext(ReviewStateContext); const commentThreads = context ? context.commentThreads : []; const hasSubject = subjectType !== undefined; @@ -196,9 +198,10 @@ export function useCommentThreads({subjectType, subjectId, subjectRange, resolut thread.subjectId === subjectId && (!rangeKey || JSON.stringify(thread.subjectRange) === rangeKey))) && - matchesResolution(thread, resolution) + (matchesResolution(thread, resolution) || thread.id === revealedThreadId) ); - }, [commentThreads, hasSubject, subjectType, subjectId, subjectRange, resolution]); + }, [commentThreads, hasSubject, subjectType, subjectId, subjectRange, resolution, + revealedThreadId]); } export function matchesResolution(thread, resolution) { diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js index 1d21c33664..b7f4471739 100644 --- a/entry_types/scrolled/package/src/review/Thread.js +++ b/entry_types/scrolled/package/src/review/Thread.js @@ -1,15 +1,17 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; import classNames from 'classnames'; -import {useI18n} from 'pageflow-scrolled/frontend'; +import {useI18n, useLocale} from 'pageflow-scrolled/frontend'; import {AvatarStack} from './Avatar'; import {Comment} from './Comment'; +import {CommentMenu} from './CommentMenu'; +import {formatDate} from './formatDate'; import {ReplyForm} from './ReplyForm'; import {useCommentDraft} from './ReviewStateProvider'; import {useSubjectQuote} from './subjectQuote'; import {commentsWithOutdatedQuote} from './outdatedQuotes'; import {useMarkThreadReadWhenSeen} from './markThreadReadWhenSeen'; -import {useUnreadComments} from './unreadComments'; +import {useUnreadActivity} from './unreadActivity'; import {useScrollHighlightedThreadIntoView} from './scrollHighlightedThreadIntoView'; import ChevronIcon from './images/chevron.svg'; @@ -17,7 +19,7 @@ import ResolveIcon from './images/resolve.svg'; import UnresolveIcon from './images/unresolve.svg'; import styles from './Thread.module.css'; -export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, onClick, highlighted, showNewMarker, interactive = true}) { +export function Thread({thread, collapsed: collapsedProp, visibleReplyCount, onExpandReplies, onToggle, onReply, onResolve, onClick, highlighted, showUnreadMarker, markReadWhenHighlighted, interactive = true}) { const {t} = useI18n({locale: 'ui'}); const firstComment = thread.comments[0]; const replies = thread.comments.slice(1); @@ -29,25 +31,36 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o const repliesCollapsed = collapsed && replies.length > 0; - const newComments = useUnreadComments(thread); - const newReplyCount = useMemo(() => { - const ids = new Set(newComments.map(comment => comment.id)); - return replies.filter(reply => ids.has(reply.id)).length; - }, [newComments, replies]); + // A collapsed thread has nothing folded - hiding both the fold and the + // count would leave no way back into it. + const foldedReplyCount = visibleReplyCount === undefined || repliesCollapsed ? + 0 : + Math.max(replies.length - visibleReplyCount, 0); + const shownReplies = foldedReplyCount > 0 ? + replies.slice(foldedReplyCount) : + replies; + + const unread = useUnreadActivity(thread); + const unreadIds = useMemo( + () => new Set(unread.map(event => event.id)), + [unread] + ); - const hidesNewReplies = repliesCollapsed && newReplyCount > 0; + const unreadResolution = unread.some(event => event.resolution); + const unreadReplyCount = replies.filter(reply => unreadIds.has(reply.id)).length; + const hidesUnreadReplies = repliesCollapsed && unreadReplyCount > 0; + const unreadTopic = !!firstComment && unreadIds.has(firstComment.id); // Where the unseen part of the thread starts. Only meaningful with // seen comments above it: a thread that is new all through says so // through its dot instead of repeating it at the very top. - const firstNewReplyId = useMemo(() => { - if (!newComments.length || newComments[0].id === firstComment?.id) { + const firstUnreadReplyId = useMemo(() => { + if (!unread.length || unread[0].id === firstComment?.id) { return null; } - const ids = new Set(newComments.map(comment => comment.id)); - return replies.find(reply => ids.has(reply.id))?.id; - }, [newComments, replies, firstComment]); + return replies.find(reply => unreadIds.has(reply.id))?.id; + }, [unread, unreadIds, replies, firstComment]); // Kept here rather than per comment so that a thread never shows two // textareas at once: neither two comments being edited, nor an edit next @@ -73,7 +86,17 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o const ref = useRef(); const scrollHighlightedIntoView = useScrollHighlightedThreadIntoView(); - useMarkThreadReadWhenSeen({thread, ref, enabled: !repliesCollapsed}); + // Read state is one timestamp per thread, so a thread still keeping an + // unread comment out of sight would be marked read over comments never + // shown. Replies folded away for having been seen hide nothing. + const hiddenReplies = repliesCollapsed ? replies : replies.slice(0, foldedReplyCount); + const hidesUnread = hiddenReplies.some(reply => unreadIds.has(reply.id)); + + useMarkThreadReadWhenSeen({ + thread, + ref, + enabled: !hidesUnread && (highlighted || !markReadWhenHighlighted) + }); useEffect(() => { if (scrollHighlightedIntoView && highlighted && ref.current) { @@ -85,25 +108,18 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o
@@ -115,23 +131,34 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o showQuote={outdatedQuotes.has(firstComment.id)} {...editProps(firstComment)} />} - {repliesCollapsed && -