1.0.60
All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s

This commit is contained in:
Bryan1029384756
2026-04-14 20:03:54 -05:00
parent b7a4cf4ce8
commit 965048f7d2
47 changed files with 2558 additions and 135 deletions

View File

@@ -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(