From e698e4880ff4c507f36b4a454c74dac80d39122c Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 15:58:54 +0700 Subject: [PATCH] Crew chat: subtle "New" divider for messages since last visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/CrewChat.tsx | 58 +++++++++++++++++++++++++++++++----- src/hooks/useCrewChatRead.ts | 29 ++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useCrewChatRead.ts diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index 9bac48e..5cf6f90 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -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(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) => ( - +
+ {m.id === firstUnreadId && ( +
+ + + New + + +
+ )} + +
))} diff --git a/src/hooks/useCrewChatRead.ts b/src/hooks/useCrewChatRead.ts new file mode 100644 index 0000000..3968508 --- /dev/null +++ b/src/hooks/useCrewChatRead.ts @@ -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(key, 0); + + const markSeen = useCallback( + (ts?: number) => { + setLastSeen(ts ?? Math.floor(Date.now() / 1000)); + }, + [setLastSeen], + ); + + return { lastSeen, markSeen }; +}