Crew chat: subtle "New" divider for messages since last visit

A returning member couldn't tell new messages from old ones. Add the universal
unread-divider pattern (Slack/Discord-style) directly inside the chat:

- useCrewChatRead: per-crew, per-pubkey last-seen timestamp in localStorage
  (local-only, zero network cost, works for both NIP-17 and Cordn transports).
- CrewChat snapshots last-seen once per open so the divider stays put while
  reading, draws a minimal "New" line above the first message since last visit,
  and quietly advances last-seen to the newest rendered message.

No new fetches, no config, nothing for the user to manage — it just works.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 15:58:54 +07:00
parent f37d0184d3
commit e698e4880f
2 changed files with 79 additions and 8 deletions

View file

@ -22,6 +22,7 @@ import {
type DirectMessage,
} from '@/hooks/useDirectMessages';
import { useCordnChat } from '@/hooks/useCordnChat';
import { useCrewChatRead } from '@/hooks/useCrewChatRead';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import type { CordnMessage } from '@/lib/cordn/types';
import { genUserName } from '@/lib/genUserName';
@ -205,6 +206,37 @@ export function CrewChat({
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
const isPending = usingCordn ? cordn.isSending : nip17Sending;
// ── "New messages" divider ──
// Snapshot the last-seen time ONCE per open so the divider stays put while the
// member reads, instead of jumping as fresh messages stream in. Anything after
// that timestamp (and not authored by me) is "new since your last visit".
const { lastSeen, markSeen } = useCrewChatRead(crewCoord);
const seenAtOpenRef = useRef<number | null>(null);
if (seenAtOpenRef.current === null && lastSeen !== undefined) {
seenAtOpenRef.current = lastSeen;
}
const seenAtOpen = seenAtOpenRef.current ?? 0;
// The first message id that falls after the snapshot — the divider sits above
// it. Skip when there's nothing older (a brand-new reader sees no divider).
const firstUnreadId = useMemo(() => {
if (!user || seenAtOpen <= 0) return null;
const hasOlder = messages.some((m) => m.createdAt <= seenAtOpen);
if (!hasOlder) return null;
const firstNew = messages.find(
(m) => m.createdAt > seenAtOpen && m.pubkey !== user.pubkey,
);
return firstNew?.id ?? null;
}, [messages, seenAtOpen, user]);
// Keep the stored "last seen" caught up to the newest message we've rendered,
// so the next visit only flags genuinely newer messages.
useEffect(() => {
if (messages.length === 0) return;
const newest = messages.reduce((max, m) => Math.max(max, m.createdAt), 0);
if (newest > (lastSeen ?? 0)) markSeen(newest);
}, [messages, lastSeen, markSeen]);
// Auto-scroll to newest message when the list grows.
const lastCount = useRef(0);
useEffect(() => {
@ -349,14 +381,24 @@ export function CrewChat({
{messages
?.filter((m) => canModerate || !hiddenIds?.has(m.id))
.map((m) => (
<CrewMessageBubble
key={m.id}
message={m}
isOwn={m.pubkey === user.pubkey}
isHidden={hiddenIds?.has(m.id)}
canModerate={canModerate}
onHide={handleHideMessage}
/>
<div key={m.id}>
{m.id === firstUnreadId && (
<div className="flex items-center gap-2 py-1.5" aria-label="New messages">
<span className="h-px flex-1 bg-teal-500/30" />
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
New
</span>
<span className="h-px flex-1 bg-teal-500/30" />
</div>
)}
<CrewMessageBubble
message={m}
isOwn={m.pubkey === user.pubkey}
isHidden={hiddenIds?.has(m.id)}
canModerate={canModerate}
onHide={handleHideMessage}
/>
</div>
))}
</div>

View file

@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Per-crew "last seen the chat" timestamp (unix seconds), stored per-pubkey +
* per-crew in localStorage. Used only to draw a subtle "new messages" divider
* when a member returns to a chat — no network cost, works for every transport.
*
* We deliberately keep this local-only: crew chat is casual and the divider is a
* convenience, not a synced read-receipt. Nothing here leaves the device.
*/
export function useCrewChatRead(crewCoord: string | undefined) {
const { user } = useCurrentUser();
const key =
user && crewCoord
? `crew-chat-last-seen:${user.pubkey}:${crewCoord}`
: 'crew-chat-last-seen:anon';
const [lastSeen, setLastSeen] = useLocalStorage<number>(key, 0);
const markSeen = useCallback(
(ts?: number) => {
setLastSeen(ts ?? Math.floor(Date.now() / 1000));
},
[setLastSeen],
);
return { lastSeen, markSeen };
}