From 19605e0c190610897e8b77221c884854e1943d09 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 15 May 2026 09:41:55 +0200 Subject: [PATCH] add following and muting events --- src/App.tsx | 5 +- src/PageManager.tsx | 54 ++- src/components/NoteStats/index.tsx | 3 + .../NotificationThreadWatchButtons/index.tsx | 103 ++++ .../PersonalListBech32List/index.tsx | 2 +- .../PersonalListNoteRefRow/index.tsx | 33 +- src/constants.ts | 12 +- src/contexts/primary-note-view-context.tsx | 2 + src/i18n/locales/en.ts | 15 +- src/lib/draft-event.ts | 14 + src/lib/link.ts | 3 + src/lib/notification-thread-watch.ts | 155 ++++++ src/lib/personal-list-refs.ts | 8 + .../primary/SpellsPage/fauxSpellFeeds.ts | 31 ++ src/pages/primary/SpellsPage/index.tsx | 6 +- .../primary/SpellsPage/useSpellsPageFeed.ts | 91 +++- .../NotificationThreadWatchListPage.tsx | 219 +++++++++ .../PersonalListsSettingsPage/index.tsx | 37 +- .../NotificationThreadWatchProvider.tsx | 454 ++++++++++++++++++ src/routes.tsx | 12 + src/services/indexed-db.service.ts | 20 +- src/services/navigation.service.ts | 4 + 22 files changed, 1264 insertions(+), 19 deletions(-) create mode 100644 src/components/NotificationThreadWatchButtons/index.tsx create mode 100644 src/lib/notification-thread-watch.ts create mode 100644 src/pages/secondary/NotificationThreadWatchListPage.tsx create mode 100644 src/providers/NotificationThreadWatchProvider.tsx diff --git a/src/App.tsx b/src/App.tsx index d0c03be2..e3980150 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import PublishSuccessSubtleIndicator from '@/components/PublishSuccessSubtleIndi import ReadAloudPlayerModal from '@/components/ReadAloudPlayerModal' import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' +import { NotificationThreadWatchProvider } from '@/providers/NotificationThreadWatchProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider' @@ -51,7 +52,8 @@ export default function App(): JSX.Element { - + + @@ -69,6 +71,7 @@ export default function App(): JSX.Element { + diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 5b6ad39f..2f98be16 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -101,6 +101,16 @@ const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePag const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage')) const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) +const PrimaryNotificationThreadFollowListPageLazy = lazy(() => + import('@/pages/secondary/NotificationThreadWatchListPage').then((m) => ({ + default: m.NotificationThreadFollowListPage + })) +) +const PrimaryNotificationThreadMuteListPageLazy = lazy(() => + import('@/pages/secondary/NotificationThreadWatchListPage').then((m) => ({ + default: m.NotificationThreadMuteListPage + })) +) const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage')) const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage')) const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage')) @@ -840,6 +850,46 @@ export function useSmartPinListNavigation() { return { navigateToPinList } } +export function useSmartNotificationThreadFollowListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToNotificationThreadFollowList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'notification-thread-follow' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToNotificationThreadFollowList } +} + +export function useSmartNotificationThreadMuteListNavigation() { + const { setPrimaryNoteView } = usePrimaryNoteView() + const { push: pushSecondaryPage } = useSecondaryPage() + const { isSmallScreen } = useScreenSize() + + const navigateToNotificationThreadMuteList = (url: string) => { + if (isSmallScreen) { + window.history.pushState(null, '', url) + setPrimaryNoteView( + suspensePrimaryPage(), + 'notification-thread-mute' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToNotificationThreadMuteList } +} + export function useSmartInterestListNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() const { push: pushSecondaryPage } = useSecondaryPage() @@ -1858,7 +1908,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { primaryViewType === 'pins' || primaryViewType === 'interests' || primaryViewType === 'user-emojis' || - primaryViewType === 'mute' + primaryViewType === 'mute' || + primaryViewType === 'notification-thread-follow' || + primaryViewType === 'notification-thread-mute' ) { setPrimaryNoteView(null) return diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index b2aec740..3c7ab743 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -11,6 +11,7 @@ import { shouldHideInteractions } from '@/lib/event-filtering' import { Event } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import BookmarkButton from '../BookmarkButton' +import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons' import { LikeButtonWithStats } from './LikeButton' import { ReplyButtonWithStats } from './ReplyButton' import { RepostButtonWithStats } from './RepostButton' @@ -107,6 +108,7 @@ export default function NoteStats({ {!isRssArticleRoot && !isZapPoll && ( )} + {!isRssArticleRoot && } {!isRssArticleRoot && } @@ -136,6 +138,7 @@ export default function NoteStats({ )}
+ {!isRssArticleRoot && } {!isRssArticleRoot && }
diff --git a/src/components/NotificationThreadWatchButtons/index.tsx b/src/components/NotificationThreadWatchButtons/index.tsx new file mode 100644 index 00000000..612f1ea9 --- /dev/null +++ b/src/components/NotificationThreadWatchButtons/index.tsx @@ -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 ( + <> + + + + ) +} diff --git a/src/components/PersonalListBech32List/index.tsx b/src/components/PersonalListBech32List/index.tsx index 32f79739..9dd4cc0f 100644 --- a/src/components/PersonalListBech32List/index.tsx +++ b/src/components/PersonalListBech32List/index.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' const PAGE = 10 -type TListMode = 'bookmark' | 'pin' +type TListMode = 'bookmark' | 'pin' | 'notificationThreadFollow' | 'notificationThreadMute' /** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */ export default function PersonalListBech32List({ diff --git a/src/components/PersonalListNoteRefRow/index.tsx b/src/components/PersonalListNoteRefRow/index.tsx index 2fdd22a8..5b4e21fb 100644 --- a/src/components/PersonalListNoteRefRow/index.tsx +++ b/src/components/PersonalListNoteRefRow/index.tsx @@ -8,13 +8,14 @@ import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import { useBookmarksOptional } from '@/providers/bookmarks-context' import { useNostr } from '@/providers/NostrProvider' +import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' import { ChevronRight, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import type { Event } from 'nostr-tools' -type TListMode = 'bookmark' | 'pin' +type TListMode = 'bookmark' | 'pin' | 'notificationThreadFollow' | 'notificationThreadMute' /** * One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists). @@ -33,6 +34,7 @@ export default function PersonalListNoteRefRow({ const { navigateToNote } = useSmartNoteNavigation() const { checkLogin } = useNostr() const bookmarks = useBookmarksOptional() + const notificationWatch = useNotificationThreadWatchOptional() const removePinEntry = useRemovePinListEntry(onEntryRemoved) const [removing, setRemoving] = useState(false) @@ -65,20 +67,44 @@ export default function PersonalListNoteRefRow({ } else { toast.info(t('Bookmark not in list')) } - } else { + } else if (listMode === 'pin') { const ok = await removePinEntry(bech32Id, event as Event | null) if (ok) { toast.success(t('Note unpinned')) } else { toast.info(t('Pin not in list')) } + } else if (listMode === 'notificationThreadFollow') { + if (!notificationWatch) { + toast.error(t('Thread notification list update failed')) + return + } + const ok = await notificationWatch.removeFollowRefByBech32(bech32Id) + if (ok) { + toast.success(t('Removed from notification thread follow list')) + } else { + toast.info(t('Entry not in list')) + } + } else if (listMode === 'notificationThreadMute') { + if (!notificationWatch) { + toast.error(t('Thread notification list update failed')) + return + } + const ok = await notificationWatch.removeMuteRefByBech32(bech32Id) + if (ok) { + toast.success(t('Removed from notification thread mute list')) + } else { + toast.info(t('Entry not in list')) + } } } catch (err) { const msg = err instanceof Error ? err.message : String(err) toast.error( listMode === 'bookmark' ? `${t('Remove bookmark failed')}: ${msg}` - : `${t('Failed to remove pin')}: ${msg}` + : listMode === 'pin' + ? `${t('Failed to remove pin')}: ${msg}` + : `${t('Thread notification list update failed')}: ${msg}` ) } finally { setRemoving(false) @@ -91,6 +117,7 @@ export default function PersonalListNoteRefRow({ checkLogin, event, listMode, + notificationWatch, removePinEntry, removing, t diff --git a/src/constants.ts b/src/constants.ts index 5a6ccd9e..c198521a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -586,7 +586,17 @@ export const ExtendedKind = { /** NIP-34 / Git Republic: issue */ GIT_ISSUE: 1621, /** Git Republic: release (linked to repo via `a` tag) */ - GIT_RELEASE: 1642 + GIT_RELEASE: 1642, + /** + * Imwald: replaceable list (`e` / `a` refs) of thread roots whose replies should appear in your + * notifications as if you authored the root. + */ + EVENTS_I_FOLLOW_NOTIFICATIONS_LIST: 19130, + /** + * Imwald: replaceable list (`e` / `a` refs) of thread roots whose replies you do not want in + * notifications (e.g. noisy or hostile threads). + */ + EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132 } /** diff --git a/src/contexts/primary-note-view-context.tsx b/src/contexts/primary-note-view-context.tsx index 348b3dbd..5289a92a 100644 --- a/src/contexts/primary-note-view-context.tsx +++ b/src/contexts/primary-note-view-context.tsx @@ -13,6 +13,8 @@ export type TPrimaryOverlayViewType = | 'pins' | 'interests' | 'user-emojis' + | 'notification-thread-follow' + | 'notification-thread-mute' | 'others-relay-settings' export type PrimaryNoteViewContextValue = { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8c096bc9..e43c8fe9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -665,10 +665,21 @@ export default { Quotes: "Quotes", "Lightning Invoice": "Lightning Invoice", "Bookmark failed": "Bookmark failed", + "Follow this": "Follow this", + "Mute this": "Mute this", + "Following thread for notifications": "Following thread for notifications", + "Muted thread for notifications": "Muted thread for notifications", + "Unfollow thread notifications": "Unfollow thread notifications", + "Thread notification list update failed": "Thread notification list update failed", "Remove bookmark failed": "Remove bookmark failed", "Removed from bookmarks": "Removed from bookmarks", "Bookmark not in list": "This bookmark is not in your list (already removed or out of sync).", "Pin not in list": "This pin is not in your list (already removed or out of sync).", + "Entry not in list": "This entry is not in your list (already removed or out of sync).", + "Removed from notification thread follow list": "Removed from thread notification follow list", + "Removed from notification thread mute list": "Removed from thread notification mute list", + "No entries in notification thread follow list": "No threads in your follow list yet. Use the bell on a note to add one.", + "No entries in notification thread mute list": "No threads in your mute list yet. Use the bell-off control on a note to add one.", "Failed to remove pin": "Failed to remove pin", Translation: "Translation", Balance: "Balance", @@ -1732,10 +1743,12 @@ export default { "RSS Feed Settings": "RSS Feed Settings", "Follow sets": "Follow sets", "Personal Lists": "Personal Lists", - "Personal lists hub intro": "Open mute list, following, bookmarks list, pinned notes, interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.", + "Personal lists hub intro": "Open mute list, following, bookmarks list, thread notification follow/mute lists (kinds 19130 / 19132), pinned notes, interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.", "Mute list": "Mute list", "Following list": "Following list", "Bookmarks list": "Bookmarks list", + "Notification thread follow list": "Thread notifications (follow)", + "Notification thread mute list": "Thread notifications (mute)", "Pinned notes list": "Pinned notes list", "Interests list": "Interests list", "User emoji list": "User emoji list (kind 10030)", diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 09b5cee5..35f2299d 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -990,6 +990,20 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft } } +/** Replaceable personal list (same tag shape as bookmarks: `e` / `a` refs). */ +export function createReplaceablePersonalListDraftEvent( + kind: number, + tags: string[][], + content = '' +): TDraftEvent { + return { + kind, + content, + tags, + created_at: dayjs().unix() + } +} + /** NIP-B0 (kind 39701): parameterized web bookmark; `d` = URL without scheme, `i`/`I` = canonical http(s) URL. */ export function createWebBookmarkDraftEvent(options: { url: string diff --git a/src/lib/link.ts b/src/lib/link.ts index 50dda137..3d80de04 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -134,6 +134,9 @@ export const toMuteList = () => '/mutes' export const toBookmarksList = () => '/bookmarks' +export const toNotificationThreadFollowList = () => '/notification-thread-follow' +export const toNotificationThreadMuteList = () => '/notification-thread-mute' + export const toPinsList = () => '/pins' export const toInterestsList = () => '/interests' export const toUserEmojiList = () => '/user-emojis' diff --git a/src/lib/notification-thread-watch.ts b/src/lib/notification-thread-watch.ts new file mode 100644 index 00000000..232c3c66 --- /dev/null +++ b/src/lib/notification-thread-watch.ts @@ -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 + aCoordLower: Set +} + +export function emptyThreadWatchRefs(): TThreadWatchListRefs { + return { eHexLower: new Set(), aCoordLower: new Set() } +} + +export function parseThreadWatchListRefs(ev: Event | null | undefined): TThreadWatchListRefs { + const eHexLower = new Set() + const aCoordLower = new Set() + 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) { + 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() + 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(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 +} diff --git a/src/lib/personal-list-refs.ts b/src/lib/personal-list-refs.ts index 3394a042..1eef396b 100644 --- a/src/lib/personal-list-refs.ts +++ b/src/lib/personal-list-refs.ts @@ -31,6 +31,14 @@ export function bookmarkBech32IdsFromListEvent(ev: Event | null): string[] { return dedupePreserveOrder(raw).reverse() } +/** + * Imwald kinds **19130** / **19132** (thread notification follow / mute lists): same `e` / `a` → nevent/naddr + * ordering as {@link bookmarkBech32IdsFromListEvent} (newest-first). + */ +export function notificationThreadWatchBech32IdsFromListEvent(ev: Event | null): string[] { + return bookmarkBech32IdsFromListEvent(ev) +} + /** Kind 10001 pin list: `e` reversed then `a`, same ordering as profile pins. */ export function pinBech32IdsFromListEvent(ev: Event | null): string[] { if (!ev?.tags?.length) return [] diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 2a3e2a13..061e02e5 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -21,6 +21,14 @@ import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { normalizeTopic } from '@/lib/discussion-topics' +import { + chunkArray, + extractACoordsForNotificationReq, + extractEHexIdsForNotificationReq, + NOTIFICATION_THREAD_WATCH_A_CHUNK, + NOTIFICATION_THREAD_WATCH_E_CHUNK, + parseThreadWatchListRefs +} from '@/lib/notification-thread-watch' import { userIdToPubkey } from '@/lib/pubkey' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' @@ -181,6 +189,29 @@ export function buildNotificationsSpellSubRequests(urls: string[], pubkey: strin return [{ urls, filter: { limit: FAUX_SPELL_EVENT_LIMIT, '#p': [pk] } }] } +/** + * Extra shards: events referencing followed thread roots via `#e` / `#a` (OR within each filter). + * Merged with {@link buildNotificationsSpellSubRequests} in the notifications faux spell. + */ +export function buildNotificationsFollowedThreadSubRequests( + urls: string[], + followListEvent: Event | null | undefined +): TFeedSubRequest[] { + if (!urls.length) return [] + const refs = parseThreadWatchListRefs(followListEvent ?? null) + const kinds = [...NOTIFICATION_SPELL_KINDS] + const out: TFeedSubRequest[] = [] + for (const chunk of chunkArray(extractEHexIdsForNotificationReq(refs), NOTIFICATION_THREAD_WATCH_E_CHUNK)) { + if (chunk.length === 0) continue + out.push({ urls, filter: { kinds, limit: FAUX_SPELL_EVENT_LIMIT, '#e': chunk } }) + } + for (const chunk of chunkArray(extractACoordsForNotificationReq(refs), NOTIFICATION_THREAD_WATCH_A_CHUNK)) { + if (chunk.length === 0) continue + out.push({ urls, filter: { kinds, limit: FAUX_SPELL_EVENT_LIMIT, '#a': chunk } }) + } + return out +} + export function buildDiscussionFilter(): Filter { return { kinds: [ExtendedKind.DISCUSSION], diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 2098e4e6..0553d31e 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -27,6 +27,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useBookmarks } from '@/providers/bookmarks-context' import { useNostr } from '@/providers/NostrProvider' +import { useNotificationThreadWatch } from '@/providers/NotificationThreadWatchProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { dedupeFollowSetEventsByD } from '@/lib/follow-set-spell' @@ -88,6 +89,7 @@ const SpellsPage = forwardRef(function SpellsPage( } = useNostr() const { addBookmark, removeBookmark } = useBookmarks() const { hideUntrustedNotifications } = useUserTrust() + const { eventsIFollowListEvent, eventsIMutedListEvent } = useNotificationThreadWatch() const { isSmallScreen } = useScreenSize() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() @@ -245,7 +247,9 @@ const SpellsPage = forwardRef(function SpellsPage( contactsSyncKey, followSetListEvents, followSetCatalogLoading, - kindFilterShowKinds + kindFilterShowKinds, + notificationEventsIFollowListEvent: eventsIFollowListEvent, + notificationEventsIMutedListEvent: eventsIMutedListEvent }) useEffect(() => { diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index c02dd251..aa618d26 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -12,6 +12,11 @@ import { } from '@/lib/favorites-feed-relays' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { isUserInEventMentions } from '@/lib/event' +import { + isNotificationThreadInteractionEvent, + parseThreadWatchListRefs, + threadWatchMatchesRefs +} from '@/lib/notification-thread-watch' import { decodeFollowSetSpellId, getFollowSetDTag, @@ -25,6 +30,7 @@ import { buildDiscussionFilter, buildInterestsSubRequests, buildMediaSpellFilter, + buildNotificationsFollowedThreadSubRequests, buildNotificationsSpellSubRequests, buildWebBookmarksSpellSubRequests, NOTIFICATION_SPELL_LOADING_SAFETY_MS, @@ -37,6 +43,7 @@ import { import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' import type { TFeedSubRequest } from '@/types' import { isFollowFeedFauxSpellId } from './fauxSpellConfig' +import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' /** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */ const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000 @@ -115,6 +122,8 @@ export type UseSpellsPageFeedArgs = { followSetListEvents: Event[] followSetCatalogLoading: boolean kindFilterShowKinds: number[] + notificationEventsIFollowListEvent: Event | null | undefined + notificationEventsIMutedListEvent: Event | null | undefined } export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -133,7 +142,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { contactsSyncKey, followSetListEvents, followSetCatalogLoading, - kindFilterShowKinds + kindFilterShowKinds, + notificationEventsIFollowListEvent, + notificationEventsIMutedListEvent } = a const hideRepliesFollowing = useNoteListHideReplies() @@ -334,6 +345,20 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { [...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) ) : '' + const notificationFollowTagsStableKey = notificationEventsIFollowListEvent + ? JSON.stringify( + [...notificationEventsIFollowListEvent.tags].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)) + ) + ) + : '' + const notificationMutedTagsStableKey = notificationEventsIMutedListEvent + ? JSON.stringify( + [...notificationEventsIMutedListEvent.tags].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)) + ) + ) + : '' const fauxFeedRelaysDepsKey = [ sortedFavoriteRelaysKey, @@ -343,7 +368,13 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { interestTagsStableKey, bookmarkListEvent?.id ?? '', String(bookmarkListEvent?.created_at ?? ''), - bookmarkTagsStableKey + bookmarkTagsStableKey, + notificationEventsIFollowListEvent?.id ?? '', + String(notificationEventsIFollowListEvent?.created_at ?? ''), + notificationFollowTagsStableKey, + notificationEventsIMutedListEvent?.id ?? '', + String(notificationEventsIMutedListEvent?.created_at ?? ''), + notificationMutedTagsStableKey ].join('\0') const syncFauxSubRequests = useMemo(() => { @@ -374,7 +405,12 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { if (selectedFauxSpell === 'notifications') { if (!notificationsFeedPubkey || !feedUrls.length) return [] - return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) + const base = buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) + const extra = buildNotificationsFollowedThreadSubRequests( + feedUrls, + notificationEventsIFollowListEvent ?? null + ) + return [...base, ...extra] } if (selectedFauxSpell === 'discussions') { if (!feedUrls.length) return [] @@ -409,7 +445,20 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { ] } return [] - }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey, interestListEvent, bookmarkListEvent, favoriteRelays, blockedRelays, relayList]) + }, [ + selectedFauxSpell, + pubkey, + notificationsFeedPubkey, + fauxFeedRelaysDepsKey, + relayMailboxStableKey, + interestListEvent, + bookmarkListEvent, + notificationEventsIFollowListEvent, + notificationEventsIMutedListEvent, + favoriteRelays, + blockedRelays, + relayList + ]) const fauxSubRequests = useMemo(() => { const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') @@ -524,9 +573,37 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { ) const notificationsMentionExtraHide = useCallback( - (evt: Event) => - notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false, - [notificationsFeedPubkey] + (evt: Event) => { + if (!notificationsFeedPubkey) return false + const pk = normalizeHexPubkey(notificationsFeedPubkey) + const followRefs = parseThreadWatchListRefs(notificationEventsIFollowListEvent ?? null) + const mutedRefs = parseThreadWatchListRefs(notificationEventsIMutedListEvent ?? null) + + if ( + threadWatchMatchesRefs(evt, mutedRefs) && + isNotificationThreadInteractionEvent(evt) + ) { + return true + } + + if (isUserInEventMentions(evt, pk)) return false + + if (hexPubkeysEqual(evt.pubkey, pk)) return false + + if ( + threadWatchMatchesRefs(evt, followRefs) && + isNotificationThreadInteractionEvent(evt) + ) { + return false + } + + return true + }, + [ + notificationsFeedPubkey, + notificationEventsIFollowListEvent, + notificationEventsIMutedListEvent + ] ) return { diff --git a/src/pages/secondary/NotificationThreadWatchListPage.tsx b/src/pages/secondary/NotificationThreadWatchListPage.tsx new file mode 100644 index 00000000..a9c4c71e --- /dev/null +++ b/src/pages/secondary/NotificationThreadWatchListPage.tsx @@ -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( + 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(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 + } + + 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 ( + + void refreshFromRelays()} /> + + + + + + openJson()}> + + {t('View JSON')} + + setCleanConfirmOpen(true)} + > + + {t('Clean list')} + + + + + ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + + +
+ {bech32Ids.length === 0 ? ( +

{t(emptyKey)}

+ ) : ( + + )} +
+
+ ) + } +) + +export const NotificationThreadFollowListPage = forwardRef>( + function NotificationThreadFollowListPage(props, ref) { + return + } +) + +export const NotificationThreadMuteListPage = forwardRef>( + function NotificationThreadMuteListPage(props, ref) { + return + } +) + +NotificationThreadFollowListPage.displayName = 'NotificationThreadFollowListPage' +NotificationThreadMuteListPage.displayName = 'NotificationThreadMuteListPage' diff --git a/src/pages/secondary/PersonalListsSettingsPage/index.tsx b/src/pages/secondary/PersonalListsSettingsPage/index.tsx index 75debeeb..e3290d1e 100644 --- a/src/pages/secondary/PersonalListsSettingsPage/index.tsx +++ b/src/pages/secondary/PersonalListsSettingsPage/index.tsx @@ -8,6 +8,8 @@ import { useSmartFollowingListNavigation, useSmartInterestListNavigation, useSmartMuteListNavigation, + useSmartNotificationThreadFollowListNavigation, + useSmartNotificationThreadMuteListNavigation, useSmartPinListNavigation, useSmartSettingsNavigation, useSmartUserEmojiListNavigation @@ -19,11 +21,13 @@ import { toFollowingList, toInterestsList, toMuteList, + toNotificationThreadFollowList, + toNotificationThreadMuteList, toPinsList, toUserEmojiList } from '@/lib/link' import { useNostr } from '@/providers/NostrProvider' -import { Bookmark, ChevronRight, Hash, Pin, Smile, Sticker, Users, VolumeX } from 'lucide-react' +import { Bookmark, Bell, BellOff, ChevronRight, Hash, Pin, Smile, Sticker, Users, VolumeX } from 'lucide-react' import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -39,6 +43,8 @@ const PersonalListsSettingsPage = forwardRef( const { navigateToMuteList } = useSmartMuteListNavigation() const { navigateToFollowingList } = useSmartFollowingListNavigation() const { navigateToBookmarkList } = useSmartBookmarkListNavigation() + const { navigateToNotificationThreadFollowList } = useSmartNotificationThreadFollowListNavigation() + const { navigateToNotificationThreadMuteList } = useSmartNotificationThreadMuteListNavigation() const { navigateToPinList } = useSmartPinListNavigation() const { navigateToInterestList } = useSmartInterestListNavigation() const { navigateToUserEmojiList } = useSmartUserEmojiListNavigation() @@ -84,7 +90,10 @@ const PersonalListsSettingsPage = forwardRef( ) : null} {pubkey ? ( - navigateToBookmarkList(toBookmarksList())}> + navigateToBookmarkList(toBookmarksList())} + >
{t('Bookmarks list')}
@@ -92,6 +101,30 @@ const PersonalListsSettingsPage = forwardRef( ) : null} + {pubkey ? ( + navigateToNotificationThreadFollowList(toNotificationThreadFollowList())} + > +
+ +
{t('Notification thread follow list')}
+
+ +
+ ) : null} + {pubkey ? ( + navigateToNotificationThreadMuteList(toNotificationThreadMuteList())} + > +
+ +
{t('Notification thread mute list')}
+
+ +
+ ) : null} {pubkey ? ( navigateToPinList(toPinsList())}>
diff --git a/src/providers/NotificationThreadWatchProvider.tsx b/src/providers/NotificationThreadWatchProvider.tsx new file mode 100644 index 00000000..66290d39 --- /dev/null +++ b/src/providers/NotificationThreadWatchProvider.tsx @@ -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 + mutedRefs: ReturnType + isFollowedForNotifications: (event: Event) => boolean + isMutedForNotifications: (event: Event) => boolean + followThreadForNotifications: (event: Event) => Promise + muteThreadForNotifications: (event: Event) => Promise + unfollowThreadForNotifications: (event: Event) => Promise + unmuteThreadForNotifications: (event: Event) => Promise + /** Refetch both lists from relays + IDB and update local state (e.g. settings list editor). */ + refreshNotificationThreadListsFromRelays: () => Promise + removeFollowRefByBech32: (bech32Id: string) => Promise + removeMuteRefByBech32: (bech32Id: string) => Promise +} + +const NotificationThreadWatchContext = createContext(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() + const seenA = new Set() + 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(null) + const [eventsIMutedListEvent, setEventsIMutedListEvent] = useState(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 => { + 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 => { + 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 => { + 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 => { + 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 ( + {children} + ) +} + +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) +} diff --git a/src/routes.tsx b/src/routes.tsx index 5da100a0..dd6078f0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -13,6 +13,16 @@ const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingList const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage')) const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage')) const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage')) +const NotificationThreadFollowListPageLazy = lazy(() => + import('./pages/secondary/NotificationThreadWatchListPage').then((m) => ({ + default: m.NotificationThreadFollowListPage + })) +) +const NotificationThreadMuteListPageLazy = lazy(() => + import('./pages/secondary/NotificationThreadWatchListPage').then((m) => ({ + default: m.NotificationThreadMuteListPage + })) +) const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage')) const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) @@ -91,6 +101,8 @@ const ROUTES = [ { path: '/profile-editor', element: SR(ProfileEditorPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/bookmarks', element: SR(BookmarkListPageLazy) }, + { path: '/notification-thread-follow', element: SR(NotificationThreadFollowListPageLazy) }, + { path: '/notification-thread-mute', element: SR(NotificationThreadMuteListPageLazy) }, { path: '/pins', element: SR(PinListPageLazy) }, { path: '/interests', element: SR(InterestListPageLazy) }, { path: '/user-emojis', element: SR(UserEmojiListPageLazy) }, diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 8a8770e3..8f4d338a 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -86,6 +86,10 @@ export const StoreNames = { FOLLOW_SET_EVENTS: 'followSetEvents', MUTE_LIST_EVENTS: 'muteListEvents', BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', + /** Imwald kind 19130: thread roots to mirror in notifications. */ + NOTIFICATION_THREAD_FOLLOW_EVENTS: 'notificationThreadFollowEvents', + /** Imwald kind 19132: thread roots to hide interaction notifications for. */ + NOTIFICATION_THREAD_MUTE_EVENTS: 'notificationThreadMuteEvents', PIN_LIST_EVENTS: 'pinListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', INTEREST_LIST_EVENTS: 'interestListEvents', @@ -168,7 +172,7 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set( ]) /** Schema version we expect. When adding stores or migrations, bump this. */ -const DB_VERSION = 35 +const DB_VERSION = 36 /** Max age for profile and payment info cache before we refetch (5 min). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -314,6 +318,12 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) { db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) } + if (!db.objectStoreNames.contains(StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS)) { + db.createObjectStore(StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS, { keyPath: 'key' }) + } + if (!db.objectStoreNames.contains(StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS)) { + db.createObjectStore(StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS, { keyPath: 'key' }) + } if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) { db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' }) } @@ -1073,6 +1083,10 @@ class IndexedDbService { return StoreNames.MUTE_LIST_EVENTS case kinds.BookmarkList: return StoreNames.BOOKMARK_LIST_EVENTS + case ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST: + return StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS + case ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST: + return StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS case 10001: // Pin list return StoreNames.PIN_LIST_EVENTS case 10015: // Interest list @@ -2182,6 +2196,10 @@ class IndexedDbService { if (storeName === StoreNames.FOLLOW_SET_EVENTS) return ExtendedKind.FOLLOW_SET if (storeName === StoreNames.MUTE_LIST_EVENTS) return kinds.Mutelist if (storeName === StoreNames.BOOKMARK_LIST_EVENTS) return kinds.BookmarkList + if (storeName === StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS) + return ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST + if (storeName === StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS) + return ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST if (storeName === StoreNames.PIN_LIST_EVENTS) return 10001 if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015 if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index fb2464a9..f00459d4 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -46,6 +46,8 @@ export type ViewType = | 'pins' | 'interests' | 'user-emojis' + | 'notification-thread-follow' + | 'notification-thread-mute' | 'others-relay-settings' | null @@ -293,6 +295,8 @@ export class NavigationService { if (viewType === 'following') return 'Following' if (viewType === 'mute') return 'Muted Users' if (viewType === 'bookmarks') return 'Bookmarks' + if (viewType === 'notification-thread-follow') return 'Thread notifications (follow)' + if (viewType === 'notification-thread-mute') return 'Thread notifications (mute)' if (viewType === 'pins') return 'Pinned notes' if (viewType === 'interests') return 'Interests' if (viewType === 'user-emojis') return 'Custom emoji list'