feat: envelope-only DM unread in the bell

This commit is contained in:
SkateSpots 2026-10-28 22:43:52 +07:00
parent b2bc718a85
commit 4ae6b1d5ea
31 changed files with 198 additions and 5 deletions

View file

@ -2,7 +2,7 @@ import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight, Handshake } from 'lucide-react';
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart, Radio, ArrowRight, Handshake, MessageCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
@ -18,6 +18,7 @@ import { ImageWithFallback } from '@/components/ImageWithFallback';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
@ -226,6 +227,7 @@ export function NotificationBell({
isLoading: cordnJoinLoading,
dismiss: dismissCordnJoin,
} = useCordnJoinNotifications(open);
const { unreadCount: unreadDms, markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
// Buying/selling steps: an offer on your listing, a counter it's your turn to
@ -285,7 +287,7 @@ export function NotificationBell({
);
// Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it.
const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades;
const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter + unreadTrades + unreadDms;
// Opening the bell marks social + crew as read (and newsletter, since the
// News tab is right there). Newsletter read state also clears on the /newsletter page.
@ -359,7 +361,7 @@ export function NotificationBell({
{badgeTotal > 0 ? (
<button
type="button"
onClick={markAllRead}
onClick={() => { markAllRead(); markDmEnvelopesSeen(); }}
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-black uppercase tracking-wide text-gray-300 transition-all active:scale-95 hover:border-teal-400/40 hover:bg-teal-500/10 hover:text-teal-300"
>
<CheckCheck className="h-3 w-3" /> {t('socialUi.bell.markRead')}
@ -417,10 +419,26 @@ export function NotificationBell({
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
</div>
)}
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && (
{!isLoading && crewTotal === 0 && activity.length === 0 && newsletterIssues.length === 0 && tradeNotifs.length === 0 && unreadDms === 0 && (
<EmptyState label={t('socialUi.bell.emptyAll')} />
)}
<div className="space-y-2.5">
{unreadDms > 0 && (
<Link
to="/messages"
onClick={() => setOpen(false)}
className="flex items-center gap-3 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/[0.06] p-3 transition-colors hover:border-[#ff0040]/60 hover:bg-[#ff0040]/10"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[#ff0040]/15">
<MessageCircle className="h-4 w-4 text-[#ff0040]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-black text-white">{t('socialUi.dms.title')}</p>
<p className="text-xs text-gray-400">{t('socialUi.bell.dmsUnread', { count: unreadDms })}</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-[#ff0040]" />
</Link>
)}
{/* Buying/selling steps first — they're the most time-sensitive. */}
{tradeNotifs.map((n) => (
<TradeNotificationCard

View file

@ -10,6 +10,7 @@ import { useAppContext } from './useAppContext';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useRelays } from '@/lib/relays';
import { SKATE_RELAY } from '@/lib/types';
import { markOwnDmWraps } from '@/lib/dmOwnWraps';
import type { RelayMetadata } from '@/contexts/AppContext';
import {
loadDmDecryptCache,
@ -427,6 +428,10 @@ export function useSendDirectMessage() {
giftWraps.push(...(await Promise.all(allTargets.map(createSealAndWrap))));
}
// Remember our own gift wraps so the decrypt-free DM badge does not count
// the sender's self-copy as an incoming message.
markOwnDmWraps(giftWraps.map((wrap) => wrap.id));
// 4. Work out where to deliver. NIP-17: publish the wrap to each
// RECIPIENT's inbox relays (their kind 10050), plus our own inbox so we
// can read our sent copy.

View file

@ -0,0 +1,67 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDmEnvelopes } from '@/hooks/useDmEnvelopes';
import { getOwnDmWrapIds } from '@/lib/dmOwnWraps';
const SEEN_KEY = 'skatespots:dm-envelope-seen';
const SEEN_EVENT = 'skatespots:dm-envelope-seen-change';
const MAX_SEEN = 500;
function readSeen(): string[] {
try {
const raw = localStorage.getItem(SEEN_KEY);
const parsed = raw ? JSON.parse(raw) as unknown : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}
/**
* Lightweight, decrypt-free DM unread indicator for the bell.
*
* It counts NIP-17 envelope ids not seen on this device. The first ever run
* primes history so existing messages don't appear as brand-new; opening the
* Messages page acknowledges the current envelopes. No message text is
* decrypted and Amber is never prompted.
*/
export function useDmUnreadSummary(enabled = true) {
const { data: envelopes = [] } = useDmEnvelopes(enabled);
const [seen, setSeen] = useState<string[]>(readSeen);
const primedRef = useRef<boolean>(typeof localStorage !== 'undefined' && localStorage.getItem(SEEN_KEY) !== null);
const [primed, setPrimed] = useState(primedRef.current);
useEffect(() => {
const onChange = (event: Event) => {
const detail = (event as CustomEvent<string[]>).detail;
if (Array.isArray(detail)) setSeen(detail);
};
window.addEventListener(SEEN_EVENT, onChange);
return () => window.removeEventListener(SEEN_EVENT, onChange);
}, []);
const seenSet = useMemo(() => new Set(seen), [seen]);
const unreadCount = useMemo(() => {
if (!primed) return 0;
const own = getOwnDmWrapIds();
return envelopes.filter((envelope) => !seenSet.has(envelope.id) && !own.has(envelope.id)).length;
}, [envelopes, primed, seenSet]);
useEffect(() => {
if (primedRef.current || envelopes.length === 0) return;
primedRef.current = true;
setPrimed(true);
const ids = envelopes.map((envelope) => envelope.id).slice(-MAX_SEEN);
setSeen(ids);
try { localStorage.setItem(SEEN_KEY, JSON.stringify(ids)); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent(SEEN_EVENT, { detail: ids }));
}, [envelopes]);
const markSeen = useCallback(() => {
const ids = envelopes.map((envelope) => envelope.id).slice(-MAX_SEEN);
setSeen(ids);
try { localStorage.setItem(SEEN_KEY, JSON.stringify(ids)); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent(SEEN_EVENT, { detail: ids }));
}, [envelopes]);
return { unreadCount, markSeen };
}

View file

@ -39,6 +39,7 @@ const batch2 = {
tabSocial: "Social",
tabNews: "Notícies",
tabCrews: "Crews",
dmsUnread: "{{count}} missatge(s) xifrat(s) nou(s)",
checkingUpdates: "Buscant actualitzacions…",
checkingTrades: "Buscant ofertes i intercanvis…",
loadingDrops: "Carregant les últimes novetats…",

View file

@ -90,6 +90,9 @@ const drift = {
"form": {
"cordnGroup": "Skupina Cordn",
"cordnGroupHint": "Volitelné: vlož odkaz na skupinu ze Staircase nebo Cordn web. Tvoje crew tak získá skutečný end-to-end MLS chat hostovaný na našem koordinátoru."
},
"bell": {
"dmsUnread": "{{count}} nových šifrovaných zpráv"
}
},
"settingsUi": {

View file

@ -93,6 +93,9 @@ const drift = {
cordnGroup: "Cordn-Gruppe",
cordnGroupHint: "Optional: Füge den Gruppenlink aus Staircase oder Cordn Web ein. Deine Crew erhält dann echten Ende-zu-Ende-MLS-Chat, gehostet auf unserem Koordinator.",
},
bell: {
dmsUnread: "{{count}} neue verschlüsselte Nachricht(en)",
},
},
settingsUi: {
loginDialog: {

View file

@ -95,6 +95,9 @@ const drift = {
cordnGroup: "Ομάδα Cordn",
cordnGroupHint: "Προαιρετικό: επικόλλησε τον σύνδεσμο της ομάδας από Staircase ή Cordn web. Το crew σου αποκτά τότε πραγματική end-to-end συνομιλία MLS, φιλοξενούμενη στον συντονιστή μας.",
},
bell: {
dmsUnread: "{{count}} νέα κρυπτογραφημένα μηνύματα",
},
},
settingsUi: {
loginDialog: {

View file

@ -3732,6 +3732,7 @@ socialUi: {
tabSocial: 'Social',
tabNews: 'News',
tabCrews: 'Crews',
dmsUnread: '{{count}} new encrypted message(s)',
checkingUpdates: 'Checking for updates…',
checkingTrades: 'Checking offers and trades…',
loadingDrops: 'Loading the latest drops…',

View file

@ -3732,6 +3732,7 @@ socialUi: {
tabSocial: 'Social',
tabNews: 'Noticias',
tabCrews: 'Peñas',
dmsUnread: '{{count}} mensaje(s) cifrado(s) nuevo(s)',
checkingUpdates: 'Buscando novedades…',
checkingTrades: 'Revisando ofertas y tratos…',
loadingDrops: 'Cargando las últimas entregas…',

View file

@ -39,6 +39,7 @@ const batch2 = {
tabSocial: "Soziala",
tabNews: "Berriak",
tabCrews: "Koadrilak",
dmsUnread: "{{count}} mezu zifratu berri",
checkingUpdates: "Eguneratzeak egiaztatzen…",
checkingTrades: "Eskaintzak eta trukeak egiaztatzen…",
loadingDrops: "Azken bidalketak kargatzen…",

View file

@ -93,6 +93,9 @@ const drift = {
cordnGroup: "Groupe Cordn",
cordnGroupHint: "Facultatif : collez le lien du groupe depuis Staircase ou Cordn web. Votre Crew obtient alors un vrai chat MLS de bout en bout, hébergé sur notre coordinateur.",
},
bell: {
dmsUnread: "{{count}} nouveau(x) message(s) chiffré(s)",
},
},
settingsUi: {
loginDialog: {

View file

@ -39,6 +39,7 @@ const batch2 = {
tabSocial: "Social",
tabNews: "Novidades",
tabCrews: "Crews",
dmsUnread: "{{count}} mensaxe(s) cifrado(s) novo(s)",
checkingUpdates: "Buscando actualizacións…",
checkingTrades: "Comprobando ofertas e intercambios…",
loadingDrops: "Cargando as últimas novidades…",

View file

@ -39,6 +39,7 @@ const batch2 = {
"tabSocial": "सोशल",
"tabNews": "समाचार",
"tabCrews": "क्रू",
"dmsUnread": "{{count}} नए एन्क्रिप्टेड संदेश",
"checkingUpdates": "अपडेट जाँच रहे हैं…",
"checkingTrades": "ऑफ़र और ट्रेड जाँच रहे हैं…",
"loadingDrops": "नए ड्रॉप लोड हो रहे हैं…",

View file

@ -87,6 +87,9 @@ const driftStrings = {
cordnGroup: 'Grup Cordn',
cordnGroupHint: 'Opsional: tempel tautan grup dari Staircase atau Cordn web. Crew-mu lalu mendapat chat MLS ujung-ke-ujung sungguhan, dihosting di koordinator kami.',
},
bell: {
dmsUnread: '{{count}} pesan terenkripsi baru',
},
},
settingsUi: {
loginDialog: {

View file

@ -93,6 +93,9 @@ const drift = {
cordnGroup: "Gruppo Cordn",
cordnGroupHint: "Opzionale: incolla il link del gruppo da Staircase o Cordn web. La tua crew ottiene così una vera chat MLS end-to-end, ospitata sul nostro coordinatore.",
},
bell: {
dmsUnread: "{{count}} nuovo/i messaggio/i cifrato/i",
},
},
settingsUi: {
loginDialog: {

View file

@ -103,6 +103,9 @@ const drift: Dict = {
cordnGroup: 'Cordnグループ',
cordnGroupHint: '任意:StaircaseまたはCordnウェブからグループリンクを貼り付けてください。するとクルーに、私たちのコーディネーターでホストされる本物のエンドツーエンドMLSチャットが与えられます。',
},
bell: {
dmsUnread: '{{count}}件の新しい暗号化メッセージ',
},
},
settingsUi: {
loginDialog: {

View file

@ -103,6 +103,9 @@ const drift: Dict = {
cordnGroup: 'Cordn 그룹',
cordnGroupHint: '선택 사항: Staircase 또는 Cordn 웹의 그룹 링크를 붙여넣으세요. 그러면 크루가 우리 코디네이터에서 호스팅되는 실제 종단 간 MLS 채팅을 갖게 됩니다.',
},
bell: {
dmsUnread: '새 암호화 메시지 {{count}}개',
},
},
settingsUi: {
loginDialog: {

View file

@ -90,6 +90,9 @@ const drift = {
"form": {
"cordnGroup": "Cordn-groep",
"cordnGroupHint": "Optioneel: plak de grouplink van Staircase of Cordn web. Je crew krijgt dan echte end-to-end MLS-chat, gehost op onze coördinator."
},
"bell": {
"dmsUnread": "{{count}} nieuw(e) versleutelde bericht(en)"
}
},
"settingsUi": {

View file

@ -90,6 +90,9 @@ const drift = {
"form": {
"cordnGroup": "Grupa Cordn",
"cordnGroupHint": "Opcjonalnie: wklej link do grupy ze Staircase lub Cordn web. Twoje crew dostanie wtedy prawdziwy czat MLS end-to-end, hostowany na naszym koordynatorze."
},
"bell": {
"dmsUnread": "{{count}} nowych zaszyfrowanych wiadomości"
}
},
"settingsUi": {

View file

@ -93,6 +93,9 @@ const drift = {
cordnGroup: "Grupo Cordn",
cordnGroupHint: "Opcional: cole o link do grupo do Staircase ou do Cordn web. Sua crew passa a ter um chat MLS real de ponta a ponta, hospedado no nosso coordenador.",
},
bell: {
dmsUnread: "{{count}} nova(s) mensagem(ns) criptografada(s)",
},
},
settingsUi: {
loginDialog: {

View file

@ -90,6 +90,9 @@ const drift = {
"form": {
"cordnGroup": "Grup Cordn",
"cordnGroupHint": "Opțional: lipește linkul grupului din Staircase sau Cordn web. Crew-ul tău primește atunci chat MLS real end-to-end, găzduit pe coordonatorul nostru."
},
"bell": {
"dmsUnread": "{{count}} mesaje criptate noi"
}
},
"settingsUi": {

View file

@ -95,6 +95,9 @@ const drift = {
cordnGroup: "Группа Cordn",
cordnGroupHint: "Необязательно: вставьте ссылку на группу из Staircase или Cordn (веб). Тогда ваш Crew получит настоящий сквозной MLS-чат на нашем координаторе.",
},
bell: {
dmsUnread: "{{count}} новых зашифрованных сообщений",
},
},
settingsUi: {
loginDialog: {

View file

@ -95,6 +95,9 @@ const drift = {
cordnGroup: "Cordn-grupp",
cordnGroupHint: "Valfritt: klistra in grupplänken från Staircase eller Cordn webb. Ditt crew får då riktig end-to-end MLS-chatt, hostad på vår koordinator.",
},
bell: {
dmsUnread: "{{count}} nya krypterade meddelanden",
},
},
settingsUi: {
loginDialog: {

View file

@ -87,6 +87,9 @@ const driftStrings = {
cordnGroup: 'กลุ่ม Cordn',
cordnGroupHint: 'ไม่บังคับ: วางลิงก์กลุ่มจาก Staircase หรือ Cordn เว็บ crew ของคุณจะได้แชท MLS เข้ารหัสต้นทาง-ปลายทางจริง โฮสต์บน coordinator ของเรา',
},
bell: {
dmsUnread: 'ข้อความเข้ารหัสใหม่ {{count}} ข้อความ',
},
},
settingsUi: {
loginDialog: {

View file

@ -87,6 +87,9 @@ const driftStrings = {
cordnGroup: 'Cordn grubu',
cordnGroupHint: "İsteğe bağlı: Staircase veya Cordn web'den grup bağlantısını yapıştır. Crew'in böylece koordinatörümüzde barındırılan gerçek uçtan uca MLS sohbetine kavuşur.",
},
bell: {
dmsUnread: '{{count}} yeni şifreli mesaj',
},
},
settingsUi: {
loginDialog: {

View file

@ -95,6 +95,9 @@ const drift = {
cordnGroup: "Група Cordn",
cordnGroupHint: "Необов'язково: вставте посилання на групу зі Staircase або Cordn (веб). Тоді ваша команда отримає справжній наскрізний MLS-чат на нашому координаторі.",
},
bell: {
dmsUnread: "{{count}} нових зашифрованих повідомлень",
},
},
settingsUi: {
loginDialog: {

View file

@ -87,6 +87,9 @@ const driftStrings = {
cordnGroup: 'Nhóm Cordn',
cordnGroupHint: 'Tùy chọn: dán liên kết nhóm từ Staircase hoặc Cordn web. Crew của bạn sau đó có chat MLS đầu-cuối thực sự, được lưu trữ trên điều phối viên của chúng tôi.',
},
bell: {
dmsUnread: '{{count}} tin nhắn mã hóa mới',
},
},
settingsUi: {
loginDialog: {

View file

@ -103,6 +103,9 @@ const drift: Dict = {
cordnGroup: 'Cordn 群组',
cordnGroupHint: '可选:粘贴来自 Staircase 或 Cordn 网页的群组链接。你的 Crew 随后将获得真正的端到端 MLS 聊天,托管在我们的协调器上。',
},
bell: {
dmsUnread: '{{count}} 条新的加密消息',
},
},
settingsUi: {
loginDialog: {

View file

@ -103,6 +103,9 @@ const drift: Dict = {
cordnGroup: 'Cordn 群組',
cordnGroupHint: '選填:貼上來自 Staircase 或 Cordn 網頁的群組連結。你的 Crew 隨後將獲得真正的端到端 MLS 聊天,託管在我們的協調器上。',
},
bell: {
dmsUnread: '{{count}} 則新的加密訊息',
},
},
settingsUi: {
loginDialog: {

31
src/lib/dmOwnWraps.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Gift-wrap ids created by this device. NIP-17 sends a copy to the sender, and
* envelope-only unread counts can't distinguish that copy from an incoming
* message without decrypting. Tracking outbound wrap ids keeps the decrypt-free
* bell badge honest.
*/
const KEY = 'skatespots:dm-own-wraps';
const MAX = 500;
function read(): string[] {
try {
const raw = localStorage.getItem(KEY);
const parsed = raw ? JSON.parse(raw) as unknown : [];
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}
export function markOwnDmWraps(ids: string[]): void {
try {
const next = [...new Set([...ids, ...read()])].slice(0, MAX);
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
// best-effort only
}
}
export function getOwnDmWrapIds(): Set<string> {
return new Set(read());
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
@ -18,6 +18,7 @@ import { useUserCrews } from '@/hooks/useCrews';
import { useCordnGroupGids } from '@/hooks/useCordnNative';
import { SKATE_CREW_KIND } from '@/lib/types';
import { useDMRead } from '@/hooks/useDMRead';
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useLocalStorage } from '@/hooks/useLocalStorage';
@ -227,6 +228,7 @@ export default function DirectMessagesPage() {
const undecryptable = inbox?.undecryptable ?? 0;
const { data: groups, refetch: refetchGroups } = useGroupConversations();
const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
// The viewer's crews — surfaced here as entry points to their private (MLS)
// chat. `cordnGids` is a local IndexedDB read (no signer prompt) used to mark
// which crews already have a chat on this device.
@ -234,6 +236,10 @@ export default function DirectMessagesPage() {
const cordnGids = useCordnGroupGids(user?.pubkey);
const [showNewMessage, setShowNewMessage] = useState(false);
useEffect(() => {
markDmEnvelopesSeen();
}, [markDmEnvelopesSeen]);
// 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 } }) => {