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,
|
type DirectMessage,
|
||||||
} from '@/hooks/useDirectMessages';
|
} from '@/hooks/useDirectMessages';
|
||||||
import { useCordnChat } from '@/hooks/useCordnChat';
|
import { useCordnChat } from '@/hooks/useCordnChat';
|
||||||
|
import { useCrewChatRead } from '@/hooks/useCrewChatRead';
|
||||||
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
|
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
|
||||||
import type { CordnMessage } from '@/lib/cordn/types';
|
import type { CordnMessage } from '@/lib/cordn/types';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -205,6 +206,37 @@ export function CrewChat({
|
||||||
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
|
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
|
||||||
const isPending = usingCordn ? cordn.isSending : nip17Sending;
|
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.
|
// Auto-scroll to newest message when the list grows.
|
||||||
const lastCount = useRef(0);
|
const lastCount = useRef(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -349,14 +381,24 @@ export function CrewChat({
|
||||||
{messages
|
{messages
|
||||||
?.filter((m) => canModerate || !hiddenIds?.has(m.id))
|
?.filter((m) => canModerate || !hiddenIds?.has(m.id))
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<CrewMessageBubble
|
<div key={m.id}>
|
||||||
key={m.id}
|
{m.id === firstUnreadId && (
|
||||||
message={m}
|
<div className="flex items-center gap-2 py-1.5" aria-label="New messages">
|
||||||
isOwn={m.pubkey === user.pubkey}
|
<span className="h-px flex-1 bg-teal-500/30" />
|
||||||
isHidden={hiddenIds?.has(m.id)}
|
<span className="text-[10px] font-bold uppercase tracking-wide text-teal-400">
|
||||||
canModerate={canModerate}
|
New
|
||||||
onHide={handleHideMessage}
|
</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>
|
</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