diff --git a/src/hooks/useDMRead.ts b/src/hooks/useDMRead.ts new file mode 100644 index 0000000..96b2ed5 --- /dev/null +++ b/src/hooks/useDMRead.ts @@ -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>(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 }; +} diff --git a/src/pages/ConversationPage.tsx b/src/pages/ConversationPage.tsx index 84d1039..4aafdf3 100644 --- a/src/pages/ConversationPage.tsx +++ b/src/pages/ConversationPage.tsx @@ -12,6 +12,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages'; +import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { formatDistanceToNow } from 'date-fns'; @@ -97,6 +98,7 @@ export default function ConversationPage() { const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || ''); const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage(); + const { markRead } = useDMRead(); const author = useAuthor(otherPubkey || undefined); const metadata = author.data?.metadata; const displayName = metadata?.name ?? (otherPubkey ? genUserName(otherPubkey) : 'Unknown'); @@ -112,6 +114,14 @@ export default function ConversationPage() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [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 = () => { if (!newMessage.trim() || !otherPubkey || isSending) return; diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index 3fe6a38..909ab63 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -12,6 +12,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useConversations } from '@/hooks/useDirectMessages'; +import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { useLocalStorage } from '@/hooks/useLocalStorage'; @@ -60,9 +61,10 @@ function PhishingWarningBanner() { } // Conversation preview component -function ConversationPreview({ pubkey, lastMessage }: { - pubkey: string; +function ConversationPreview({ pubkey, lastMessage, unread }: { + pubkey: string; lastMessage?: { content: string; createdAt: number; pubkey: string }; + unread?: boolean; }) { const author = useAuthor(pubkey); const { user } = useCurrentUser(); @@ -79,7 +81,8 @@ function ConversationPreview({ pubkey, lastMessage }: {
@@ -91,13 +94,31 @@ function ConversationPreview({ pubkey, lastMessage }: {
-

{displayName}

- {timeAgo && ( - {timeAgo} - )} +

{displayName}

+
+ {timeAgo && ( + {timeAgo} + )} + {unread && ( + + )} +
{lastMessage && ( -

+

{isFromMe && You: } {lastMessage.content}

@@ -130,8 +151,17 @@ export default function DirectMessagesPage() { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { data: conversations, isLoading, error } = useConversations(); + const { getLastRead } = useDMRead(); 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({ title: 'Messages - SkateSpots', description: 'Private encrypted messages with the skateboarding community', @@ -258,6 +288,7 @@ export default function DirectMessagesPage() { key={conv.pubkey} pubkey={conv.pubkey} lastMessage={conv.lastMessage} + unread={isUnread(conv)} /> )) ) : (