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>
This commit is contained in:
shakespeare.diy 2026-09-03 18:35:13 +07:00
parent 3833625bde
commit c51d9f68d4
3 changed files with 92 additions and 8 deletions

43
src/hooks/useDMRead.ts Normal file
View file

@ -0,0 +1,43 @@
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 };
}

View file

@ -12,6 +12,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages'; import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
import { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
@ -97,6 +98,7 @@ export default function ConversationPage() {
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || ''); const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage(); const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
const { markRead } = useDMRead();
const author = useAuthor(otherPubkey || undefined); const author = useAuthor(otherPubkey || undefined);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
const displayName = metadata?.name ?? (otherPubkey ? genUserName(otherPubkey) : 'Unknown'); const displayName = metadata?.name ?? (otherPubkey ? genUserName(otherPubkey) : 'Unknown');
@ -112,6 +114,14 @@ export default function ConversationPage() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]); }, [messages]);
// Viewing a conversation marks it read up to its newest message, so the
// unread dot on the inbox clears the moment you open it.
useEffect(() => {
if (!otherPubkey || !messages || messages.length === 0) return;
const newest = messages.reduce((max, m) => Math.max(max, m.createdAt), 0);
markRead(otherPubkey, newest);
}, [otherPubkey, messages, markRead]);
const handleSend = () => { const handleSend = () => {
if (!newMessage.trim() || !otherPubkey || isSending) return; if (!newMessage.trim() || !otherPubkey || isSending) return;

View file

@ -12,6 +12,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversations } from '@/hooks/useDirectMessages'; import { useConversations } from '@/hooks/useDirectMessages';
import { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
@ -60,9 +61,10 @@ function PhishingWarningBanner() {
} }
// Conversation preview component // Conversation preview component
function ConversationPreview({ pubkey, lastMessage }: { function ConversationPreview({ pubkey, lastMessage, unread }: {
pubkey: string; pubkey: string;
lastMessage?: { content: string; createdAt: number; pubkey: string }; lastMessage?: { content: string; createdAt: number; pubkey: string };
unread?: boolean;
}) { }) {
const author = useAuthor(pubkey); const author = useAuthor(pubkey);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -79,7 +81,8 @@ function ConversationPreview({ pubkey, lastMessage }: {
<Link to={`/messages/${nip19.npubEncode(pubkey)}`}> <Link to={`/messages/${nip19.npubEncode(pubkey)}`}>
<Card className={cn( <Card className={cn(
"hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800", "hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800",
"hover:border-[#ff0040]/50 active:scale-[0.99]" "hover:border-[#ff0040]/50 active:scale-[0.99]",
unread && "border-[#ff0040]/40 bg-[#ff0040]/[0.04]"
)}> )}>
<CardContent className="p-3 sm:p-4"> <CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2.5 sm:gap-3"> <div className="flex items-center gap-2.5 sm:gap-3">
@ -91,13 +94,31 @@ function ConversationPreview({ pubkey, lastMessage }: {
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="font-bold text-white truncate text-sm sm:text-base">{displayName}</p> <p className={cn(
"truncate text-sm sm:text-base",
unread ? "font-black text-white" : "font-bold text-white"
)}>{displayName}</p>
<div className="flex items-center gap-2 flex-shrink-0">
{timeAgo && ( {timeAgo && (
<span className="text-[10px] sm:text-xs text-gray-500 flex-shrink-0">{timeAgo}</span> <span className={cn(
"text-[10px] sm:text-xs",
unread ? "text-[#ff0040]" : "text-gray-500"
)}>{timeAgo}</span>
)}
{unread && (
<span
className="h-2.5 w-2.5 rounded-full bg-[#ff0040] shadow-[0_0_6px_rgba(255,0,64,0.9)]"
aria-label="Unread messages"
title="Unread messages"
/>
)} )}
</div> </div>
</div>
{lastMessage && ( {lastMessage && (
<p className="text-xs sm:text-sm text-gray-400 truncate"> <p className={cn(
"text-xs sm:text-sm truncate",
unread ? "text-gray-200 font-medium" : "text-gray-400"
)}>
{isFromMe && <span className="text-gray-500">You: </span>} {isFromMe && <span className="text-gray-500">You: </span>}
{lastMessage.content} {lastMessage.content}
</p> </p>
@ -130,8 +151,17 @@ export default function DirectMessagesPage() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
const { data: conversations, isLoading, error } = useConversations(); const { data: conversations, isLoading, error } = useConversations();
const { getLastRead } = useDMRead();
const [showNewMessage, setShowNewMessage] = useState(false); const [showNewMessage, setShowNewMessage] = useState(false);
// A conversation is unread when its latest message is from the OTHER person
// and newer than the last time we opened that conversation.
const isUnread = (conv: { pubkey: string; lastMessage?: { createdAt: number; pubkey: string } }) => {
const lm = conv.lastMessage;
if (!lm || lm.pubkey === user?.pubkey) return false;
return lm.createdAt > getLastRead(conv.pubkey);
};
useSeoMeta({ useSeoMeta({
title: 'Messages - SkateSpots', title: 'Messages - SkateSpots',
description: 'Private encrypted messages with the skateboarding community', description: 'Private encrypted messages with the skateboarding community',
@ -258,6 +288,7 @@ export default function DirectMessagesPage() {
key={conv.pubkey} key={conv.pubkey}
pubkey={conv.pubkey} pubkey={conv.pubkey}
lastMessage={conv.lastMessage} lastMessage={conv.lastMessage}
unread={isUnread(conv)}
/> />
)) ))
) : ( ) : (