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:
parent
f37d0184d3
commit
e698e4880f
2 changed files with 79 additions and 8 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
29
src/hooks/useCrewChatRead.ts
Normal file
29
src/hooks/useCrewChatRead.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue