feat: per-thread DM mute, event pass, a11y header, undo mute

This commit is contained in:
SkateSpots 2026-10-29 12:37:10 +07:00
parent be00aa3fed
commit 673bb983b8
41 changed files with 382 additions and 13 deletions

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -78,6 +79,13 @@ export function FirstRunChecklist({ onFindNearby }: { onFindNearby?: () => void
> >
{t('mapUi.gotIt')} {t('mapUi.gotIt')}
</Button> </Button>
<Link
to="/crews"
onClick={() => setDismissed(true)}
className="self-center text-xs font-medium text-gray-500 underline underline-offset-2 hover:text-gray-300"
>
{t('mapUi.firstRunJoinCrew')}
</Link>
</div> </div>
</div> </div>
); );

View file

@ -843,7 +843,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
alt="SkateSpots" alt="SkateSpots"
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30" className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
/> />
<span className="font-display text-xl neon-red tracking-wide" style={{ <span className="min-w-0 truncate font-display text-xl neon-red tracking-wide" style={{
textShadow: '0 0 8px #ff0040' textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span> }}>SKATESPOTS</span>
</Link> </Link>

View file

@ -1,8 +1,9 @@
import { VolumeX } from 'lucide-react'; import { Volume2, VolumeX } from 'lucide-react';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMuteList } from '@/hooks/useMuteList'; import { useMuteList } from '@/hooks/useMuteList';
import { useMuteUser } from '@/hooks/useMuteUser'; import { useMuteUser } from '@/hooks/useMuteUser';
import { useUnmuteUser } from '@/hooks/useUnmuteUser';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
/** Inline mute action for content cards. Renders nothing when logged out. */ /** Inline mute action for content cards. Renders nothing when logged out. */
@ -17,21 +18,23 @@ export function MuteMenuItem({
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { data: muted } = useMuteList(); const { data: muted } = useMuteList();
const mute = useMuteUser(); const mute = useMuteUser();
const unmute = useUnmuteUser();
if (!user || user.pubkey === pubkey) return null; if (!user || user.pubkey === pubkey) return null;
const isMuted = !!muted?.has(pubkey); const isMuted = !!muted?.has(pubkey);
return ( return (
<DropdownMenuItem <DropdownMenuItem
disabled={isMuted || mute.isPending} disabled={mute.isPending || unmute.isPending}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!isMuted) mute.mutate(pubkey); if (isMuted) unmute.mutate(pubkey);
else mute.mutate(pubkey);
}} }}
className={className} className={className}
> >
<VolumeX className="mr-2 h-4 w-4" /> {isMuted ? <Volume2 className="mr-2 h-4 w-4" /> : <VolumeX className="mr-2 h-4 w-4" />}
{isMuted ? t('walletUi.trust.muted') : t('walletUi.trust.mute')} {isMuted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
</DropdownMenuItem> </DropdownMenuItem>
); );
} }

41
src/hooks/useDmMute.ts Normal file
View file

@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react';
const KEY = 'skatespots:dm-muted-threads';
const EVENT = 'skatespots:dm-muted-threads-change';
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 [];
}
}
/** Device-local per-thread mute for 1:1 DMs, group DMs and crew chat keys. */
export function useDmMute() {
const [keys, setKeys] = useState<string[]>(read);
useEffect(() => {
const onChange = (event: Event) => {
const detail = (event as CustomEvent<string[]>).detail;
if (Array.isArray(detail)) setKeys(detail);
};
window.addEventListener(EVENT, onChange);
return () => window.removeEventListener(EVENT, onChange);
}, []);
const write = useCallback((next: string[]) => {
setKeys(next);
try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { /* ignore */ }
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
}, []);
const isMuted = useCallback((key: string | undefined) => !!key && keys.includes(key), [keys]);
const toggle = useCallback((key: string) => {
write(keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key].slice(-200));
}, [keys, write]);
return { isMuted, toggle };
}

View file

@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { onlyVerified } from '@/lib/verifyEvents';
import { removeLocalMute } from '@/lib/localMutes';
import { MUTE_LIST_KIND } from '@/hooks/useMuteList';
/** Remove a pubkey from the viewer's NIP-51 kind-10000 mute list. */
export function useUnmuteUser() {
const { nostr } = useNostr();
const { mutateAsync: publish } = useNostrPublish();
const { user } = useCurrentUser();
const { toast } = useToast();
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (target: string) => {
if (!user) throw new Error(t('walletUi.trust.loginToMute'));
const events = onlyVerified(await nostr.query(
[{ kinds: [MUTE_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(8000) },
));
const existing = (events[0]?.tags ?? [])
.filter(([name]) => name === 'p')
.map(([, value]) => value)
.filter((value) => value && value !== target);
await publish({
kind: MUTE_LIST_KIND,
content: '',
tags: existing.map((pk) => ['p', pk]),
});
},
onSuccess: (_data, target) => {
removeLocalMute(target);
queryClient.invalidateQueries({ queryKey: ['mute-list', user?.pubkey] });
queryClient.invalidateQueries();
toast({ title: t('walletUi.trust.unmuted'), description: t('walletUi.trust.unmutedDesc') });
},
onError: (error) => {
toast({
title: t('walletUi.trust.unmuteFailed'),
description: error instanceof Error ? error.message : undefined,
variant: 'destructive',
});
},
});
}

View file

@ -1123,6 +1123,7 @@ const batch4 = {
"eventDetail": { "eventDetail": {
"aCrew": "una crew", "aCrew": "una crew",
"addToCalendar": "Afegeix al calendari", "addToCalendar": "Afegeix al calendari",
"eventPass": "Passi de l'esdeveniment",
"backToEvents": "Torna als esdeveniments", "backToEvents": "Torna als esdeveniments",
"cancelled": "Aquest esdeveniment s'ha cancel·lat", "cancelled": "Aquest esdeveniment s'ha cancel·lat",
"checkUpdate": ": consulta l'actualització", "checkUpdate": ": consulta l'actualització",

View file

@ -428,6 +428,10 @@ const batch6 = {
mute: "Silencia", mute: "Silencia",
muted: "Silenciat", muted: "Silenciat",
mutedDesc: "No veuràs el seu contingut.", mutedDesc: "No veuràs el seu contingut.",
unmute: "Deixa de silenciar",
unmuted: "Silenciament desactivat",
unmutedDesc: "El seu contingut tornarà a aparèixer.",
unmuteFailed: "No s'ha pogut deixar de silenciar. Torna-ho a provar.",
muteFailed: "No s'ha pogut silenciar. Torna-ho a provar.", muteFailed: "No s'ha pogut silenciar. Torna-ho a provar.",
loginToMute: "Inicia sessió per silenciar.", loginToMute: "Inicia sessió per silenciar.",
attributedNetwork: "De gent de la teva xarxa", attributedNetwork: "De gent de la teva xarxa",

View file

@ -114,6 +114,10 @@ const drift = {
"mute": "Ztlumit", "mute": "Ztlumit",
"muted": "Ztlumeno", "muted": "Ztlumeno",
"mutedDesc": "Nebudeš vídat jejich obsah.", "mutedDesc": "Nebudeš vídat jejich obsah.",
"unmute": "Zrušit ztlumení",
"unmuted": "Ztlumení zrušeno",
"unmutedDesc": "Jejich obsah se znovu objeví.",
"unmuteFailed": "Ztlumení se nepodařilo zrušit. Zkus to znovu.",
"muteFailed": "Ztlumení se nezdařilo. Zkus to znovu.", "muteFailed": "Ztlumení se nezdařilo. Zkus to znovu.",
"loginToMute": "Přihlas se, abys mohl ztlumit.", "loginToMute": "Přihlas se, abys mohl ztlumit.",
"attributedNetwork": "Od lidí ve tvé síti", "attributedNetwork": "Od lidí ve tvé síti",
@ -467,6 +471,11 @@ const drift = {
offerPickup: "Tento týden si to můžu vyzvednout.", offerPickup: "Tento týden si to můžu vyzvednout.",
offerShipping: "Můžeš spojit dopravu?", offerShipping: "Můžeš spojit dopravu?",
}, },
"placesUi": {
"eventDetail": {
"eventPass": "Vstupenka na akci"
},
},
}; };
export default drift; export default drift;

View file

@ -116,6 +116,10 @@ const drift = {
mute: "Stummschalten", mute: "Stummschalten",
muted: "Stummgeschaltet", muted: "Stummgeschaltet",
mutedDesc: "Du siehst deren Inhalte nicht.", mutedDesc: "Du siehst deren Inhalte nicht.",
unmute: "Stummschaltung aufheben",
unmuted: "Stummschaltung aufgehoben",
unmutedDesc: "Ihre Inhalte werden wieder angezeigt.",
unmuteFailed: "Stummschaltung konnte nicht aufgehoben werden. Versuch es erneut.",
muteFailed: "Stummschalten fehlgeschlagen. Versuche es erneut.", muteFailed: "Stummschalten fehlgeschlagen. Versuche es erneut.",
loginToMute: "Melde dich an, um stummzuschalten.", loginToMute: "Melde dich an, um stummzuschalten.",
attributedNetwork: "Von Personen in deinem Netzwerk", attributedNetwork: "Von Personen in deinem Netzwerk",
@ -469,6 +473,11 @@ const drift = {
offerPickup: "Ich kann es diese Woche abholen.", offerPickup: "Ich kann es diese Woche abholen.",
offerShipping: "Kannst du den Versand bündeln?", offerShipping: "Kannst du den Versand bündeln?",
}, },
placesUi: {
eventDetail: {
eventPass: "Event-Pass"
},
},
}; };
export default drift; export default drift;

View file

@ -118,6 +118,10 @@ const drift = {
mute: "Σίγαση", mute: "Σίγαση",
muted: "Σε σίγαση", muted: "Σε σίγαση",
mutedDesc: "Δεν θα βλέπεις το περιεχόμενό τους.", mutedDesc: "Δεν θα βλέπεις το περιεχόμενό τους.",
unmute: "Κατάργηση σίγασης",
unmuted: "Η σίγαση καταργήθηκε",
unmutedDesc: "Το περιεχόμενό τους θα εμφανιστεί ξανά.",
unmuteFailed: "Δεν ήταν δυνατή η κατάργηση της σίγασης. Δοκίμασε ξανά.",
muteFailed: "Δεν ήταν δυνατή η σίγαση. Δοκίμασε ξανά.", muteFailed: "Δεν ήταν δυνατή η σίγαση. Δοκίμασε ξανά.",
loginToMute: "Συνδέσου για σίγαση.", loginToMute: "Συνδέσου για σίγαση.",
attributedNetwork: "Από άτομα στο δίκτυό σου", attributedNetwork: "Από άτομα στο δίκτυό σου",
@ -471,6 +475,11 @@ const drift = {
offerPickup: "Μπορώ να το παραλάβω αυτή την εβδομάδα.", offerPickup: "Μπορώ να το παραλάβω αυτή την εβδομάδα.",
offerShipping: "Μπορείς να συνδυάσεις τα μεταφορικά;", offerShipping: "Μπορείς να συνδυάσεις τα μεταφορικά;",
}, },
placesUi: {
eventDetail: {
eventPass: "Πάσο εκδήλωσης"
},
},
}; };
export default drift; export default drift;

View file

@ -3427,6 +3427,7 @@ placesUi: {
eventDetail: { eventDetail: {
aCrew: 'a crew', aCrew: 'a crew',
addToCalendar: 'Add to calendar', addToCalendar: 'Add to calendar',
eventPass: 'Event pass',
backToEvents: 'Back to Events', backToEvents: 'Back to Events',
cancelled: 'This event was cancelled', cancelled: 'This event was cancelled',
checkUpdate: '— check the update', checkUpdate: '— check the update',
@ -6207,6 +6208,10 @@ walletUi: {
mute: 'Mute', mute: 'Mute',
muted: 'Muted', muted: 'Muted',
mutedDesc: 'You will not see their content.', mutedDesc: 'You will not see their content.',
unmute: 'Unmute',
unmuted: 'Unmuted',
unmutedDesc: 'Their content will appear again.',
unmuteFailed: 'Could not unmute. Try again.',
muteFailed: 'Could not mute. Try again.', muteFailed: 'Could not mute. Try again.',
loginToMute: 'Log in to mute.', loginToMute: 'Log in to mute.',
attributedNetwork: 'From people in your network', attributedNetwork: 'From people in your network',

View file

@ -3427,6 +3427,7 @@ placesUi: {
eventDetail: { eventDetail: {
aCrew: 'una crew', aCrew: 'una crew',
addToCalendar: 'Añadir al calendario', addToCalendar: 'Añadir al calendario',
eventPass: 'Pase del evento',
backToEvents: 'Volver a eventos', backToEvents: 'Volver a eventos',
cancelled: 'Este evento se canceló', cancelled: 'Este evento se canceló',
checkUpdate: '— consulta la actualización', checkUpdate: '— consulta la actualización',
@ -6207,6 +6208,10 @@ walletUi: {
mute: 'Silenciar', mute: 'Silenciar',
muted: 'Silenciado', muted: 'Silenciado',
mutedDesc: 'No veras su contenido.', mutedDesc: 'No veras su contenido.',
unmute: 'Dejar de silenciar',
unmuted: 'Sin silencio',
unmutedDesc: 'Su contenido volvera a aparecer.',
unmuteFailed: 'No se pudo dejar de silenciar. Intentalo de nuevo.',
muteFailed: 'No se pudo silenciar. Intentalo de nuevo.', muteFailed: 'No se pudo silenciar. Intentalo de nuevo.',
loginToMute: 'Inicia sesion para silenciar.', loginToMute: 'Inicia sesion para silenciar.',
attributedNetwork: 'De personas de tu red', attributedNetwork: 'De personas de tu red',

View file

@ -1123,6 +1123,7 @@ const batch4 = {
eventDetail: { eventDetail: {
aCrew: "krew bat", aCrew: "krew bat",
addToCalendar: "Gehitu egutegira", addToCalendar: "Gehitu egutegira",
eventPass: "Ekitaldiko pasea",
backToEvents: "Itzuli ekitaldietara", backToEvents: "Itzuli ekitaldietara",
cancelled: "Ekitaldi hau bertan behera utzi da", cancelled: "Ekitaldi hau bertan behera utzi da",
checkUpdate: "— begiratu eguneratzea", checkUpdate: "— begiratu eguneratzea",

View file

@ -428,6 +428,10 @@ const batch6 = {
mute: "Mututu", mute: "Mututu",
muted: "Mutututa", muted: "Mutututa",
mutedDesc: "Ez duzu haien edukia ikusiko.", mutedDesc: "Ez duzu haien edukia ikusiko.",
unmute: "Mutua kendu",
unmuted: "Mutua kenduta",
unmutedDesc: "Haien edukia berriro agertuko da.",
unmuteFailed: "Ezin izan da mutua kendu. Saiatu berriro.",
muteFailed: "Ezin izan da mututu. Saiatu berriro.", muteFailed: "Ezin izan da mututu. Saiatu berriro.",
loginToMute: "Hasi saioa mututzeko.", loginToMute: "Hasi saioa mututzeko.",
attributedNetwork: "Zure sareko pertsonen eskutik", attributedNetwork: "Zure sareko pertsonen eskutik",

View file

@ -116,6 +116,10 @@ const drift = {
mute: "Masquer", mute: "Masquer",
muted: "Masqué", muted: "Masqué",
mutedDesc: "Vous ne verrez pas son contenu.", mutedDesc: "Vous ne verrez pas son contenu.",
unmute: "Ne plus masquer",
unmuted: "Plus masqué",
unmutedDesc: "Son contenu réapparaîtra.",
unmuteFailed: "Impossible de ne plus masquer. Réessayez.",
muteFailed: "Impossible de masquer. Réessayez.", muteFailed: "Impossible de masquer. Réessayez.",
loginToMute: "Connectez-vous pour masquer.", loginToMute: "Connectez-vous pour masquer.",
attributedNetwork: "De personnes de votre réseau", attributedNetwork: "De personnes de votre réseau",
@ -469,6 +473,11 @@ const drift = {
offerPickup: "Je peux venir le chercher cette semaine.", offerPickup: "Je peux venir le chercher cette semaine.",
offerShipping: "Peux-tu regrouper l'expédition ?", offerShipping: "Peux-tu regrouper l'expédition ?",
}, },
placesUi: {
eventDetail: {
eventPass: "Pass événement"
},
},
}; };
export default drift; export default drift;

View file

@ -1123,6 +1123,7 @@ const batch4 = {
eventDetail: { eventDetail: {
aCrew: "un crew", aCrew: "un crew",
addToCalendar: "Engadir ao calendario", addToCalendar: "Engadir ao calendario",
eventPass: "Pase do evento",
backToEvents: "Volver aos eventos", backToEvents: "Volver aos eventos",
cancelled: "Este evento cancelouse", cancelled: "Este evento cancelouse",
checkUpdate: ": consulta a actualización", checkUpdate: ": consulta a actualización",

View file

@ -428,6 +428,10 @@ const batch6 = {
"mute": "Silenciar", "mute": "Silenciar",
"muted": "Silenciado", "muted": "Silenciado",
"mutedDesc": "Non verás o seu contido.", "mutedDesc": "Non verás o seu contido.",
"unmute": "Deixar de silenciar",
"unmuted": "Silenciamento desfeito",
"unmutedDesc": "O seu contido volverá aparecer.",
"unmuteFailed": "Non se puido deixar de silenciar. Téntao de novo.",
"muteFailed": "Non se puido silenciar. Téntao de novo.", "muteFailed": "Non se puido silenciar. Téntao de novo.",
"loginToMute": "Inicia sesión para silenciar.", "loginToMute": "Inicia sesión para silenciar.",
"attributedNetwork": "De persoas da túa rede", "attributedNetwork": "De persoas da túa rede",

View file

@ -1123,6 +1123,7 @@ const batch4 = {
"eventDetail": { "eventDetail": {
"aCrew": "एक क्रू", "aCrew": "एक क्रू",
"addToCalendar": "कैलेंडर में जोड़ें", "addToCalendar": "कैलेंडर में जोड़ें",
"eventPass": "इवेंट पास",
"backToEvents": "इवेंट्स पर वापस जाएँ", "backToEvents": "इवेंट्स पर वापस जाएँ",
"cancelled": "यह इवेंट रद्द हो गया था", "cancelled": "यह इवेंट रद्द हो गया था",
"checkUpdate": "— अपडेट देखें", "checkUpdate": "— अपडेट देखें",

View file

@ -428,6 +428,10 @@ const batch6 = {
"mute": "म्यूट करें", "mute": "म्यूट करें",
"muted": "म्यूट किया", "muted": "म्यूट किया",
"mutedDesc": "आपको इनका कंटेंट नहीं दिखेगा।", "mutedDesc": "आपको इनका कंटेंट नहीं दिखेगा।",
"unmute": "अनम्यूट करें",
"unmuted": "अनम्यूट किया",
"unmutedDesc": "उनका कंटेंट फिर से दिखाई देगा।",
"unmuteFailed": "अनम्यूट नहीं हो सका। फिर कोशिश करें।",
"muteFailed": "म्यूट नहीं हो सका। फिर कोशिश करें।", "muteFailed": "म्यूट नहीं हो सका। फिर कोशिश करें।",
"loginToMute": "म्यूट करने के लिए लॉग इन करें।", "loginToMute": "म्यूट करने के लिए लॉग इन करें।",
"attributedNetwork": "आपके नेटवर्क के लोगों से", "attributedNetwork": "आपके नेटवर्क के लोगों से",

View file

@ -110,6 +110,10 @@ const driftStrings = {
mute: 'Bisukan', mute: 'Bisukan',
muted: 'Dibisukan', muted: 'Dibisukan',
mutedDesc: 'Kamu tidak akan melihat konten mereka.', mutedDesc: 'Kamu tidak akan melihat konten mereka.',
unmute: 'Batalkan bisukan',
unmuted: 'Bisukan dibatalkan',
unmutedDesc: 'Konten mereka akan muncul lagi.',
unmuteFailed: 'Gagal membatalkan bisukan. Coba lagi.',
muteFailed: 'Gagal membisukan. Coba lagi.', muteFailed: 'Gagal membisukan. Coba lagi.',
loginToMute: 'Masuk untuk membisukan.', loginToMute: 'Masuk untuk membisukan.',
attributedNetwork: 'Dari orang-orang di jaringanmu', attributedNetwork: 'Dari orang-orang di jaringanmu',
@ -463,6 +467,11 @@ const driftStrings = {
offerPickup: "Saya bisa ambil sendiri minggu ini.", offerPickup: "Saya bisa ambil sendiri minggu ini.",
offerShipping: "Bisa gabung ongkos kirimnya?", offerShipping: "Bisa gabung ongkos kirimnya?",
}, },
placesUi: {
eventDetail: {
eventPass: "Tiket acara"
},
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -116,6 +116,10 @@ const drift = {
mute: "Silenzia", mute: "Silenzia",
muted: "Silenziato", muted: "Silenziato",
mutedDesc: "Non vedrai i suoi contenuti.", mutedDesc: "Non vedrai i suoi contenuti.",
unmute: "Annulla silenziamento",
unmuted: "Silenziamento annullato",
unmutedDesc: "I suoi contenuti torneranno visibili.",
unmuteFailed: "Impossibile annullare il silenziamento. Riprova.",
muteFailed: "Impossibile silenziare. Riprova.", muteFailed: "Impossibile silenziare. Riprova.",
loginToMute: "Accedi per silenziare.", loginToMute: "Accedi per silenziare.",
attributedNetwork: "Da persone nella tua rete", attributedNetwork: "Da persone nella tua rete",
@ -469,6 +473,11 @@ const drift = {
offerPickup: "Posso ritirarlo questa settimana.", offerPickup: "Posso ritirarlo questa settimana.",
offerShipping: "Puoi raggruppare la spedizione?", offerShipping: "Puoi raggruppare la spedizione?",
}, },
placesUi: {
eventDetail: {
eventPass: "Pass evento"
},
},
}; };
export default drift; export default drift;

View file

@ -126,6 +126,10 @@ const drift: Dict = {
mute: 'ミュート', mute: 'ミュート',
muted: 'ミュート済み', muted: 'ミュート済み',
mutedDesc: '相手のコンテンツは表示されません。', mutedDesc: '相手のコンテンツは表示されません。',
unmute: 'ミュート解除',
unmuted: 'ミュート解除済み',
unmutedDesc: '相手のコンテンツが再び表示されます。',
unmuteFailed: 'ミュートを解除できませんでした。もう一度お試しください。',
muteFailed: 'ミュートできませんでした。もう一度お試しください。', muteFailed: 'ミュートできませんでした。もう一度お試しください。',
loginToMute: 'ミュートするにはログインしてください。', loginToMute: 'ミュートするにはログインしてください。',
attributedNetwork: 'あなたのネットワークの人から', attributedNetwork: 'あなたのネットワークの人から',
@ -479,6 +483,11 @@ const drift: Dict = {
offerPickup: "今週中に受け取れます。", offerPickup: "今週中に受け取れます。",
offerShipping: "送料をまとめられますか?", offerShipping: "送料をまとめられますか?",
}, },
placesUi: {
eventDetail: {
eventPass: "イベントパス"
},
},
}; };
export default drift; export default drift;

View file

@ -126,6 +126,10 @@ const drift: Dict = {
mute: '뮤트', mute: '뮤트',
muted: '뮤트됨', muted: '뮤트됨',
mutedDesc: '상대방의 콘텐츠가 표시되지 않습니다.', mutedDesc: '상대방의 콘텐츠가 표시되지 않습니다.',
unmute: '뮤트 해제',
unmuted: '뮤트 해제됨',
unmutedDesc: '상대방의 콘텐츠가 다시 표시됩니다.',
unmuteFailed: '뮤트를 해제하지 못했습니다. 다시 시도하세요.',
muteFailed: '뮤트하지 못했습니다. 다시 시도하세요.', muteFailed: '뮤트하지 못했습니다. 다시 시도하세요.',
loginToMute: '뮤트하려면 로그인하세요.', loginToMute: '뮤트하려면 로그인하세요.',
attributedNetwork: '당신의 네트워크에 있는 사람들로부터', attributedNetwork: '당신의 네트워크에 있는 사람들로부터',
@ -479,6 +483,11 @@ const drift: Dict = {
offerPickup: "이번 주에 직접 수령할 수 있어요.", offerPickup: "이번 주에 직접 수령할 수 있어요.",
offerShipping: "배송비를 묶을 수 있나요?", offerShipping: "배송비를 묶을 수 있나요?",
}, },
placesUi: {
eventDetail: {
eventPass: "이벤트 패스"
},
},
}; };
export default drift; export default drift;

View file

@ -114,6 +114,10 @@ const drift = {
"mute": "Dempen", "mute": "Dempen",
"muted": "Gedempt", "muted": "Gedempt",
"mutedDesc": "Je ziet hun content niet meer.", "mutedDesc": "Je ziet hun content niet meer.",
"unmute": "Dempen opheffen",
"unmuted": "Dempen opgeheven",
"unmutedDesc": "Je ziet hun content weer.",
"unmuteFailed": "Kon dempen niet opheffen. Probeer het opnieuw.",
"muteFailed": "Kon niet dempen. Probeer het opnieuw.", "muteFailed": "Kon niet dempen. Probeer het opnieuw.",
"loginToMute": "Log in om te dempen.", "loginToMute": "Log in om te dempen.",
"attributedNetwork": "Van mensen in je netwerk", "attributedNetwork": "Van mensen in je netwerk",
@ -467,6 +471,11 @@ const drift = {
offerPickup: "Ik kan het deze week ophalen.", offerPickup: "Ik kan het deze week ophalen.",
offerShipping: "Kun je de verzendkosten bundelen?", offerShipping: "Kun je de verzendkosten bundelen?",
}, },
"placesUi": {
"eventDetail": {
"eventPass": "Evenementenpas"
},
},
}; };
export default drift; export default drift;

View file

@ -114,6 +114,10 @@ const drift = {
"mute": "Wycisz", "mute": "Wycisz",
"muted": "Wyciszony", "muted": "Wyciszony",
"mutedDesc": "Nie zobaczysz ich treści.", "mutedDesc": "Nie zobaczysz ich treści.",
"unmute": "Wyłącz wyciszenie",
"unmuted": "Wyciszenie wyłączone",
"unmutedDesc": "Ich treści znów będą widoczne.",
"unmuteFailed": "Nie udało się wyłączyć wyciszenia. Spróbuj ponownie.",
"muteFailed": "Nie udało się wyciszyć. Spróbuj ponownie.", "muteFailed": "Nie udało się wyciszyć. Spróbuj ponownie.",
"loginToMute": "Zaloguj się, aby wyciszyć.", "loginToMute": "Zaloguj się, aby wyciszyć.",
"attributedNetwork": "Od osób w twojej sieci", "attributedNetwork": "Od osób w twojej sieci",
@ -467,6 +471,11 @@ const drift = {
offerPickup: "Mogę odebrać w tym tygodniu.", offerPickup: "Mogę odebrać w tym tygodniu.",
offerShipping: "Czy możesz połączyć wysyłkę?", offerShipping: "Czy możesz połączyć wysyłkę?",
}, },
"placesUi": {
"eventDetail": {
"eventPass": "Karnet na wydarzenie"
},
},
}; };
export default drift; export default drift;

View file

@ -116,6 +116,10 @@ const drift = {
mute: "Silenciar", mute: "Silenciar",
muted: "Silenciado", muted: "Silenciado",
mutedDesc: "Você não verá o conteúdo dela.", mutedDesc: "Você não verá o conteúdo dela.",
unmute: "Deixar de silenciar",
unmuted: "Silenciamento desfeito",
unmutedDesc: "O conteúdo dela voltará a aparecer.",
unmuteFailed: "Não foi possível deixar de silenciar. Tente novamente.",
muteFailed: "Não foi possível silenciar. Tente novamente.", muteFailed: "Não foi possível silenciar. Tente novamente.",
loginToMute: "Faça login para silenciar.", loginToMute: "Faça login para silenciar.",
attributedNetwork: "De pessoas da sua rede", attributedNetwork: "De pessoas da sua rede",
@ -469,6 +473,11 @@ const drift = {
offerPickup: "Consigo retirar esta semana.", offerPickup: "Consigo retirar esta semana.",
offerShipping: "Você consegue agrupar o frete?", offerShipping: "Você consegue agrupar o frete?",
}, },
placesUi: {
eventDetail: {
eventPass: "Passe do evento"
},
},
}; };
export default drift; export default drift;

View file

@ -114,6 +114,10 @@ const drift = {
"mute": "Oprește sunetul", "mute": "Oprește sunetul",
"muted": "Fără sunet", "muted": "Fără sunet",
"mutedDesc": "Nu le vei vedea conținutul.", "mutedDesc": "Nu le vei vedea conținutul.",
"unmute": "Reia sunetul",
"unmuted": "Sunet reactivat",
"unmutedDesc": "Conținutul lor va apărea din nou.",
"unmuteFailed": "Nu s-a putut reactiva sunetul. Încearcă din nou.",
"muteFailed": "Nu s-a putut opri sunetul. Încearcă din nou.", "muteFailed": "Nu s-a putut opri sunetul. Încearcă din nou.",
"loginToMute": "Autentifică-te pentru a opri sunetul.", "loginToMute": "Autentifică-te pentru a opri sunetul.",
"attributedNetwork": "De la persoane din rețeaua ta", "attributedNetwork": "De la persoane din rețeaua ta",
@ -467,6 +471,11 @@ const drift = {
offerPickup: "Pot ridica personal săptămâna asta.", offerPickup: "Pot ridica personal săptămâna asta.",
offerShipping: "Poți combina transportul?", offerShipping: "Poți combina transportul?",
}, },
"placesUi": {
"eventDetail": {
"eventPass": "Pass de eveniment"
},
},
}; };
export default drift; export default drift;

View file

@ -118,6 +118,10 @@ const drift = {
mute: "Заглушить", mute: "Заглушить",
muted: "Заглушено", muted: "Заглушено",
mutedDesc: "Вы не будете видеть их контент.", mutedDesc: "Вы не будете видеть их контент.",
unmute: "Снять заглушение",
unmuted: "Заглушение снято",
unmutedDesc: "Их контент снова будет виден.",
unmuteFailed: "Не удалось снять заглушение. Попробуйте снова.",
muteFailed: "Не удалось заглушить. Попробуйте снова.", muteFailed: "Не удалось заглушить. Попробуйте снова.",
loginToMute: "Войдите, чтобы заглушить.", loginToMute: "Войдите, чтобы заглушить.",
attributedNetwork: "От людей из вашей сети", attributedNetwork: "От людей из вашей сети",
@ -471,6 +475,11 @@ const drift = {
offerPickup: "Могу забрать на этой неделе.", offerPickup: "Могу забрать на этой неделе.",
offerShipping: "Можете объединить доставку?", offerShipping: "Можете объединить доставку?",
}, },
placesUi: {
eventDetail: {
eventPass: "Пропуск на мероприятие"
},
},
}; };
export default drift; export default drift;

View file

@ -118,6 +118,10 @@ const drift = {
mute: "Tysta", mute: "Tysta",
muted: "Tystad", muted: "Tystad",
mutedDesc: "Du kommer inte se deras innehåll.", mutedDesc: "Du kommer inte se deras innehåll.",
unmute: "Sluta tysta",
unmuted: "Inte längre tystad",
unmutedDesc: "Deras innehåll visas igen.",
unmuteFailed: "Kunde inte sluta tysta. Försök igen.",
muteFailed: "Kunde inte tysta. Försök igen.", muteFailed: "Kunde inte tysta. Försök igen.",
loginToMute: "Logga in för att tysta.", loginToMute: "Logga in för att tysta.",
attributedNetwork: "Från personer i ditt nätverk", attributedNetwork: "Från personer i ditt nätverk",
@ -471,6 +475,11 @@ const drift = {
offerPickup: "Jag kan hämta den här veckan.", offerPickup: "Jag kan hämta den här veckan.",
offerShipping: "Kan du samla frakten?", offerShipping: "Kan du samla frakten?",
}, },
placesUi: {
eventDetail: {
eventPass: "Evenemangspass"
},
},
}; };
export default drift; export default drift;

View file

@ -110,6 +110,10 @@ const driftStrings = {
mute: 'ปิดเสียง', mute: 'ปิดเสียง',
muted: 'ปิดเสียงแล้ว', muted: 'ปิดเสียงแล้ว',
mutedDesc: 'คุณจะไม่เห็นเนื้อหาของพวกเขา', mutedDesc: 'คุณจะไม่เห็นเนื้อหาของพวกเขา',
unmute: 'เปิดเสียง',
unmuted: 'เปิดเสียงแล้ว',
unmutedDesc: 'เนื้อหาของพวกเขาจะปรากฏอีกครั้ง',
unmuteFailed: 'เปิดเสียงไม่สำเร็จ ลองอีกครั้ง',
muteFailed: 'ปิดเสียงไม่สำเร็จ ลองอีกครั้ง', muteFailed: 'ปิดเสียงไม่สำเร็จ ลองอีกครั้ง',
loginToMute: 'เข้าสู่ระบบเพื่อปิดเสียง', loginToMute: 'เข้าสู่ระบบเพื่อปิดเสียง',
attributedNetwork: 'จากคนในเครือข่ายของคุณ', attributedNetwork: 'จากคนในเครือข่ายของคุณ',
@ -463,6 +467,11 @@ const driftStrings = {
offerPickup: "ฉันรับเองได้ภายในสัปดาห์นี้", offerPickup: "ฉันรับเองได้ภายในสัปดาห์นี้",
offerShipping: "รวมค่าส่งได้ไหม?", offerShipping: "รวมค่าส่งได้ไหม?",
}, },
placesUi: {
eventDetail: {
eventPass: "บัตรอีเวนต์"
},
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -110,6 +110,10 @@ const driftStrings = {
mute: 'Sessize al', mute: 'Sessize al',
muted: 'Sessize alındı', muted: 'Sessize alındı',
mutedDesc: 'İçeriklerini görmezsin.', mutedDesc: 'İçeriklerini görmezsin.',
unmute: 'Sesi aç',
unmuted: 'Sesi açıldı',
unmutedDesc: 'İçerikleri yeniden görünür.',
unmuteFailed: 'Sesi açılamadı. Tekrar dene.',
muteFailed: 'Sessize alınamadı. Tekrar dene.', muteFailed: 'Sessize alınamadı. Tekrar dene.',
loginToMute: 'Sessize almak için giriş yap.', loginToMute: 'Sessize almak için giriş yap.',
attributedNetwork: 'Ağındaki kişilerden', attributedNetwork: 'Ağındaki kişilerden',
@ -463,6 +467,11 @@ const driftStrings = {
offerPickup: "Bu hafta kendim alabilirim.", offerPickup: "Bu hafta kendim alabilirim.",
offerShipping: "Kargo ücretini birleştirebilir misin?", offerShipping: "Kargo ücretini birleştirebilir misin?",
}, },
placesUi: {
eventDetail: {
eventPass: "Etkinlik pası"
},
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -118,6 +118,10 @@ const drift = {
mute: "Заглушити", mute: "Заглушити",
muted: "Заглушено", muted: "Заглушено",
mutedDesc: "Ви не бачитимете їхній вміст.", mutedDesc: "Ви не бачитимете їхній вміст.",
unmute: "Зняти заглушення",
unmuted: "Заглушення знято",
unmutedDesc: "Їхній вміст знову буде видно.",
unmuteFailed: "Не вдалося зняти заглушення. Спробуйте знову.",
muteFailed: "Не вдалося заглушити. Спробуйте знову.", muteFailed: "Не вдалося заглушити. Спробуйте знову.",
loginToMute: "Увійдіть, щоб заглушити.", loginToMute: "Увійдіть, щоб заглушити.",
attributedNetwork: "Від людей з вашої мережі", attributedNetwork: "Від людей з вашої мережі",
@ -471,6 +475,11 @@ const drift = {
offerPickup: "Можу забрати цього тижня.", offerPickup: "Можу забрати цього тижня.",
offerShipping: "Можете об'єднати доставку?", offerShipping: "Можете об'єднати доставку?",
}, },
placesUi: {
eventDetail: {
eventPass: "Перепустка на подію"
},
},
}; };
export default drift; export default drift;

View file

@ -110,6 +110,10 @@ const driftStrings = {
mute: 'Ẩn', mute: 'Ẩn',
muted: 'Đã ẩn', muted: 'Đã ẩn',
mutedDesc: 'Bạn sẽ không thấy nội dung của họ.', mutedDesc: 'Bạn sẽ không thấy nội dung của họ.',
unmute: 'Bỏ ẩn',
unmuted: 'Đã bỏ ẩn',
unmutedDesc: 'Nội dung của họ sẽ hiện lại.',
unmuteFailed: 'Không thể bỏ ẩn. Thử lại.',
muteFailed: 'Không thể ẩn. Thử lại.', muteFailed: 'Không thể ẩn. Thử lại.',
loginToMute: 'Đăng nhập để ẩn.', loginToMute: 'Đăng nhập để ẩn.',
attributedNetwork: 'Từ những người trong mạng lưới của bạn', attributedNetwork: 'Từ những người trong mạng lưới của bạn',
@ -463,6 +467,11 @@ const driftStrings = {
offerPickup: "Tôi có thể tự lấy trong tuần này.", offerPickup: "Tôi có thể tự lấy trong tuần này.",
offerShipping: "Bạn gộp phí ship được không?", offerShipping: "Bạn gộp phí ship được không?",
}, },
placesUi: {
eventDetail: {
eventPass: "Thẻ sự kiện"
},
},
}; };
export default driftStrings; export default driftStrings;

View file

@ -126,6 +126,10 @@ const drift: Dict = {
mute: '屏蔽', mute: '屏蔽',
muted: '已屏蔽', muted: '已屏蔽',
mutedDesc: '你将不会看到他们的内容。', mutedDesc: '你将不会看到他们的内容。',
unmute: '取消屏蔽',
unmuted: '已取消屏蔽',
unmutedDesc: '他们的内容将重新显示。',
unmuteFailed: '无法取消屏蔽。请重试。',
muteFailed: '无法屏蔽。请重试。', muteFailed: '无法屏蔽。请重试。',
loginToMute: '登录以屏蔽。', loginToMute: '登录以屏蔽。',
attributedNetwork: '来自你网络中的人', attributedNetwork: '来自你网络中的人',
@ -479,6 +483,11 @@ const drift: Dict = {
offerPickup: "我这周可以自取。", offerPickup: "我这周可以自取。",
offerShipping: "可以合并运费吗?", offerShipping: "可以合并运费吗?",
}, },
placesUi: {
eventDetail: {
eventPass: "活动通行证"
},
},
}; };
export default drift; export default drift;

View file

@ -126,6 +126,10 @@ const drift: Dict = {
mute: '靜音', mute: '靜音',
muted: '已靜音', muted: '已靜音',
mutedDesc: '你不會看到他們的內容。', mutedDesc: '你不會看到他們的內容。',
unmute: '取消靜音',
unmuted: '已取消靜音',
unmutedDesc: '他們的內容將重新顯示。',
unmuteFailed: '無法取消靜音。請重試。',
muteFailed: '無法靜音。請重試。', muteFailed: '無法靜音。請重試。',
loginToMute: '登入以靜音。', loginToMute: '登入以靜音。',
attributedNetwork: '來自你網路中的人', attributedNetwork: '來自你網路中的人',
@ -479,6 +483,11 @@ const drift: Dict = {
offerPickup: "我這週可以自取。", offerPickup: "我這週可以自取。",
offerShipping: "可以合併運費嗎?", offerShipping: "可以合併運費嗎?",
}, },
placesUi: {
eventDetail: {
eventPass: "活動通行證"
},
},
}; };
export default drift; export default drift;

View file

@ -39,3 +39,7 @@ export function addLocalMute(pubkey: string): void {
list.push(pubkey); list.push(pubkey);
write(list); write(list);
} }
export function removeLocalMute(pubkey: string): void {
write(read().filter((pk) => pk !== pubkey));
}

View file

@ -19,6 +19,7 @@ import { useCashuEscrow } from '@/hooks/wallet/useCashuEscrow';
import { EscrowPanel } from '@/components/EscrowPanel'; import { EscrowPanel } from '@/components/EscrowPanel';
import { TranslateMessage } from '@/components/chat/TranslateMessage'; import { TranslateMessage } from '@/components/chat/TranslateMessage';
import { useDMRead } from '@/hooks/useDMRead'; import { useDMRead } from '@/hooks/useDMRead';
import { useDmMute } from '@/hooks/useDmMute';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { formatDistanceToNow } from '@/lib/dateFnsI18n';
@ -29,6 +30,8 @@ import {
Loader2, Loader2,
AlertCircle, AlertCircle,
RefreshCw, RefreshCw,
VolumeX,
Volume2,
CheckCircle2, CheckCircle2,
Handshake, Handshake,
} from 'lucide-react'; } from 'lucide-react';
@ -115,6 +118,8 @@ export default function ConversationPage() {
const escrow = useCashuEscrow(); const escrow = useCashuEscrow();
const [isRedeeming, setIsRedeeming] = useState(false); const [isRedeeming, setIsRedeeming] = useState(false);
const { markRead } = useDMRead(); const { markRead } = useDMRead();
const { isMuted, toggle: toggleMute } = useDmMute();
const muted = isMuted(otherPubkey ?? undefined);
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) : t('socialUi.conversation.unknown')); const displayName = metadata?.name ?? (otherPubkey ? genUserName(otherPubkey) : t('socialUi.conversation.unknown'));
@ -316,6 +321,18 @@ export default function ConversationPage() {
{t('socialUi.conversation.encrypted')} {t('socialUi.conversation.encrypted')}
</p> </p>
</div> </div>
{otherPubkey && (
<Button
variant="ghost"
size="icon"
onClick={() => toggleMute(otherPubkey)}
aria-label={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
title={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
className={cn('text-gray-400 hover:text-white', muted && 'text-amber-400')}
>
{muted ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View file

@ -19,6 +19,7 @@ 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 { useDmUnreadSummary } from '@/hooks/useDmUnreadSummary';
import { useDmMute } from '@/hooks/useDmMute';
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';
@ -82,9 +83,10 @@ function GroupPreview({ conversation }: { conversation: Conversation }) {
const participants = conversation.participants ?? []; const participants = conversation.participants ?? [];
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { getLastRead } = useDMRead(); const { getLastRead } = useDMRead();
const { isMuted } = useDmMute();
const last = conversation.lastMessage; const last = conversation.lastMessage;
const isFromMe = last?.pubkey === user?.pubkey; const isFromMe = last?.pubkey === user?.pubkey;
const unread = !!last && !isFromMe && last.createdAt > getLastRead(conversation.pubkey); const unread = !!last && !isFromMe && !isMuted(conversation.pubkey) && last.createdAt > getLastRead(conversation.pubkey);
const timeAgo = last const timeAgo = last
? formatDistanceToNow(new Date(last.createdAt * 1000), { addSuffix: true }) ? formatDistanceToNow(new Date(last.createdAt * 1000), { addSuffix: true })
: ''; : '';
@ -229,6 +231,7 @@ export default function DirectMessagesPage() {
const { data: groups, refetch: refetchGroups } = useGroupConversations(); const { data: groups, refetch: refetchGroups } = useGroupConversations();
const { getLastRead } = useDMRead(); const { getLastRead } = useDMRead();
const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user); const { markSeen: markDmEnvelopesSeen } = useDmUnreadSummary(!!user);
const { isMuted } = useDmMute();
// 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.
@ -244,7 +247,7 @@ export default function DirectMessagesPage() {
// 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 } }) => {
const lm = conv.lastMessage; const lm = conv.lastMessage;
if (!lm || lm.pubkey === user?.pubkey) return false; if (!lm || lm.pubkey === user?.pubkey || isMuted(conv.pubkey)) return false;
return lm.createdAt > getLastRead(conv.pubkey); return lm.createdAt > getLastRead(conv.pubkey);
}; };

View file

@ -30,7 +30,7 @@ import { cn } from '@/lib/utils';
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import NotFound from './NotFound'; import NotFound from './NotFound';
import { Calendar, Clock, MapPin, ArrowLeft, Share2, Navigation, CalendarPlus, Pencil, Music, Megaphone, XCircle, CalendarClock } from 'lucide-react'; import { Calendar, Clock, MapPin, ArrowLeft, Share2, Navigation, CalendarPlus, Pencil, Music, Megaphone, XCircle, CalendarClock, Ticket } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { ControllerBadge } from '@/components/ControllerBadge'; import { ControllerBadge } from '@/components/ControllerBadge';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -146,6 +146,7 @@ export default function EventDetailPage() {
// NIP-52 RSVPs for this event (counts + attendee avatars). // NIP-52 RSVPs for this event (counts + attendee avatars).
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined); const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined);
const hasEventPass = !!user && !!event && (user.pubkey === event.pubkey || rsvpSummary.myStatus === 'accepted' || rsvpSummary.myStatus === 'tentative');
// Optional crowdfunding goal (sats) set by the host as a `goal` tag. // Optional crowdfunding goal (sats) set by the host as a `goal` tag.
const goalSats = Number(event?.event.tags.find(([t]) => t === 'goal')?.[1] ?? 0) || 0; const goalSats = Number(event?.event.tags.find(([t]) => t === 'goal')?.[1] ?? 0) || 0;
@ -377,6 +378,18 @@ export default function EventDetailPage() {
<CalendarPlus className="h-4 w-4" /> <CalendarPlus className="h-4 w-4" />
</Button> </Button>
)} )}
{hasEventPass && (
<Link to={`/card?type=event&event=${event.id}`}>
<Button
size="icon"
className="h-9 w-9 bg-black/50 text-white backdrop-blur hover:bg-black/70"
aria-label={t('placesUi.eventDetail.eventPass')}
title={t('placesUi.eventDetail.eventPass')}
>
<Ticket className="h-4 w-4" />
</Button>
</Link>
)}
<Button <Button
size="icon" size="icon"
onClick={handleShare} onClick={handleShare}

View file

@ -13,10 +13,11 @@ import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useGroupConversation, useSendDirectMessage, getGroupId } from '@/hooks/useDirectMessages'; import { useGroupConversation, useSendDirectMessage, getGroupId } from '@/hooks/useDirectMessages';
import { useDMRead } from '@/hooks/useDMRead'; import { useDMRead } from '@/hooks/useDMRead';
import { useDmMute } from '@/hooks/useDmMute';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { ArrowLeft, Lock, Loader2, RefreshCw, Send, Users } from 'lucide-react'; import { ArrowLeft, Lock, Loader2, RefreshCw, Send, Users, Volume2, VolumeX } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function ParticipantName({ pubkey }: { pubkey: string }) { function ParticipantName({ pubkey }: { pubkey: string }) {
@ -56,7 +57,9 @@ export default function GroupConversationPage() {
const { data: messages, isLoading, refetch, isRefetching } = useGroupConversation(allParticipants); const { data: messages, isLoading, refetch, isRefetching } = useGroupConversation(allParticipants);
const { markRead } = useDMRead(); const { markRead } = useDMRead();
const { isMuted, toggle: toggleMute } = useDmMute();
const groupId = useMemo(() => getGroupId(allParticipants), [allParticipants]); const groupId = useMemo(() => getGroupId(allParticipants), [allParticipants]);
const muted = isMuted(groupId);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -156,6 +159,16 @@ export default function GroupConversationPage() {
{t('socialUi.groupConversation.encryptedGroup')} {t('socialUi.groupConversation.encryptedGroup')}
</p> </p>
</div> </div>
<Button
variant="ghost"
size="icon"
aria-label={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
title={muted ? t('walletUi.trust.unmute') : t('walletUi.trust.mute')}
onClick={() => toggleMute(groupId)}
className={cn('text-gray-400 hover:text-white', muted && 'text-amber-400')}
>
{muted ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View file

@ -224,8 +224,15 @@ export default function RiderCardPage() {
// ── Page tab: 'card' | 'badges' | 'award' | 'passes' ───────────────────── // ── Page tab: 'card' | 'badges' | 'award' | 'passes' ─────────────────────
const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award' | 'passes'>('card'); const [pageTab, setPageTab] = useState<'card' | 'badges' | 'award' | 'passes'>('card');
const [searchParams] = useSearchParams();
// ── Card state ──────────────────────────────────────────────────────────── // ── Card state ────────────────────────────────────────────────────────────
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>('rider'); const requestedType = searchParams.get('type');
const [cardType, setCardType] = useState<'rider' | 'park' | 'crew' | 'event'>(
requestedType === 'park' || requestedType === 'crew' || requestedType === 'event'
? requestedType
: 'rider',
);
const [theme, setTheme] = useState<ThemeId>('thug'); const [theme, setTheme] = useState<ThemeId>('thug');
const [holographic, setHolographic] = useState(true); const [holographic, setHolographic] = useState(true);
@ -260,7 +267,7 @@ export default function RiderCardPage() {
const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew; const activeCrew = memberships.find(m => m.crew.id === crewId)?.crew ?? memberships[0]?.crew;
// Event pass / wristband: events you organize, or that you RSVP'd going to. // Event pass / wristband: events you organize, or that you RSVP'd going to.
const [eventId, setEventId] = useState(''); const [eventId, setEventId] = useState(() => searchParams.get('event') ?? '');
const { data: allEvents } = useSkateEvents(200); const { data: allEvents } = useSkateEvents(200);
const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey); const { data: rsvpCoords = [] } = useMyRsvpCoords(user?.pubkey);
const myEvents = useMemo(() => { const myEvents = useMemo(() => {
@ -291,7 +298,6 @@ export default function RiderCardPage() {
// Deep link from a venue page: /card?venue=<kind>:<pubkey>:<d> opens the park // Deep link from a venue page: /card?venue=<kind>:<pubkey>:<d> opens the park
// pass editor with that venue preselected. // pass editor with that venue preselected.
const [searchParams] = useSearchParams();
const venueParam = searchParams.get('venue'); const venueParam = searchParams.get('venue');
useEffect(() => { useEffect(() => {
if (!venueParam) return; if (!venueParam) return;