22 changed files with 1264 additions and 19 deletions
@ -0,0 +1,103 @@ |
|||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' |
||||||
|
import { Bell, BellOff } from 'lucide-react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' |
||||||
|
|
||||||
|
export default function NotificationThreadWatchButtons({ event }: { event: Event }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey } = useNostr() |
||||||
|
const watch = useNotificationThreadWatchOptional() |
||||||
|
const [busy, setBusy] = useState<'follow' | 'mute' | null>(null) |
||||||
|
|
||||||
|
if (!watch || !pubkey) return null |
||||||
|
if (hexPubkeysEqual(event.pubkey, normalizeHexPubkey(pubkey))) return null |
||||||
|
|
||||||
|
const followed = watch.isFollowedForNotifications(event) |
||||||
|
const muted = watch.isMutedForNotifications(event) |
||||||
|
|
||||||
|
const onFollow = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
setBusy('follow') |
||||||
|
try { |
||||||
|
if (followed) { |
||||||
|
const ok = await watch.unfollowThreadForNotifications(event) |
||||||
|
if (ok) { |
||||||
|
toast.success(t('Unfollowed thread notifications')) |
||||||
|
} else { |
||||||
|
toast.error(t('Thread notification list update failed')) |
||||||
|
} |
||||||
|
} else { |
||||||
|
await watch.followThreadForNotifications(event) |
||||||
|
toast.success(t('Following thread for notifications')) |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) |
||||||
|
} finally { |
||||||
|
setBusy(null) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const onMute = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
setBusy('mute') |
||||||
|
try { |
||||||
|
if (muted) { |
||||||
|
const ok = await watch.unmuteThreadForNotifications(event) |
||||||
|
if (ok) { |
||||||
|
toast.success(t('Unmuted thread notifications')) |
||||||
|
} else { |
||||||
|
toast.error(t('Thread notification list update failed')) |
||||||
|
} |
||||||
|
} else { |
||||||
|
await watch.muteThreadForNotifications(event) |
||||||
|
toast.success(t('Muted thread for notifications')) |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) |
||||||
|
} finally { |
||||||
|
setBusy(null) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'rounded p-1 transition-colors enabled:hover:bg-muted', |
||||||
|
followed |
||||||
|
? 'bg-primary/15 text-primary ring-1 ring-inset ring-primary/35' |
||||||
|
: 'text-muted-foreground' |
||||||
|
)} |
||||||
|
disabled={busy !== null} |
||||||
|
aria-pressed={followed} |
||||||
|
title={followed ? t('Unfollow thread notifications') : t('Follow this')} |
||||||
|
aria-label={followed ? t('Unfollow thread notifications') : t('Follow this')} |
||||||
|
onClick={onFollow} |
||||||
|
> |
||||||
|
<Bell className={cn('size-4', followed && 'fill-current')} /> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'rounded p-1 transition-colors enabled:hover:bg-muted', |
||||||
|
muted |
||||||
|
? 'bg-destructive/15 text-destructive ring-1 ring-inset ring-destructive/30' |
||||||
|
: 'text-muted-foreground' |
||||||
|
)} |
||||||
|
disabled={busy !== null} |
||||||
|
aria-pressed={muted} |
||||||
|
title={muted ? t('Unmute thread notifications') : t('Mute this')} |
||||||
|
aria-label={muted ? t('Unmute thread notifications') : t('Mute this')} |
||||||
|
onClick={onMute} |
||||||
|
> |
||||||
|
<BellOff className={cn('size-4', muted && 'fill-current')} /> |
||||||
|
</button> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,155 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
getParentEventHexId, |
||||||
|
getRootEventHexId, |
||||||
|
isNip18RepostKind, |
||||||
|
isReplyNoteEvent, |
||||||
|
normalizeReplaceableCoordinateString, |
||||||
|
resolveDeclaredThreadRootEventHex |
||||||
|
} from '@/lib/event' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
/** Max `e` ids per REQ filter shard (relay limits). */ |
||||||
|
export const NOTIFICATION_THREAD_WATCH_E_CHUNK = 24 |
||||||
|
/** Max `a` coordinates per REQ filter shard. */ |
||||||
|
export const NOTIFICATION_THREAD_WATCH_A_CHUNK = 16 |
||||||
|
/** Cap stored refs driving live `#e` / `#a` shards (newest wins by list tag order). */ |
||||||
|
export const NOTIFICATION_THREAD_WATCH_MAX_E_IDS = 120 |
||||||
|
export const NOTIFICATION_THREAD_WATCH_MAX_A_COORDS = 60 |
||||||
|
|
||||||
|
export type TThreadWatchListRefs = { |
||||||
|
eHexLower: Set<string> |
||||||
|
aCoordLower: Set<string> |
||||||
|
} |
||||||
|
|
||||||
|
export function emptyThreadWatchRefs(): TThreadWatchListRefs { |
||||||
|
return { eHexLower: new Set<string>(), aCoordLower: new Set<string>() } |
||||||
|
} |
||||||
|
|
||||||
|
export function parseThreadWatchListRefs(ev: Event | null | undefined): TThreadWatchListRefs { |
||||||
|
const eHexLower = new Set<string>() |
||||||
|
const aCoordLower = new Set<string>() |
||||||
|
if (!ev?.tags) return { eHexLower, aCoordLower } |
||||||
|
for (const t of ev.tags) { |
||||||
|
if ((t[0] === 'e' || t[0] === 'E') && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) { |
||||||
|
eHexLower.add(t[1].toLowerCase()) |
||||||
|
} |
||||||
|
if ((t[0] === 'a' || t[0] === 'A') && t[1]) { |
||||||
|
const n = normalizeReplaceableCoordinateString(t[1]) |
||||||
|
if (n) aCoordLower.add(n) |
||||||
|
} |
||||||
|
} |
||||||
|
return { eHexLower, aCoordLower } |
||||||
|
} |
||||||
|
|
||||||
|
function addResolvedHexCandidates(event: Event, into: Set<string>) { |
||||||
|
const add = (h?: string) => { |
||||||
|
if (!h || !/^[0-9a-f]{64}$/i.test(h)) return |
||||||
|
const L = h.toLowerCase() |
||||||
|
into.add(L) |
||||||
|
into.add(resolveDeclaredThreadRootEventHex(L)) |
||||||
|
} |
||||||
|
add(getRootEventHexId(event)) |
||||||
|
add(getParentEventHexId(event)) |
||||||
|
for (const t of event.tags) { |
||||||
|
if ((t[0] === 'e' || t[0] === 'E') && t[1] && /^[0-9a-f]{64}$/i.test(t[1])) { |
||||||
|
add(t[1]) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function listNormalizedACoordsFromEvent(event: Event): string[] { |
||||||
|
const out: string[] = [] |
||||||
|
for (const t of event.tags) { |
||||||
|
if ((t[0] === 'a' || t[0] === 'A') && t[1]) { |
||||||
|
const n = normalizeReplaceableCoordinateString(t[1]) |
||||||
|
if (n) out.push(n) |
||||||
|
} |
||||||
|
} |
||||||
|
return [...new Set(out)] |
||||||
|
} |
||||||
|
|
||||||
|
export function threadWatchMatchesRefs( |
||||||
|
event: Event, |
||||||
|
refs: TThreadWatchListRefs |
||||||
|
): boolean { |
||||||
|
if (!refs.eHexLower.size && !refs.aCoordLower.size) return false |
||||||
|
const hexCandidates = new Set<string>() |
||||||
|
addResolvedHexCandidates(event, hexCandidates) |
||||||
|
for (const h of hexCandidates) { |
||||||
|
if (refs.eHexLower.has(h)) return true |
||||||
|
} |
||||||
|
for (const ac of listNormalizedACoordsFromEvent(event)) { |
||||||
|
if (refs.aCoordLower.has(ac)) return true |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
function threadWatchListTagMatchesEvent(tag: string[], event: Event): boolean { |
||||||
|
const k = tag[0] |
||||||
|
if ((k === 'e' || k === 'E') && tag[1] && /^[0-9a-f]{64}$/i.test(tag[1])) { |
||||||
|
const id = tag[1].toLowerCase() |
||||||
|
const refs: TThreadWatchListRefs = { eHexLower: new Set([id]), aCoordLower: new Set() } |
||||||
|
return threadWatchMatchesRefs(event, refs) |
||||||
|
} |
||||||
|
if ((k === 'a' || k === 'A') && tag[1]) { |
||||||
|
const n = normalizeReplaceableCoordinateString(tag[1]) |
||||||
|
if (!n) return false |
||||||
|
const refs: TThreadWatchListRefs = { eHexLower: new Set(), aCoordLower: new Set([n]) } |
||||||
|
return threadWatchMatchesRefs(event, refs) |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Drops every `e` / `a` ref that applies to `event` (same rules as {@link threadWatchMatchesRefs}), |
||||||
|
* so toggling off works when the list stores a thread root id but the UI row is a reply (or vice versa). |
||||||
|
*/ |
||||||
|
export function listTagsAfterRemovingThreadWatchMatches( |
||||||
|
listTags: string[][], |
||||||
|
event: Event |
||||||
|
): string[][] | null { |
||||||
|
let changed = false |
||||||
|
const next = listTags.filter((t) => { |
||||||
|
if (threadWatchListTagMatchesEvent(t, event)) { |
||||||
|
changed = true |
||||||
|
return false |
||||||
|
} |
||||||
|
return true |
||||||
|
}) |
||||||
|
return changed ? next : null |
||||||
|
} |
||||||
|
|
||||||
|
/** Replies, reactions, reposts, zaps-on-note, comments, poll votes, highlights — not plain top-level notes. */ |
||||||
|
export function isNotificationThreadInteractionEvent(event: Event): boolean { |
||||||
|
if (event.kind === kinds.ShortTextNote) return isReplyNoteEvent(event) |
||||||
|
if (event.kind === kinds.Reaction || event.kind === ExtendedKind.EXTERNAL_REACTION) return true |
||||||
|
if (isNip18RepostKind(event.kind)) return true |
||||||
|
if (event.kind === kinds.Zap) { |
||||||
|
return event.tags.some( |
||||||
|
(t) => t[0] === 'e' || t[0] === 'E' || t[0] === 'a' || t[0] === 'A' |
||||||
|
) |
||||||
|
} |
||||||
|
if (event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) return true |
||||||
|
if (event.kind === ExtendedKind.POLL_RESPONSE) return true |
||||||
|
if (event.kind === kinds.Highlights) return true |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
export function extractEHexIdsForNotificationReq(refs: TThreadWatchListRefs): string[] { |
||||||
|
return [...refs.eHexLower].slice(0, NOTIFICATION_THREAD_WATCH_MAX_E_IDS) |
||||||
|
} |
||||||
|
|
||||||
|
export function extractACoordsForNotificationReq(refs: TThreadWatchListRefs): string[] { |
||||||
|
return [...refs.aCoordLower].slice(0, NOTIFICATION_THREAD_WATCH_MAX_A_COORDS) |
||||||
|
} |
||||||
|
|
||||||
|
export function chunkArray<T>(arr: T[], size: number): T[][] { |
||||||
|
if (size <= 0) return arr.length ? [arr] : [] |
||||||
|
const out: T[][] = [] |
||||||
|
for (let i = 0; i < arr.length; i += size) { |
||||||
|
out.push(arr.slice(i, i + size)) |
||||||
|
} |
||||||
|
return out |
||||||
|
} |
||||||
@ -0,0 +1,219 @@ |
|||||||
|
import JsonViewDialog from '@/components/JsonViewDialog' |
||||||
|
import PersonalListBech32List from '@/components/PersonalListBech32List' |
||||||
|
import { RefreshButton } from '@/components/RefreshButton' |
||||||
|
import { |
||||||
|
AlertDialog, |
||||||
|
AlertDialogAction, |
||||||
|
AlertDialogCancel, |
||||||
|
AlertDialogContent, |
||||||
|
AlertDialogDescription, |
||||||
|
AlertDialogFooter, |
||||||
|
AlertDialogHeader, |
||||||
|
AlertDialogTitle |
||||||
|
} from '@/components/ui/alert-dialog' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
DropdownMenu, |
||||||
|
DropdownMenuContent, |
||||||
|
DropdownMenuItem, |
||||||
|
DropdownMenuTrigger |
||||||
|
} from '@/components/ui/dropdown-menu' |
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||||
|
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||||
|
import { createReplaceablePersonalListDraftEvent } from '@/lib/draft-event' |
||||||
|
import { notificationThreadWatchBech32IdsFromListEvent } from '@/lib/personal-list-refs' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useNotificationThreadWatch } from '@/providers/NotificationThreadWatchProvider' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import dayjs from 'dayjs' |
||||||
|
import { Code, Eraser, MoreVertical } from 'lucide-react' |
||||||
|
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import NotFoundPage from './NotFoundPage' |
||||||
|
|
||||||
|
type TVariant = 'follow' | 'mute' |
||||||
|
|
||||||
|
type TPageProps = { index?: number; hideTitlebar?: boolean; variant: TVariant } |
||||||
|
|
||||||
|
const NotificationThreadWatchListPageInner = forwardRef<HTMLDivElement, TPageProps>( |
||||||
|
function NotificationThreadWatchListPageInner({ index, hideTitlebar = false, variant }, ref) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||||
|
const { profile, pubkey, publish } = useNostr() |
||||||
|
const { eventsIFollowListEvent, eventsIMutedListEvent, refreshNotificationThreadListsFromRelays } = |
||||||
|
useNotificationThreadWatch() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
const [jsonOpen, setJsonOpen] = useState(false) |
||||||
|
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||||
|
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) |
||||||
|
const [cleaning, setCleaning] = useState(false) |
||||||
|
|
||||||
|
const kind = |
||||||
|
variant === 'follow' |
||||||
|
? ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST |
||||||
|
: ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST |
||||||
|
const listEvent = variant === 'follow' ? eventsIFollowListEvent : eventsIMutedListEvent |
||||||
|
const listMode = variant === 'follow' ? 'notificationThreadFollow' : 'notificationThreadMute' |
||||||
|
|
||||||
|
const bech32Ids = useMemo(() => notificationThreadWatchBech32IdsFromListEvent(listEvent), [listEvent]) |
||||||
|
|
||||||
|
const refreshFromRelays = useCallback(async () => { |
||||||
|
await refreshNotificationThreadListsFromRelays() |
||||||
|
}, [refreshNotificationThreadListsFromRelays]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!hideTitlebar) { |
||||||
|
registerPrimaryPanelRefresh(null) |
||||||
|
return |
||||||
|
} |
||||||
|
registerPrimaryPanelRefresh(() => { |
||||||
|
void refreshFromRelays() |
||||||
|
}) |
||||||
|
return () => registerPrimaryPanelRefresh(null) |
||||||
|
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays]) |
||||||
|
|
||||||
|
const openJson = useCallback(() => { |
||||||
|
setJsonPayload({ |
||||||
|
listEvent: listEvent ?? null, |
||||||
|
derivedBech32Ids: bech32Ids, |
||||||
|
kind, |
||||||
|
note: |
||||||
|
variant === 'follow' |
||||||
|
? 'Kind 19130 (Imwald): `e` / `a` tags — threads whose replies appear in your notifications as if you were the OP.' |
||||||
|
: 'Kind 19132 (Imwald): `e` / `a` tags — threads whose reply-style notifications are hidden.' |
||||||
|
}) |
||||||
|
}, [listEvent, bech32Ids, kind, variant]) |
||||||
|
|
||||||
|
const handleCleanList = useCallback(async () => { |
||||||
|
if (!pubkey || cleaning) return |
||||||
|
setCleaning(true) |
||||||
|
try { |
||||||
|
if (dayjs().unix() === listEvent?.created_at) { |
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000)) |
||||||
|
} |
||||||
|
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({ |
||||||
|
accountPubkey: pubkey, |
||||||
|
favoriteRelays: favoriteRelays ?? [], |
||||||
|
blockedRelays |
||||||
|
}) |
||||||
|
const draft = createReplaceablePersonalListDraftEvent(kind, [], '') |
||||||
|
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays }) |
||||||
|
await indexedDb.putReplaceableEvent(published) |
||||||
|
await refreshNotificationThreadListsFromRelays() |
||||||
|
toast.success(t('List cleaned')) |
||||||
|
} catch (e) { |
||||||
|
toast.error(t('Failed to clean list') + ': ' + (e instanceof Error ? e.message : String(e))) |
||||||
|
} finally { |
||||||
|
setCleaning(false) |
||||||
|
setCleanConfirmOpen(false) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
pubkey, |
||||||
|
cleaning, |
||||||
|
listEvent?.created_at, |
||||||
|
kind, |
||||||
|
favoriteRelays, |
||||||
|
blockedRelays, |
||||||
|
publish, |
||||||
|
refreshNotificationThreadListsFromRelays, |
||||||
|
t |
||||||
|
]) |
||||||
|
|
||||||
|
if (!profile || !pubkey) { |
||||||
|
return <NotFoundPage /> |
||||||
|
} |
||||||
|
|
||||||
|
const titleKey = |
||||||
|
variant === 'follow' ? 'Notification thread follow list' : 'Notification thread mute list' |
||||||
|
const emptyKey = |
||||||
|
variant === 'follow' |
||||||
|
? 'No entries in notification thread follow list' |
||||||
|
: 'No entries in notification thread mute list' |
||||||
|
|
||||||
|
return ( |
||||||
|
<SecondaryPageLayout |
||||||
|
ref={ref} |
||||||
|
index={index} |
||||||
|
title={hideTitlebar ? undefined : t(titleKey)} |
||||||
|
hideBackButton={hideTitlebar} |
||||||
|
controls={ |
||||||
|
hideTitlebar ? undefined : ( |
||||||
|
<div className="flex items-center gap-0"> |
||||||
|
<RefreshButton onClick={() => void refreshFromRelays()} /> |
||||||
|
<DropdownMenu> |
||||||
|
<DropdownMenuTrigger asChild> |
||||||
|
<Button variant="ghost" size="icon" aria-label={t('More options')}> |
||||||
|
<MoreVertical className="size-4" /> |
||||||
|
</Button> |
||||||
|
</DropdownMenuTrigger> |
||||||
|
<DropdownMenuContent align="end"> |
||||||
|
<DropdownMenuItem onClick={() => openJson()}> |
||||||
|
<Code className="mr-2 size-4" /> |
||||||
|
{t('View JSON')} |
||||||
|
</DropdownMenuItem> |
||||||
|
<DropdownMenuItem |
||||||
|
className="text-destructive focus:text-destructive" |
||||||
|
onClick={() => setCleanConfirmOpen(true)} |
||||||
|
> |
||||||
|
<Eraser className="mr-2 size-4" /> |
||||||
|
{t('Clean list')} |
||||||
|
</DropdownMenuItem> |
||||||
|
</DropdownMenuContent> |
||||||
|
</DropdownMenu> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||||
|
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}> |
||||||
|
<AlertDialogContent> |
||||||
|
<AlertDialogHeader> |
||||||
|
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle> |
||||||
|
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription> |
||||||
|
</AlertDialogHeader> |
||||||
|
<AlertDialogFooter> |
||||||
|
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel> |
||||||
|
<AlertDialogAction |
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" |
||||||
|
disabled={cleaning} |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault() |
||||||
|
void handleCleanList() |
||||||
|
}} |
||||||
|
> |
||||||
|
{cleaning ? t('loading...') : t('Clean list')} |
||||||
|
</AlertDialogAction> |
||||||
|
</AlertDialogFooter> |
||||||
|
</AlertDialogContent> |
||||||
|
</AlertDialog> |
||||||
|
<div key={listEvent?.id ?? 'none'} className="min-h-[30vh] pt-1"> |
||||||
|
{bech32Ids.length === 0 ? ( |
||||||
|
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t(emptyKey)}</p> |
||||||
|
) : ( |
||||||
|
<PersonalListBech32List bech32Ids={bech32Ids} listMode={listMode} /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</SecondaryPageLayout> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
export const NotificationThreadFollowListPage = forwardRef<HTMLDivElement, Omit<TPageProps, 'variant'>>( |
||||||
|
function NotificationThreadFollowListPage(props, ref) { |
||||||
|
return <NotificationThreadWatchListPageInner {...props} variant="follow" ref={ref} /> |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
export const NotificationThreadMuteListPage = forwardRef<HTMLDivElement, Omit<TPageProps, 'variant'>>( |
||||||
|
function NotificationThreadMuteListPage(props, ref) { |
||||||
|
return <NotificationThreadWatchListPageInner {...props} variant="mute" ref={ref} /> |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
NotificationThreadFollowListPage.displayName = 'NotificationThreadFollowListPage' |
||||||
|
NotificationThreadMuteListPage.displayName = 'NotificationThreadMuteListPage' |
||||||
@ -0,0 +1,454 @@ |
|||||||
|
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||||
|
import { buildATag, buildETag, createReplaceablePersonalListDraftEvent } from '@/lib/draft-event' |
||||||
|
import { getReplaceableCoordinateFromEvent, isReplaceableEvent, normalizeReplaceableCoordinateString } from '@/lib/event' |
||||||
|
import { |
||||||
|
bookmarkListTagsAfterRemovingRef, |
||||||
|
decodePersonalListBech32Ref, |
||||||
|
type TPersonalListBech32Ref |
||||||
|
} from '@/lib/personal-list-mutations' |
||||||
|
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' |
||||||
|
import { |
||||||
|
listTagsAfterRemovingThreadWatchMatches, |
||||||
|
parseThreadWatchListRefs, |
||||||
|
threadWatchMatchesRefs |
||||||
|
} from '@/lib/notification-thread-watch' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import indexedDb from '@/services/indexed-db.service' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { useCallback, useContext, useEffect, useMemo, useState, createContext, type ReactNode } from 'react' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
|
||||||
|
export type TNotificationThreadWatchContext = { |
||||||
|
eventsIFollowListEvent: Event | null |
||||||
|
eventsIMutedListEvent: Event | null |
||||||
|
followRefs: ReturnType<typeof parseThreadWatchListRefs> |
||||||
|
mutedRefs: ReturnType<typeof parseThreadWatchListRefs> |
||||||
|
isFollowedForNotifications: (event: Event) => boolean |
||||||
|
isMutedForNotifications: (event: Event) => boolean |
||||||
|
followThreadForNotifications: (event: Event) => Promise<void> |
||||||
|
muteThreadForNotifications: (event: Event) => Promise<void> |
||||||
|
unfollowThreadForNotifications: (event: Event) => Promise<boolean> |
||||||
|
unmuteThreadForNotifications: (event: Event) => Promise<boolean> |
||||||
|
/** Refetch both lists from relays + IDB and update local state (e.g. settings list editor). */ |
||||||
|
refreshNotificationThreadListsFromRelays: () => Promise<void> |
||||||
|
removeFollowRefByBech32: (bech32Id: string) => Promise<boolean> |
||||||
|
removeMuteRefByBech32: (bech32Id: string) => Promise<boolean> |
||||||
|
} |
||||||
|
|
||||||
|
const NotificationThreadWatchContext = createContext<TNotificationThreadWatchContext | undefined>(undefined) |
||||||
|
|
||||||
|
function refKeyForEvent(event: Event): TPersonalListBech32Ref { |
||||||
|
if (isReplaceableEvent(event.kind)) { |
||||||
|
const n = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(event)) |
||||||
|
return { aCoordLower: n } |
||||||
|
} |
||||||
|
return { eIdLower: event.id.toLowerCase() } |
||||||
|
} |
||||||
|
|
||||||
|
function listTagsWithoutRef(tags: string[][], ref: TPersonalListBech32Ref): string[][] | null { |
||||||
|
return bookmarkListTagsAfterRemovingRef(tags, ref) |
||||||
|
} |
||||||
|
|
||||||
|
function mergeTagsPreservingMeta(baseTags: string[][], refTags: string[][]): string[][] { |
||||||
|
const meta = baseTags.filter( |
||||||
|
(t) => t[0] === 'title' || t[0] === 'image' || t[0] === 'description' || t[0] === 'd' |
||||||
|
) |
||||||
|
const seenE = new Set<string>() |
||||||
|
const seenA = new Set<string>() |
||||||
|
const refs: string[][] = [] |
||||||
|
const pushRef = (t: string[]) => { |
||||||
|
if (t[0] === 'e' || t[0] === 'E') { |
||||||
|
const id = t[1]?.toLowerCase() |
||||||
|
if (id && !seenE.has(id)) { |
||||||
|
seenE.add(id) |
||||||
|
refs.push(t) |
||||||
|
} |
||||||
|
} else if (t[0] === 'a' || t[0] === 'A') { |
||||||
|
const n = normalizeReplaceableCoordinateString(t[1] ?? '') |
||||||
|
if (n && !seenA.has(n)) { |
||||||
|
seenA.add(n) |
||||||
|
refs.push([t[0], n, ...t.slice(2)]) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
for (const t of baseTags) pushRef(t) |
||||||
|
for (const t of refTags) pushRef(t) |
||||||
|
return [...meta, ...refs] |
||||||
|
} |
||||||
|
|
||||||
|
export function NotificationThreadWatchProvider({ children }: { children: ReactNode }) { |
||||||
|
const { pubkey: accountPubkey, publish } = useNostr() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
const [eventsIFollowListEvent, setEventsIFollowListEvent] = useState<Event | null>(null) |
||||||
|
const [eventsIMutedListEvent, setEventsIMutedListEvent] = useState<Event | null>(null) |
||||||
|
|
||||||
|
const buildComprehensiveRelayList = useCallback(async () => { |
||||||
|
if (!accountPubkey) return [] as string[] |
||||||
|
return buildAccountListRelayUrlsForMerge({ |
||||||
|
accountPubkey, |
||||||
|
favoriteRelays: favoriteRelays ?? [], |
||||||
|
blockedRelays |
||||||
|
}) |
||||||
|
}, [accountPubkey, favoriteRelays, blockedRelays]) |
||||||
|
|
||||||
|
const hydrateFromStorage = useCallback(async () => { |
||||||
|
if (!accountPubkey) { |
||||||
|
setEventsIFollowListEvent(null) |
||||||
|
setEventsIMutedListEvent(null) |
||||||
|
return |
||||||
|
} |
||||||
|
const pk = accountPubkey.trim().toLowerCase() |
||||||
|
const [fromIdbFollow, fromIdbMuted] = await Promise.all([ |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST), |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST) |
||||||
|
]) |
||||||
|
if (fromIdbFollow) setEventsIFollowListEvent(fromIdbFollow) |
||||||
|
if (fromIdbMuted) setEventsIMutedListEvent(fromIdbMuted) |
||||||
|
}, [accountPubkey]) |
||||||
|
|
||||||
|
const refreshNotificationThreadListsFromRelays = useCallback(async () => { |
||||||
|
if (!accountPubkey) { |
||||||
|
setEventsIFollowListEvent(null) |
||||||
|
setEventsIMutedListEvent(null) |
||||||
|
return |
||||||
|
} |
||||||
|
const urls = await buildComprehensiveRelayList() |
||||||
|
if (!urls.length) { |
||||||
|
await hydrateFromStorage() |
||||||
|
return |
||||||
|
} |
||||||
|
const pk = accountPubkey.trim().toLowerCase() |
||||||
|
const [remoteFollow, remoteMuted] = await Promise.all([ |
||||||
|
fetchLatestReplaceableListEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, urls), |
||||||
|
fetchLatestReplaceableListEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, urls) |
||||||
|
]) |
||||||
|
const [idbFollow, idbMuted] = await Promise.all([ |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST), |
||||||
|
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST) |
||||||
|
]) |
||||||
|
const pick = (remote: Event | undefined, idb: Event | null | undefined) => { |
||||||
|
if (remote && idb) return remote.created_at >= idb.created_at ? remote : idb |
||||||
|
return remote ?? idb ?? null |
||||||
|
} |
||||||
|
const f = pick(remoteFollow, idbFollow ?? undefined) |
||||||
|
const m = pick(remoteMuted, idbMuted ?? undefined) |
||||||
|
if (f) { |
||||||
|
await indexedDb.putReplaceableEvent(f) |
||||||
|
setEventsIFollowListEvent(f) |
||||||
|
} |
||||||
|
if (m) { |
||||||
|
await indexedDb.putReplaceableEvent(m) |
||||||
|
setEventsIMutedListEvent(m) |
||||||
|
} |
||||||
|
}, [accountPubkey, buildComprehensiveRelayList, hydrateFromStorage]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
void hydrateFromStorage() |
||||||
|
}, [hydrateFromStorage]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!accountPubkey) { |
||||||
|
setEventsIFollowListEvent(null) |
||||||
|
setEventsIMutedListEvent(null) |
||||||
|
return |
||||||
|
} |
||||||
|
let cancelled = false |
||||||
|
void (async () => { |
||||||
|
if (cancelled) return |
||||||
|
await refreshNotificationThreadListsFromRelays() |
||||||
|
})() |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [accountPubkey, refreshNotificationThreadListsFromRelays]) |
||||||
|
|
||||||
|
const followRefs = useMemo( |
||||||
|
() => parseThreadWatchListRefs(eventsIFollowListEvent), |
||||||
|
[eventsIFollowListEvent] |
||||||
|
) |
||||||
|
const mutedRefs = useMemo( |
||||||
|
() => parseThreadWatchListRefs(eventsIMutedListEvent), |
||||||
|
[eventsIMutedListEvent] |
||||||
|
) |
||||||
|
|
||||||
|
const isFollowedForNotifications = useCallback( |
||||||
|
(event: Event) => threadWatchMatchesRefs(event, followRefs), |
||||||
|
[followRefs] |
||||||
|
) |
||||||
|
const isMutedForNotifications = useCallback( |
||||||
|
(event: Event) => threadWatchMatchesRefs(event, mutedRefs), |
||||||
|
[mutedRefs] |
||||||
|
) |
||||||
|
|
||||||
|
const publishList = useCallback( |
||||||
|
async (kind: number, nextTags: string[][], content: string) => { |
||||||
|
if (!accountPubkey) return |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
const draft = createReplaceablePersonalListDraftEvent(kind, nextTags, content) |
||||||
|
const ev = await publish(draft, { specifiedRelayUrls: comprehensiveRelays }) |
||||||
|
const stored = await indexedDb.putReplaceableEvent(ev) |
||||||
|
if (kind === ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST) { |
||||||
|
setEventsIFollowListEvent(stored) |
||||||
|
} else { |
||||||
|
setEventsIMutedListEvent(stored) |
||||||
|
} |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publish] |
||||||
|
) |
||||||
|
|
||||||
|
const followThreadForNotifications = useCallback( |
||||||
|
async (event: Event) => { |
||||||
|
if (!accountPubkey) return |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
const refTag = isReplaceableEvent(event.kind) ? buildATag(event) : buildETag(event.id, event.pubkey) |
||||||
|
let followEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!followEv) { |
||||||
|
followEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
let mutedEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!mutedEv) { |
||||||
|
mutedEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
|
||||||
|
const mutedStripped = mutedEv ? listTagsAfterRemovingThreadWatchMatches(mutedEv.tags, event) : null |
||||||
|
if (mutedStripped) { |
||||||
|
await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, mutedStripped, mutedEv.content) |
||||||
|
} |
||||||
|
|
||||||
|
const curTags = followEv?.tags ?? [] |
||||||
|
const curFollowRefs = parseThreadWatchListRefs(followEv) |
||||||
|
if (threadWatchMatchesRefs(event, curFollowRefs)) { |
||||||
|
return |
||||||
|
} |
||||||
|
const next = mergeTagsPreservingMeta(curTags, [refTag]) |
||||||
|
await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv?.content ?? '') |
||||||
|
logger.component('NotificationThreadWatchProvider', 'follow thread for notifications', { |
||||||
|
kind: event.kind |
||||||
|
}) |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const muteThreadForNotifications = useCallback( |
||||||
|
async (event: Event) => { |
||||||
|
if (!accountPubkey) return |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
const refTag = isReplaceableEvent(event.kind) ? buildATag(event) : buildETag(event.id, event.pubkey) |
||||||
|
let mutedEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!mutedEv) { |
||||||
|
mutedEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
let followEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!followEv) { |
||||||
|
followEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
|
||||||
|
const followStripped = followEv ? listTagsAfterRemovingThreadWatchMatches(followEv.tags, event) : null |
||||||
|
if (followStripped) { |
||||||
|
await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, followStripped, followEv.content) |
||||||
|
} |
||||||
|
|
||||||
|
const curTags = mutedEv?.tags ?? [] |
||||||
|
const curMutedRefs = parseThreadWatchListRefs(mutedEv) |
||||||
|
if (threadWatchMatchesRefs(event, curMutedRefs)) { |
||||||
|
return |
||||||
|
} |
||||||
|
const next = mergeTagsPreservingMeta(curTags, [refTag]) |
||||||
|
await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv?.content ?? '') |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const unfollowThreadForNotifications = useCallback( |
||||||
|
async (event: Event): Promise<boolean> => { |
||||||
|
if (!accountPubkey) return false |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
let followEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!followEv) { |
||||||
|
followEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
if (!followEv) return false |
||||||
|
const next = listTagsAfterRemovingThreadWatchMatches(followEv.tags, event) |
||||||
|
if (!next) return false |
||||||
|
await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) |
||||||
|
return true |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const unmuteThreadForNotifications = useCallback( |
||||||
|
async (event: Event): Promise<boolean> => { |
||||||
|
if (!accountPubkey) return false |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
let mutedEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!mutedEv) { |
||||||
|
mutedEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
if (!mutedEv) return false |
||||||
|
const next = listTagsAfterRemovingThreadWatchMatches(mutedEv.tags, event) |
||||||
|
if (!next) return false |
||||||
|
await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) |
||||||
|
return true |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const removeFollowRefByBech32 = useCallback( |
||||||
|
async (bech32Id: string): Promise<boolean> => { |
||||||
|
const ref = decodePersonalListBech32Ref(bech32Id) |
||||||
|
if (!ref || !accountPubkey) return false |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
let followEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!followEv) { |
||||||
|
followEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
if (!followEv) return false |
||||||
|
const next = listTagsWithoutRef(followEv.tags, ref) |
||||||
|
if (!next) return false |
||||||
|
await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) |
||||||
|
return true |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const removeMuteRefByBech32 = useCallback( |
||||||
|
async (bech32Id: string): Promise<boolean> => { |
||||||
|
const ref = decodePersonalListBech32Ref(bech32Id) |
||||||
|
if (!ref || !accountPubkey) return false |
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
let mutedEv = |
||||||
|
(await fetchLatestReplaceableListEvent( |
||||||
|
accountPubkey, |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, |
||||||
|
comprehensiveRelays |
||||||
|
)) ?? null |
||||||
|
if (!mutedEv) { |
||||||
|
mutedEv = |
||||||
|
(await indexedDb.getReplaceableEvent( |
||||||
|
accountPubkey.trim().toLowerCase(), |
||||||
|
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST |
||||||
|
)) ?? null |
||||||
|
} |
||||||
|
if (!mutedEv) return false |
||||||
|
const next = listTagsWithoutRef(mutedEv.tags, ref) |
||||||
|
if (!next) return false |
||||||
|
await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) |
||||||
|
return true |
||||||
|
}, |
||||||
|
[accountPubkey, buildComprehensiveRelayList, publishList] |
||||||
|
) |
||||||
|
|
||||||
|
const value = useMemo( |
||||||
|
() => ({ |
||||||
|
eventsIFollowListEvent, |
||||||
|
eventsIMutedListEvent, |
||||||
|
followRefs, |
||||||
|
mutedRefs, |
||||||
|
isFollowedForNotifications, |
||||||
|
isMutedForNotifications, |
||||||
|
followThreadForNotifications, |
||||||
|
muteThreadForNotifications, |
||||||
|
unfollowThreadForNotifications, |
||||||
|
unmuteThreadForNotifications, |
||||||
|
refreshNotificationThreadListsFromRelays, |
||||||
|
removeFollowRefByBech32, |
||||||
|
removeMuteRefByBech32 |
||||||
|
}), |
||||||
|
[ |
||||||
|
eventsIFollowListEvent, |
||||||
|
eventsIMutedListEvent, |
||||||
|
followRefs, |
||||||
|
mutedRefs, |
||||||
|
isFollowedForNotifications, |
||||||
|
isMutedForNotifications, |
||||||
|
followThreadForNotifications, |
||||||
|
muteThreadForNotifications, |
||||||
|
unfollowThreadForNotifications, |
||||||
|
unmuteThreadForNotifications, |
||||||
|
refreshNotificationThreadListsFromRelays, |
||||||
|
removeFollowRefByBech32, |
||||||
|
removeMuteRefByBech32 |
||||||
|
] |
||||||
|
) |
||||||
|
|
||||||
|
return ( |
||||||
|
<NotificationThreadWatchContext.Provider value={value}>{children}</NotificationThreadWatchContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export function useNotificationThreadWatch(): TNotificationThreadWatchContext { |
||||||
|
const ctx = useContext(NotificationThreadWatchContext) |
||||||
|
if (!ctx) { |
||||||
|
throw new Error('useNotificationThreadWatch must be used within NotificationThreadWatchProvider') |
||||||
|
} |
||||||
|
return ctx |
||||||
|
} |
||||||
|
|
||||||
|
export function useNotificationThreadWatchOptional(): TNotificationThreadWatchContext | undefined { |
||||||
|
return useContext(NotificationThreadWatchContext) |
||||||
|
} |
||||||
Loading…
Reference in new issue