22 changed files with 1264 additions and 19 deletions
@ -0,0 +1,103 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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