This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
@@ -97,6 +97,21 @@ function DayDivider({ timestamp }: { timestamp: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Red "NEW" line inserted between the last message the viewer has
|
||||
* already read and the first unseen one. Anchored to a snapshot of
|
||||
* `lastReadTimestamp` taken when the channel opened, so live new
|
||||
* messages arriving during the session don't push the divider
|
||||
* further down.
|
||||
*/
|
||||
function NewMessagesDivider() {
|
||||
return (
|
||||
<div className={styles.newDivider} role="separator">
|
||||
<span className={styles.newDividerBadge}>New</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
const { crypto } = usePlatform();
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -124,6 +139,99 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
userId ? { userId: userId as any } : 'skip',
|
||||
);
|
||||
|
||||
// ── Read-state + "NEW" divider plumbing ─────────────────────────
|
||||
//
|
||||
// `readState` is the server's live record of the latest message
|
||||
// timestamp this user has acknowledged for the current channel.
|
||||
// `readSnapshot` captures the value at the time the channel was
|
||||
// opened so the divider stays anchored in place even after we
|
||||
// flush a later `markRead` mutation during the session. Stored
|
||||
// as state (not a ref) so the first render after the query
|
||||
// resolves picks it up deterministically.
|
||||
const readState = useQuery(
|
||||
api.readState.getReadState,
|
||||
userId && channelId
|
||||
? { userId: userId as any, channelId: channelId as any }
|
||||
: 'skip',
|
||||
);
|
||||
const markRead = useMutation(api.readState.markRead);
|
||||
const [readSnapshot, setReadSnapshot] = useState<{
|
||||
channelId: string;
|
||||
lastRead: number;
|
||||
} | null>(null);
|
||||
// Tracks the freshest timestamp we've observed in this channel so
|
||||
// the mark-read flush has something to send. Pure ref — updates
|
||||
// should never trigger a re-render.
|
||||
const latestSeenTimestampRef = useRef<number>(0);
|
||||
// Debounce timer for batched mark-read flushes.
|
||||
const markReadTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Reset the snapshot whenever the user changes channels.
|
||||
useLayoutEffect(() => {
|
||||
setReadSnapshot(null);
|
||||
latestSeenTimestampRef.current = 0;
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
markReadTimerRef.current = null;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
// First time a non-null `readState` arrives for the current
|
||||
// channel, lock it in as the divider anchor. `null` (no stored
|
||||
// read state yet — brand-new channel) is treated as "0" so the
|
||||
// divider appears the moment anyone posts.
|
||||
useEffect(() => {
|
||||
if (!channelId) return;
|
||||
if (readSnapshot?.channelId === channelId) return;
|
||||
if (readState === undefined) return;
|
||||
setReadSnapshot({
|
||||
channelId,
|
||||
lastRead: readState?.lastReadTimestamp ?? 0,
|
||||
});
|
||||
}, [readState, channelId, readSnapshot]);
|
||||
|
||||
/** Fire-and-forget mark-read flush. Gated by:
|
||||
* - an authenticated user
|
||||
* - a channel loaded
|
||||
* - the window being visible (otherwise we keep the snapshot
|
||||
* and the NEW line so the user sees it when they return)
|
||||
* - the user being pinned to the bottom of the scroller
|
||||
* - the server's stored timestamp being strictly older than
|
||||
* the freshest message we've observed */
|
||||
const flushMarkRead = useCallback(() => {
|
||||
if (!userId || !channelId) return;
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!pinnedRef.current) return;
|
||||
const ts = latestSeenTimestampRef.current;
|
||||
if (!ts) return;
|
||||
const serverTs = readState?.lastReadTimestamp ?? 0;
|
||||
if (ts <= serverTs) return;
|
||||
void markRead({
|
||||
userId: userId as any,
|
||||
channelId: channelId as any,
|
||||
lastReadTimestamp: ts,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, channelId, readState, markRead]);
|
||||
|
||||
const scheduleMarkRead = useCallback(() => {
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
}
|
||||
// 600ms debounce — batches bursts of new messages into a
|
||||
// single mutation without feeling laggy to users who watch
|
||||
// the sidebar unread dot.
|
||||
markReadTimerRef.current = window.setTimeout(() => {
|
||||
markReadTimerRef.current = null;
|
||||
flushMarkRead();
|
||||
}, 600);
|
||||
}, [flushMarkRead]);
|
||||
|
||||
// Walk every bundle we have, decrypt the ones tagged for this
|
||||
// channel, and build a {version → keyHex} map. A single bundle's
|
||||
// plaintext is a JSON object mapping channelId → keyHex (legacy
|
||||
@@ -683,6 +791,58 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
return list;
|
||||
}, [groups, pollsInChannel]);
|
||||
|
||||
// Whenever the timeline grows past the freshest timestamp we've
|
||||
// seen, update the ref and schedule a debounced mark-read. The
|
||||
// ref-only update doesn't cause re-renders — it just feeds the
|
||||
// mark-read flush with an up-to-date target.
|
||||
useEffect(() => {
|
||||
if (items.length === 0) return;
|
||||
const newest = items[items.length - 1].ts;
|
||||
if (newest > latestSeenTimestampRef.current) {
|
||||
latestSeenTimestampRef.current = newest;
|
||||
scheduleMarkRead();
|
||||
}
|
||||
}, [items, scheduleMarkRead]);
|
||||
|
||||
// Window visibility → when the tab comes back into focus, flush
|
||||
// any pending mark-read so the sidebar dot disappears without
|
||||
// needing a new message to land.
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleMarkRead();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('focus', onVisibility);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('focus', onVisibility);
|
||||
};
|
||||
}, [scheduleMarkRead]);
|
||||
|
||||
// Channel unmount (navigated elsewhere / logged out) → fire a
|
||||
// final mark-read with whatever the latest observed timestamp
|
||||
// is, bypassing the debounce. Matches Fluxer's "leaving a
|
||||
// channel marks it read" UX so the sidebar dot doesn't linger.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (markReadTimerRef.current !== null) {
|
||||
window.clearTimeout(markReadTimerRef.current);
|
||||
markReadTimerRef.current = null;
|
||||
}
|
||||
if (!userId || !channelId) return;
|
||||
const ts = latestSeenTimestampRef.current;
|
||||
if (!ts) return;
|
||||
void markRead({
|
||||
userId: userId as any,
|
||||
channelId: channelId as any,
|
||||
lastReadTimestamp: ts,
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channelId]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
|
||||
<div className={styles.scroller}>
|
||||
@@ -711,17 +871,48 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
)}
|
||||
{(() => {
|
||||
// Walk the timeline once, inserting a `DayDivider`
|
||||
// whenever the calendar day changes. `lastTs` tracks
|
||||
// the most-recent rendered item so two consecutive
|
||||
// items on the same day skip the divider.
|
||||
// whenever the calendar day changes and a single
|
||||
// `NewMessagesDivider` before the first item whose
|
||||
// timestamp is strictly greater than the snapshot
|
||||
// of `lastReadTimestamp` taken when the channel
|
||||
// opened. `lastTs` tracks the most-recent rendered
|
||||
// item so two consecutive items on the same day
|
||||
// skip the date divider.
|
||||
//
|
||||
// The NEW line never shows for the viewer's own
|
||||
// messages — if you're the one that sent it, you
|
||||
// obviously already "saw" it. A message group
|
||||
// authored entirely by the current user is skipped
|
||||
// when looking for the divider boundary, so the
|
||||
// line stays anchored at the first message from
|
||||
// somebody else.
|
||||
let lastTs: number | null = null;
|
||||
const out: React.ReactNode[] = [];
|
||||
const snapshot =
|
||||
readSnapshot?.channelId === channelId
|
||||
? readSnapshot.lastRead
|
||||
: null;
|
||||
let newLinePlaced = snapshot === null;
|
||||
const isOwnGroup = (item: TimelineItem): boolean => {
|
||||
if (item.kind !== 'group') return false;
|
||||
if (!userId) return false;
|
||||
return item.group.every((m) => m.senderId === userId);
|
||||
};
|
||||
for (const item of items) {
|
||||
if (lastTs === null || !isSameDay(lastTs, item.ts)) {
|
||||
out.push(
|
||||
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!newLinePlaced &&
|
||||
snapshot !== null &&
|
||||
item.ts > snapshot &&
|
||||
!isOwnGroup(item)
|
||||
) {
|
||||
out.push(<NewMessagesDivider key={`new-${item.key}`} />);
|
||||
newLinePlaced = true;
|
||||
}
|
||||
lastTs = item.ts;
|
||||
if (item.kind === 'group') {
|
||||
out.push(
|
||||
|
||||
Reference in New Issue
Block a user