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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
@ -18,6 +18,7 @@ import { ImageWithFallback } from '@/components/ImageWithFallback';
|
||||||
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
|
import { useCordnJoinNotifications } from '@/hooks/useCordnJoinNotifications';
|
||||||
|
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
|
||||||
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||||
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
|
import { useTradeNotifications } from '@/hooks/useTradeNotifications';
|
||||||
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||||
|
|
@ -226,6 +227,7 @@ export function NotificationBell({
|
||||||
isLoading: cordnJoinLoading,
|
isLoading: cordnJoinLoading,
|
||||||
dismiss: dismissCordnJoin,
|
dismiss: dismissCordnJoin,
|
||||||
} = useCordnJoinNotifications(open);
|
} = useCordnJoinNotifications(open);
|
||||||
|
const { unreadCount: unreadDms, markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
|
||||||
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
const { lastRead, seenAt, markAllRead } = useNotificationsRead();
|
||||||
|
|
||||||
// Buying/selling steps: an offer on your listing, a counter it's your turn to
|
// 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.
|
// 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
|
// 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.
|
// News tab is right there). Newsletter read state also clears on the /newsletter page.
|
||||||
|
|
@ -359,7 +361,7 @@ export function NotificationBell({
|
||||||
{badgeTotal > 0 ? (
|
{badgeTotal > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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')}
|
<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')}
|
<Loader2 className="h-4 w-4 animate-spin" /> {t('socialUi.bell.checkingUpdates')}
|
||||||
</div>
|
</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')} />
|
<EmptyState label={t('socialUi.bell.emptyAll')} />
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2.5">
|
<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. */}
|
{/* Buying/selling steps first — they're the most time-sensitive. */}
|
||||||
{tradeNotifs.map((n) => (
|
{tradeNotifs.map((n) => (
|
||||||
<TradeNotificationCard
|
<TradeNotificationCard
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useAppContext } from './useAppContext';
|
||||||
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||||
import { useRelays } from '@/lib/relays';
|
import { useRelays } from '@/lib/relays';
|
||||||
import { SKATE_RELAY } from '@/lib/types';
|
import { SKATE_RELAY } from '@/lib/types';
|
||||||
|
import { markOwnDmWraps } from '@/lib/dmOwnWraps';
|
||||||
import type { RelayMetadata } from '@/contexts/AppContext';
|
import type { RelayMetadata } from '@/contexts/AppContext';
|
||||||
import {
|
import {
|
||||||
loadDmDecryptCache,
|
loadDmDecryptCache,
|
||||||
|
|
@ -427,6 +428,10 @@ export function useSendDirectMessage() {
|
||||||
giftWraps.push(...(await Promise.all(allTargets.map(createSealAndWrap))));
|
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
|
// 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
|
// RECIPIENT's inbox relays (their kind 10050), plus our own inbox so we
|
||||||
// can read our sent copy.
|
// 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",
|
tabSocial: "Social",
|
||||||
tabNews: "Notícies",
|
tabNews: "Notícies",
|
||||||
tabCrews: "Crews",
|
tabCrews: "Crews",
|
||||||
|
dmsUnread: "{{count}} missatge(s) xifrat(s) nou(s)",
|
||||||
checkingUpdates: "Buscant actualitzacions…",
|
checkingUpdates: "Buscant actualitzacions…",
|
||||||
checkingTrades: "Buscant ofertes i intercanvis…",
|
checkingTrades: "Buscant ofertes i intercanvis…",
|
||||||
loadingDrops: "Carregant les últimes novetats…",
|
loadingDrops: "Carregant les últimes novetats…",
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ const drift = {
|
||||||
"form": {
|
"form": {
|
||||||
"cordnGroup": "Skupina Cordn",
|
"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."
|
"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": {
|
"settingsUi": {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ const drift = {
|
||||||
cordnGroup: "Cordn-Gruppe",
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ const drift = {
|
||||||
cordnGroup: "Ομάδα Cordn",
|
cordnGroup: "Ομάδα Cordn",
|
||||||
cordnGroupHint: "Προαιρετικό: επικόλλησε τον σύνδεσμο της ομάδας από Staircase ή Cordn web. Το crew σου αποκτά τότε πραγματική end-to-end συνομιλία MLS, φιλοξενούμενη στον συντονιστή μας.",
|
cordnGroupHint: "Προαιρετικό: επικόλλησε τον σύνδεσμο της ομάδας από Staircase ή Cordn web. Το crew σου αποκτά τότε πραγματική end-to-end συνομιλία MLS, φιλοξενούμενη στον συντονιστή μας.",
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: "{{count}} νέα κρυπτογραφημένα μηνύματα",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -3732,6 +3732,7 @@ socialUi: {
|
||||||
tabSocial: 'Social',
|
tabSocial: 'Social',
|
||||||
tabNews: 'News',
|
tabNews: 'News',
|
||||||
tabCrews: 'Crews',
|
tabCrews: 'Crews',
|
||||||
|
dmsUnread: '{{count}} new encrypted message(s)',
|
||||||
checkingUpdates: 'Checking for updates…',
|
checkingUpdates: 'Checking for updates…',
|
||||||
checkingTrades: 'Checking offers and trades…',
|
checkingTrades: 'Checking offers and trades…',
|
||||||
loadingDrops: 'Loading the latest drops…',
|
loadingDrops: 'Loading the latest drops…',
|
||||||
|
|
|
||||||
|
|
@ -3732,6 +3732,7 @@ socialUi: {
|
||||||
tabSocial: 'Social',
|
tabSocial: 'Social',
|
||||||
tabNews: 'Noticias',
|
tabNews: 'Noticias',
|
||||||
tabCrews: 'Peñas',
|
tabCrews: 'Peñas',
|
||||||
|
dmsUnread: '{{count}} mensaje(s) cifrado(s) nuevo(s)',
|
||||||
checkingUpdates: 'Buscando novedades…',
|
checkingUpdates: 'Buscando novedades…',
|
||||||
checkingTrades: 'Revisando ofertas y tratos…',
|
checkingTrades: 'Revisando ofertas y tratos…',
|
||||||
loadingDrops: 'Cargando las últimas entregas…',
|
loadingDrops: 'Cargando las últimas entregas…',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const batch2 = {
|
||||||
tabSocial: "Soziala",
|
tabSocial: "Soziala",
|
||||||
tabNews: "Berriak",
|
tabNews: "Berriak",
|
||||||
tabCrews: "Koadrilak",
|
tabCrews: "Koadrilak",
|
||||||
|
dmsUnread: "{{count}} mezu zifratu berri",
|
||||||
checkingUpdates: "Eguneratzeak egiaztatzen…",
|
checkingUpdates: "Eguneratzeak egiaztatzen…",
|
||||||
checkingTrades: "Eskaintzak eta trukeak egiaztatzen…",
|
checkingTrades: "Eskaintzak eta trukeak egiaztatzen…",
|
||||||
loadingDrops: "Azken bidalketak kargatzen…",
|
loadingDrops: "Azken bidalketak kargatzen…",
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ const drift = {
|
||||||
cordnGroup: "Groupe Cordn",
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const batch2 = {
|
||||||
tabSocial: "Social",
|
tabSocial: "Social",
|
||||||
tabNews: "Novidades",
|
tabNews: "Novidades",
|
||||||
tabCrews: "Crews",
|
tabCrews: "Crews",
|
||||||
|
dmsUnread: "{{count}} mensaxe(s) cifrado(s) novo(s)",
|
||||||
checkingUpdates: "Buscando actualizacións…",
|
checkingUpdates: "Buscando actualizacións…",
|
||||||
checkingTrades: "Comprobando ofertas e intercambios…",
|
checkingTrades: "Comprobando ofertas e intercambios…",
|
||||||
loadingDrops: "Cargando as últimas novidades…",
|
loadingDrops: "Cargando as últimas novidades…",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const batch2 = {
|
||||||
"tabSocial": "सोशल",
|
"tabSocial": "सोशल",
|
||||||
"tabNews": "समाचार",
|
"tabNews": "समाचार",
|
||||||
"tabCrews": "क्रू",
|
"tabCrews": "क्रू",
|
||||||
|
"dmsUnread": "{{count}} नए एन्क्रिप्टेड संदेश",
|
||||||
"checkingUpdates": "अपडेट जाँच रहे हैं…",
|
"checkingUpdates": "अपडेट जाँच रहे हैं…",
|
||||||
"checkingTrades": "ऑफ़र और ट्रेड जाँच रहे हैं…",
|
"checkingTrades": "ऑफ़र और ट्रेड जाँच रहे हैं…",
|
||||||
"loadingDrops": "नए ड्रॉप लोड हो रहे हैं…",
|
"loadingDrops": "नए ड्रॉप लोड हो रहे हैं…",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ const driftStrings = {
|
||||||
cordnGroup: 'Grup Cordn',
|
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.',
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ const drift = {
|
||||||
cordnGroup: "Gruppo Cordn",
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
||||||
cordnGroup: 'Cordnグループ',
|
cordnGroup: 'Cordnグループ',
|
||||||
cordnGroupHint: '任意:StaircaseまたはCordnウェブからグループリンクを貼り付けてください。するとクルーに、私たちのコーディネーターでホストされる本物のエンドツーエンドMLSチャットが与えられます。',
|
cordnGroupHint: '任意:StaircaseまたはCordnウェブからグループリンクを貼り付けてください。するとクルーに、私たちのコーディネーターでホストされる本物のエンドツーエンドMLSチャットが与えられます。',
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: '{{count}}件の新しい暗号化メッセージ',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
||||||
cordnGroup: 'Cordn 그룹',
|
cordnGroup: 'Cordn 그룹',
|
||||||
cordnGroupHint: '선택 사항: Staircase 또는 Cordn 웹의 그룹 링크를 붙여넣으세요. 그러면 크루가 우리 코디네이터에서 호스팅되는 실제 종단 간 MLS 채팅을 갖게 됩니다.',
|
cordnGroupHint: '선택 사항: Staircase 또는 Cordn 웹의 그룹 링크를 붙여넣으세요. 그러면 크루가 우리 코디네이터에서 호스팅되는 실제 종단 간 MLS 채팅을 갖게 됩니다.',
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: '새 암호화 메시지 {{count}}개',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ const drift = {
|
||||||
"form": {
|
"form": {
|
||||||
"cordnGroup": "Cordn-groep",
|
"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."
|
"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": {
|
"settingsUi": {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ const drift = {
|
||||||
"form": {
|
"form": {
|
||||||
"cordnGroup": "Grupa Cordn",
|
"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."
|
"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": {
|
"settingsUi": {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ const drift = {
|
||||||
cordnGroup: "Grupo Cordn",
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ const drift = {
|
||||||
"form": {
|
"form": {
|
||||||
"cordnGroup": "Grup Cordn",
|
"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."
|
"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": {
|
"settingsUi": {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ const drift = {
|
||||||
cordnGroup: "Группа Cordn",
|
cordnGroup: "Группа Cordn",
|
||||||
cordnGroupHint: "Необязательно: вставьте ссылку на группу из Staircase или Cordn (веб). Тогда ваш Crew получит настоящий сквозной MLS-чат на нашем координаторе.",
|
cordnGroupHint: "Необязательно: вставьте ссылку на группу из Staircase или Cordn (веб). Тогда ваш Crew получит настоящий сквозной MLS-чат на нашем координаторе.",
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: "{{count}} новых зашифрованных сообщений",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ const drift = {
|
||||||
cordnGroup: "Cordn-grupp",
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ const driftStrings = {
|
||||||
cordnGroup: 'กลุ่ม Cordn',
|
cordnGroup: 'กลุ่ม Cordn',
|
||||||
cordnGroupHint: 'ไม่บังคับ: วางลิงก์กลุ่มจาก Staircase หรือ Cordn เว็บ crew ของคุณจะได้แชท MLS เข้ารหัสต้นทาง-ปลายทางจริง โฮสต์บน coordinator ของเรา',
|
cordnGroupHint: 'ไม่บังคับ: วางลิงก์กลุ่มจาก Staircase หรือ Cordn เว็บ crew ของคุณจะได้แชท MLS เข้ารหัสต้นทาง-ปลายทางจริง โฮสต์บน coordinator ของเรา',
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: 'ข้อความเข้ารหัสใหม่ {{count}} ข้อความ',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ const driftStrings = {
|
||||||
cordnGroup: 'Cordn grubu',
|
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.",
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ const drift = {
|
||||||
cordnGroup: "Група Cordn",
|
cordnGroup: "Група Cordn",
|
||||||
cordnGroupHint: "Необов'язково: вставте посилання на групу зі Staircase або Cordn (веб). Тоді ваша команда отримає справжній наскрізний MLS-чат на нашому координаторі.",
|
cordnGroupHint: "Необов'язково: вставте посилання на групу зі Staircase або Cordn (веб). Тоді ваша команда отримає справжній наскрізний MLS-чат на нашому координаторі.",
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: "{{count}} нових зашифрованих повідомлень",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ const driftStrings = {
|
||||||
cordnGroup: 'Nhóm Cordn',
|
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.',
|
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: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
||||||
cordnGroup: 'Cordn 群组',
|
cordnGroup: 'Cordn 群组',
|
||||||
cordnGroupHint: '可选:粘贴来自 Staircase 或 Cordn 网页的群组链接。你的 Crew 随后将获得真正的端到端 MLS 聊天,托管在我们的协调器上。',
|
cordnGroupHint: '可选:粘贴来自 Staircase 或 Cordn 网页的群组链接。你的 Crew 随后将获得真正的端到端 MLS 聊天,托管在我们的协调器上。',
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: '{{count}} 条新的加密消息',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
loginDialog: {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ const drift: Dict = {
|
||||||
cordnGroup: 'Cordn 群組',
|
cordnGroup: 'Cordn 群組',
|
||||||
cordnGroupHint: '選填:貼上來自 Staircase 或 Cordn 網頁的群組連結。你的 Crew 隨後將獲得真正的端到端 MLS 聊天,託管在我們的協調器上。',
|
cordnGroupHint: '選填:貼上來自 Staircase 或 Cordn 網頁的群組連結。你的 Crew 隨後將獲得真正的端到端 MLS 聊天,託管在我們的協調器上。',
|
||||||
},
|
},
|
||||||
|
bell: {
|
||||||
|
dmsUnread: '{{count}} 則新的加密訊息',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
settingsUi: {
|
settingsUi: {
|
||||||
loginDialog: {
|
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 { Link } from 'react-router-dom';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
@ -18,6 +18,7 @@ import { useUserCrews } from '@/hooks/useCrews';
|
||||||
import { useCordnGroupGids } from '@/hooks/useCordnNative';
|
import { useCordnGroupGids } from '@/hooks/useCordnNative';
|
||||||
import { SKATE_CREW_KIND } from '@/lib/types';
|
import { SKATE_CREW_KIND } from '@/lib/types';
|
||||||
import { useDMRead } from '@/hooks/useDMRead';
|
import { useDMRead } from '@/hooks/useDMRead';
|
||||||
|
import { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
|
||||||
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';
|
||||||
|
|
@ -227,6 +228,7 @@ export default function DirectMessagesPage() {
|
||||||
const undecryptable = inbox?.undecryptable ?? 0;
|
const undecryptable = inbox?.undecryptable ?? 0;
|
||||||
const { data: groups, refetch: refetchGroups } = useGroupConversations();
|
const { data: groups, refetch: refetchGroups } = useGroupConversations();
|
||||||
const { getLastRead } = useDMRead();
|
const { getLastRead } = useDMRead();
|
||||||
|
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
|
||||||
// The viewer's crews — surfaced here as entry points to their private (MLS)
|
// 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
|
// chat. `cordnGids` is a local IndexedDB read (no signer prompt) used to mark
|
||||||
// which crews already have a chat on this device.
|
// which crews already have a chat on this device.
|
||||||
|
|
@ -234,6 +236,10 @@ export default function DirectMessagesPage() {
|
||||||
const cordnGids = useCordnGroupGids(user?.pubkey);
|
const cordnGids = useCordnGroupGids(user?.pubkey);
|
||||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markDmEnvelopesSeen();
|
||||||
|
}, [markDmEnvelopesSeen]);
|
||||||
|
|
||||||
// A conversation is unread when its latest message is from the OTHER person
|
// A conversation is unread when its latest message is from the OTHER person
|
||||||
// and newer than the last time we opened that conversation.
|
// and newer than the last time we opened that conversation.
|
||||||
const isUnread = (conv: { pubkey: string; lastMessage?: { createdAt: number; pubkey: string } }) => {
|
const isUnread = (conv: { pubkey: string; lastMessage?: { createdAt: number; pubkey: string } }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue