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:
parent
3833625bde
commit
c51d9f68d4
3 changed files with 92 additions and 8 deletions
43
src/hooks/useDMRead.ts
Normal file
43
src/hooks/useDMRead.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue