feat: envelope-only DM unread in the bell
This commit is contained in:
parent
b2bc718a85
commit
4ae6b1d5ea
31 changed files with 198 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
67
src/hooks/useDmUnreadSummary.ts
Normal file
67
src/hooks/useDmUnreadSummary.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ const drift = {
|
|||
cordnGroup: "Ομάδα Cordn",
|
||||
cordnGroupHint: "Προαιρετικό: επικόλλησε τον σύνδεσμο της ομάδας από Staircase ή Cordn web. Το crew σου αποκτά τότε πραγματική end-to-end συνομιλία MLS, φιλοξενούμενη στον συντονιστή μας.",
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: "{{count}} νέα κρυπτογραφημένα μηνύματα",
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const batch2 = {
|
|||
"tabSocial": "सोशल",
|
||||
"tabNews": "समाचार",
|
||||
"tabCrews": "क्रू",
|
||||
"dmsUnread": "{{count}} नए एन्क्रिप्टेड संदेश",
|
||||
"checkingUpdates": "अपडेट जाँच रहे हैं…",
|
||||
"checkingTrades": "ऑफ़र और ट्रेड जाँच रहे हैं…",
|
||||
"loadingDrops": "नए ड्रॉप लोड हो रहे हैं…",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
|||
cordnGroup: 'Cordnグループ',
|
||||
cordnGroupHint: '任意:StaircaseまたはCordnウェブからグループリンクを貼り付けてください。するとクルーに、私たちのコーディネーターでホストされる本物のエンドツーエンドMLSチャットが与えられます。',
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: '{{count}}件の新しい暗号化メッセージ',
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
|||
cordnGroup: 'Cordn 그룹',
|
||||
cordnGroupHint: '선택 사항: Staircase 또는 Cordn 웹의 그룹 링크를 붙여넣으세요. 그러면 크루가 우리 코디네이터에서 호스팅되는 실제 종단 간 MLS 채팅을 갖게 됩니다.',
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: '새 암호화 메시지 {{count}}개',
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ const drift = {
|
|||
cordnGroup: "Группа Cordn",
|
||||
cordnGroupHint: "Необязательно: вставьте ссылку на группу из Staircase или Cordn (веб). Тогда ваш Crew получит настоящий сквозной MLS-чат на нашем координаторе.",
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: "{{count}} новых зашифрованных сообщений",
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ const driftStrings = {
|
|||
cordnGroup: 'กลุ่ม Cordn',
|
||||
cordnGroupHint: 'ไม่บังคับ: วางลิงก์กลุ่มจาก Staircase หรือ Cordn เว็บ crew ของคุณจะได้แชท MLS เข้ารหัสต้นทาง-ปลายทางจริง โฮสต์บน coordinator ของเรา',
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: 'ข้อความเข้ารหัสใหม่ {{count}} ข้อความ',
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,9 @@ const drift = {
|
|||
cordnGroup: "Група Cordn",
|
||||
cordnGroupHint: "Необов'язково: вставте посилання на групу зі Staircase або Cordn (веб). Тоді ваша команда отримає справжній наскрізний MLS-чат на нашому координаторі.",
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: "{{count}} нових зашифрованих повідомлень",
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
|||
cordnGroup: 'Cordn 群组',
|
||||
cordnGroupHint: '可选:粘贴来自 Staircase 或 Cordn 网页的群组链接。你的 Crew 随后将获得真正的端到端 MLS 聊天,托管在我们的协调器上。',
|
||||
},
|
||||
bell: {
|
||||
dmsUnread: '{{count}} 条新的加密消息',
|
||||
},
|
||||
},
|
||||
settingsUi: {
|
||||
loginDialog: {
|
||||
|
|
|
|||
|
|
@ -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
31
src/lib/dmOwnWraps.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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 } }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue