SkateSpots/src/hooks/useDMRead.ts
shakespeare.diy c51d9f68d4 DM inbox: real unread indicators (was hardcoded to 0)
useConversations reported unreadCount: 0 for everything, so the inbox never
showed new messages. Add a lightweight, reliable unread signal:

- useDMRead: per-conversation, per-user last-read timestamps in localStorage
  (local-only; compares against the inner rumor's real created_at, not the
  fuzzed gift-wrap time, so it's accurate).
- DirectMessagesPage: a conversation is unread when its latest message is from
  the other person and newer than last-read — rendered as a red dot, bolder
  name, brighter preview text, and a subtle highlighted card.
- ConversationPage: opening a chat marks it read up to the newest message, so
  the dot clears on return (each page mounts fresh, so no stale state).

Scope: kept to the Messages page. Deliberately did NOT add a global nav badge,
which would require background-decrypting all gift wraps on every page — the
same heavy cost avoided for the crew bell.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 18:35:13 +07:00

43 lines
1.8 KiB
TypeScript

import { useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* Per-conversation "last read" timestamps (unix seconds) for direct messages,
* stored per-pubkey in localStorage. A conversation counts as unread when its
* latest message (from the OTHER person) is newer than this timestamp.
*
* Local-only by design: this is a lightweight "have I seen it" convenience, not
* a synced read-receipt broadcast to anyone. Nothing here leaves the device.
*
* NIP-17 note: gift-wrapped DMs are decrypted client-side, so we compare against
* the message's own `created_at`. (Gift-wrap timestamps can be slightly fuzzed,
* but the inner rumor's `created_at` — what we store on DirectMessage — is the
* sender's real time, so this comparison is reliable for read state.)
*/
export function useDMRead() {
const { user } = useCurrentUser();
const key = user ? `dm-last-read:${user.pubkey}` : 'dm-last-read:anon';
const [map, setMap] = useLocalStorage<Record<string, number>>(key, {});
/** Mark a conversation (keyed by the other party's pubkey) read as of `ts`. */
const markRead = useCallback(
(conversationWith: string, ts?: number) => {
const at = ts ?? Math.floor(Date.now() / 1000);
setMap((prev) => {
const key = conversationWith.toLowerCase();
if ((prev[key] ?? 0) >= at) return prev; // never move backwards
return { ...prev, [key]: at };
});
},
[setMap],
);
/** Last-read unix seconds for a conversation, or 0 if never opened. */
const getLastRead = useCallback(
(conversationWith: string) => map[conversationWith.toLowerCase()] ?? 0,
[map],
);
return { lastReadMap: map, markRead, getLastRead };
}