Browse Source

add following and muting events

imwald
Silberengel 1 month ago
parent
commit
19605e0c19
  1. 5
      src/App.tsx
  2. 54
      src/PageManager.tsx
  3. 3
      src/components/NoteStats/index.tsx
  4. 103
      src/components/NotificationThreadWatchButtons/index.tsx
  5. 2
      src/components/PersonalListBech32List/index.tsx
  6. 33
      src/components/PersonalListNoteRefRow/index.tsx
  7. 12
      src/constants.ts
  8. 2
      src/contexts/primary-note-view-context.tsx
  9. 15
      src/i18n/locales/en.ts
  10. 14
      src/lib/draft-event.ts
  11. 3
      src/lib/link.ts
  12. 155
      src/lib/notification-thread-watch.ts
  13. 8
      src/lib/personal-list-refs.ts
  14. 31
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  15. 6
      src/pages/primary/SpellsPage/index.tsx
  16. 91
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  17. 219
      src/pages/secondary/NotificationThreadWatchListPage.tsx
  18. 37
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  19. 454
      src/providers/NotificationThreadWatchProvider.tsx
  20. 12
      src/routes.tsx
  21. 20
      src/services/indexed-db.service.ts
  22. 4
      src/services/navigation.service.ts

5
src/App.tsx

@ -5,6 +5,7 @@ import PublishSuccessSubtleIndicator from '@/components/PublishSuccessSubtleIndi
import ReadAloudPlayerModal from '@/components/ReadAloudPlayerModal' import ReadAloudPlayerModal from '@/components/ReadAloudPlayerModal'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider' import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { NotificationThreadWatchProvider } from '@/providers/NotificationThreadWatchProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider' import { FavoriteRelaysActivityProvider } from '@/providers/FavoriteRelaysActivityProvider'
@ -51,7 +52,8 @@ export default function App(): JSX.Element {
<InterestListProvider> <InterestListProvider>
<UserTrustProvider> <UserTrustProvider>
<BookmarksProvider> <BookmarksProvider>
<FeedProvider> <NotificationThreadWatchProvider>
<FeedProvider>
<ReplyProvider> <ReplyProvider>
<MediaUploadServiceProvider> <MediaUploadServiceProvider>
<KindFilterProvider> <KindFilterProvider>
@ -69,6 +71,7 @@ export default function App(): JSX.Element {
</MediaUploadServiceProvider> </MediaUploadServiceProvider>
</ReplyProvider> </ReplyProvider>
</FeedProvider> </FeedProvider>
</NotificationThreadWatchProvider>
</BookmarksProvider> </BookmarksProvider>
</UserTrustProvider> </UserTrustProvider>
</InterestListProvider> </InterestListProvider>

54
src/PageManager.tsx

@ -101,6 +101,16 @@ const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePag
const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage')) const PrimaryFollowingListPageLazy = lazy(() => import('@/pages/secondary/FollowingListPage'))
const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage')) const PrimaryMuteListPageLazy = lazy(() => import('@/pages/secondary/MuteListPage'))
const PrimaryBookmarkListPageLazy = lazy(() => import('@/pages/secondary/BookmarkListPage')) 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 PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'))
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage')) const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage')) const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage'))
@ -840,6 +850,46 @@ export function useSmartPinListNavigation() {
return { navigateToPinList } 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(<PrimaryNotificationThreadFollowListPageLazy index={0} hideTitlebar={true} />),
'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(<PrimaryNotificationThreadMuteListPageLazy index={0} hideTitlebar={true} />),
'notification-thread-mute'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToNotificationThreadMuteList }
}
export function useSmartInterestListNavigation() { export function useSmartInterestListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage() const { push: pushSecondaryPage } = useSecondaryPage()
@ -1858,7 +1908,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
primaryViewType === 'pins' || primaryViewType === 'pins' ||
primaryViewType === 'interests' || primaryViewType === 'interests' ||
primaryViewType === 'user-emojis' || primaryViewType === 'user-emojis' ||
primaryViewType === 'mute' primaryViewType === 'mute' ||
primaryViewType === 'notification-thread-follow' ||
primaryViewType === 'notification-thread-mute'
) { ) {
setPrimaryNoteView(null) setPrimaryNoteView(null)
return return

3
src/components/NoteStats/index.tsx

@ -11,6 +11,7 @@ import { shouldHideInteractions } from '@/lib/event-filtering'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton' import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton' import { RepostButtonWithStats } from './RepostButton'
@ -107,6 +108,7 @@ export default function NoteStats({
{!isRssArticleRoot && !isZapPoll && ( {!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> <ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)} )}
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />} {!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>
@ -136,6 +138,7 @@ export default function NoteStats({
)} )}
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />} {!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>

103
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 (
<>
<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>
</>
)
}

2
src/components/PersonalListBech32List/index.tsx

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
const PAGE = 10 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}). */ /** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */
export default function PersonalListBech32List({ export default function PersonalListBech32List({

33
src/components/PersonalListNoteRefRow/index.tsx

@ -8,13 +8,14 @@ import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { useBookmarksOptional } from '@/providers/bookmarks-context' import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { ChevronRight, Trash2 } from 'lucide-react' import { ChevronRight, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Event } from 'nostr-tools' 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). * 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 { navigateToNote } = useSmartNoteNavigation()
const { checkLogin } = useNostr() const { checkLogin } = useNostr()
const bookmarks = useBookmarksOptional() const bookmarks = useBookmarksOptional()
const notificationWatch = useNotificationThreadWatchOptional()
const removePinEntry = useRemovePinListEntry(onEntryRemoved) const removePinEntry = useRemovePinListEntry(onEntryRemoved)
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
@ -65,20 +67,44 @@ export default function PersonalListNoteRefRow({
} else { } else {
toast.info(t('Bookmark not in list')) toast.info(t('Bookmark not in list'))
} }
} else { } else if (listMode === 'pin') {
const ok = await removePinEntry(bech32Id, event as Event | null) const ok = await removePinEntry(bech32Id, event as Event | null)
if (ok) { if (ok) {
toast.success(t('Note unpinned')) toast.success(t('Note unpinned'))
} else { } else {
toast.info(t('Pin not in list')) 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) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
toast.error( toast.error(
listMode === 'bookmark' listMode === 'bookmark'
? `${t('Remove bookmark failed')}: ${msg}` ? `${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 { } finally {
setRemoving(false) setRemoving(false)
@ -91,6 +117,7 @@ export default function PersonalListNoteRefRow({
checkLogin, checkLogin,
event, event,
listMode, listMode,
notificationWatch,
removePinEntry, removePinEntry,
removing, removing,
t t

12
src/constants.ts

@ -586,7 +586,17 @@ export const ExtendedKind = {
/** NIP-34 / Git Republic: issue */ /** NIP-34 / Git Republic: issue */
GIT_ISSUE: 1621, GIT_ISSUE: 1621,
/** Git Republic: release (linked to repo via `a` tag) */ /** 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
} }
/** /**

2
src/contexts/primary-note-view-context.tsx

@ -13,6 +13,8 @@ export type TPrimaryOverlayViewType =
| 'pins' | 'pins'
| 'interests' | 'interests'
| 'user-emojis' | 'user-emojis'
| 'notification-thread-follow'
| 'notification-thread-mute'
| 'others-relay-settings' | 'others-relay-settings'
export type PrimaryNoteViewContextValue = { export type PrimaryNoteViewContextValue = {

15
src/i18n/locales/en.ts

@ -665,10 +665,21 @@ export default {
Quotes: "Quotes", Quotes: "Quotes",
"Lightning Invoice": "Lightning Invoice", "Lightning Invoice": "Lightning Invoice",
"Bookmark failed": "Bookmark failed", "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", "Remove bookmark failed": "Remove bookmark failed",
"Removed from bookmarks": "Removed from bookmarks", "Removed from bookmarks": "Removed from bookmarks",
"Bookmark not in list": "This bookmark is not in your list (already removed or out of sync).", "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).", "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", "Failed to remove pin": "Failed to remove pin",
Translation: "Translation", Translation: "Translation",
Balance: "Balance", Balance: "Balance",
@ -1732,10 +1743,12 @@ export default {
"RSS Feed Settings": "RSS Feed Settings", "RSS Feed Settings": "RSS Feed Settings",
"Follow sets": "Follow sets", "Follow sets": "Follow sets",
"Personal Lists": "Personal Lists", "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", "Mute list": "Mute list",
"Following list": "Following list", "Following list": "Following list",
"Bookmarks list": "Bookmarks 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", "Pinned notes list": "Pinned notes list",
"Interests list": "Interests list", "Interests list": "Interests list",
"User emoji list": "User emoji list (kind 10030)", "User emoji list": "User emoji list (kind 10030)",

14
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. */ /** NIP-B0 (kind 39701): parameterized web bookmark; `d` = URL without scheme, `i`/`I` = canonical http(s) URL. */
export function createWebBookmarkDraftEvent(options: { export function createWebBookmarkDraftEvent(options: {
url: string url: string

3
src/lib/link.ts

@ -134,6 +134,9 @@ export const toMuteList = () => '/mutes'
export const toBookmarksList = () => '/bookmarks' export const toBookmarksList = () => '/bookmarks'
export const toNotificationThreadFollowList = () => '/notification-thread-follow'
export const toNotificationThreadMuteList = () => '/notification-thread-mute'
export const toPinsList = () => '/pins' export const toPinsList = () => '/pins'
export const toInterestsList = () => '/interests' export const toInterestsList = () => '/interests'
export const toUserEmojiList = () => '/user-emojis' export const toUserEmojiList = () => '/user-emojis'

155
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<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
}

8
src/lib/personal-list-refs.ts

@ -31,6 +31,14 @@ export function bookmarkBech32IdsFromListEvent(ev: Event | null): string[] {
return dedupePreserveOrder(raw).reverse() 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. */ /** Kind 10001 pin list: `e` reversed then `a`, same ordering as profile pins. */
export function pinBech32IdsFromListEvent(ev: Event | null): string[] { export function pinBech32IdsFromListEvent(ev: Event | null): string[] {
if (!ev?.tags?.length) return [] if (!ev?.tags?.length) return []

31
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 { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority' import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { normalizeTopic } from '@/lib/discussion-topics' 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 { userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' 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] } }] 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 { export function buildDiscussionFilter(): Filter {
return { return {
kinds: [ExtendedKind.DISCUSSION], kinds: [ExtendedKind.DISCUSSION],

6
src/pages/primary/SpellsPage/index.tsx

@ -27,6 +27,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useBookmarks } from '@/providers/bookmarks-context' import { useBookmarks } from '@/providers/bookmarks-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useNotificationThreadWatch } from '@/providers/NotificationThreadWatchProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import { dedupeFollowSetEventsByD } from '@/lib/follow-set-spell' import { dedupeFollowSetEventsByD } from '@/lib/follow-set-spell'
@ -88,6 +89,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} = useNostr() } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks() const { addBookmark, removeBookmark } = useBookmarks()
const { hideUntrustedNotifications } = useUserTrust() const { hideUntrustedNotifications } = useUserTrust()
const { eventsIFollowListEvent, eventsIMutedListEvent } = useNotificationThreadWatch()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults()
@ -245,7 +247,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
contactsSyncKey, contactsSyncKey,
followSetListEvents, followSetListEvents,
followSetCatalogLoading, followSetCatalogLoading,
kindFilterShowKinds kindFilterShowKinds,
notificationEventsIFollowListEvent: eventsIFollowListEvent,
notificationEventsIMutedListEvent: eventsIMutedListEvent
}) })
useEffect(() => { useEffect(() => {

91
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -12,6 +12,11 @@ import {
} from '@/lib/favorites-feed-relays' } from '@/lib/favorites-feed-relays'
import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity'
import { isUserInEventMentions } from '@/lib/event' import { isUserInEventMentions } from '@/lib/event'
import {
isNotificationThreadInteractionEvent,
parseThreadWatchListRefs,
threadWatchMatchesRefs
} from '@/lib/notification-thread-watch'
import { import {
decodeFollowSetSpellId, decodeFollowSetSpellId,
getFollowSetDTag, getFollowSetDTag,
@ -25,6 +30,7 @@ import {
buildDiscussionFilter, buildDiscussionFilter,
buildInterestsSubRequests, buildInterestsSubRequests,
buildMediaSpellFilter, buildMediaSpellFilter,
buildNotificationsFollowedThreadSubRequests,
buildNotificationsSpellSubRequests, buildNotificationsSpellSubRequests,
buildWebBookmarksSpellSubRequests, buildWebBookmarksSpellSubRequests,
NOTIFICATION_SPELL_LOADING_SAFETY_MS, NOTIFICATION_SPELL_LOADING_SAFETY_MS,
@ -37,6 +43,7 @@ import {
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { isFollowFeedFauxSpellId } from './fauxSpellConfig' 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. */ /** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */
const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000 const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000
@ -115,6 +122,8 @@ export type UseSpellsPageFeedArgs = {
followSetListEvents: Event[] followSetListEvents: Event[]
followSetCatalogLoading: boolean followSetCatalogLoading: boolean
kindFilterShowKinds: number[] kindFilterShowKinds: number[]
notificationEventsIFollowListEvent: Event | null | undefined
notificationEventsIMutedListEvent: Event | null | undefined
} }
export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
@ -133,7 +142,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
contactsSyncKey, contactsSyncKey,
followSetListEvents, followSetListEvents,
followSetCatalogLoading, followSetCatalogLoading,
kindFilterShowKinds kindFilterShowKinds,
notificationEventsIFollowListEvent,
notificationEventsIMutedListEvent
} = a } = a
const hideRepliesFollowing = useNoteListHideReplies() const hideRepliesFollowing = useNoteListHideReplies()
@ -334,6 +345,20 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
[...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) [...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 = [ const fauxFeedRelaysDepsKey = [
sortedFavoriteRelaysKey, sortedFavoriteRelaysKey,
@ -343,7 +368,13 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
interestTagsStableKey, interestTagsStableKey,
bookmarkListEvent?.id ?? '', bookmarkListEvent?.id ?? '',
String(bookmarkListEvent?.created_at ?? ''), String(bookmarkListEvent?.created_at ?? ''),
bookmarkTagsStableKey bookmarkTagsStableKey,
notificationEventsIFollowListEvent?.id ?? '',
String(notificationEventsIFollowListEvent?.created_at ?? ''),
notificationFollowTagsStableKey,
notificationEventsIMutedListEvent?.id ?? '',
String(notificationEventsIMutedListEvent?.created_at ?? ''),
notificationMutedTagsStableKey
].join('\0') ].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
@ -374,7 +405,12 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (selectedFauxSpell === 'notifications') { if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey || !feedUrls.length) return [] 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 (selectedFauxSpell === 'discussions') {
if (!feedUrls.length) return [] if (!feedUrls.length) return []
@ -409,7 +445,20 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
] ]
} }
return [] 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<TFeedSubRequest[]>(() => { const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
@ -524,9 +573,37 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
) )
const notificationsMentionExtraHide = useCallback( const notificationsMentionExtraHide = useCallback(
(evt: Event) => (evt: Event) => {
notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false, if (!notificationsFeedPubkey) return false
[notificationsFeedPubkey] 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 { return {

219
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<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'

37
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -8,6 +8,8 @@ import {
useSmartFollowingListNavigation, useSmartFollowingListNavigation,
useSmartInterestListNavigation, useSmartInterestListNavigation,
useSmartMuteListNavigation, useSmartMuteListNavigation,
useSmartNotificationThreadFollowListNavigation,
useSmartNotificationThreadMuteListNavigation,
useSmartPinListNavigation, useSmartPinListNavigation,
useSmartSettingsNavigation, useSmartSettingsNavigation,
useSmartUserEmojiListNavigation useSmartUserEmojiListNavigation
@ -19,11 +21,13 @@ import {
toFollowingList, toFollowingList,
toInterestsList, toInterestsList,
toMuteList, toMuteList,
toNotificationThreadFollowList,
toNotificationThreadMuteList,
toPinsList, toPinsList,
toUserEmojiList toUserEmojiList
} from '@/lib/link' } from '@/lib/link'
import { useNostr } from '@/providers/NostrProvider' 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 { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -39,6 +43,8 @@ const PersonalListsSettingsPage = forwardRef(
const { navigateToMuteList } = useSmartMuteListNavigation() const { navigateToMuteList } = useSmartMuteListNavigation()
const { navigateToFollowingList } = useSmartFollowingListNavigation() const { navigateToFollowingList } = useSmartFollowingListNavigation()
const { navigateToBookmarkList } = useSmartBookmarkListNavigation() const { navigateToBookmarkList } = useSmartBookmarkListNavigation()
const { navigateToNotificationThreadFollowList } = useSmartNotificationThreadFollowListNavigation()
const { navigateToNotificationThreadMuteList } = useSmartNotificationThreadMuteListNavigation()
const { navigateToPinList } = useSmartPinListNavigation() const { navigateToPinList } = useSmartPinListNavigation()
const { navigateToInterestList } = useSmartInterestListNavigation() const { navigateToInterestList } = useSmartInterestListNavigation()
const { navigateToUserEmojiList } = useSmartUserEmojiListNavigation() const { navigateToUserEmojiList } = useSmartUserEmojiListNavigation()
@ -84,7 +90,10 @@ const PersonalListsSettingsPage = forwardRef(
</SettingRow> </SettingRow>
) : null} ) : null}
{pubkey ? ( {pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToBookmarkList(toBookmarksList())}> <SettingRow
className="clickable"
onClick={() => navigateToBookmarkList(toBookmarksList())}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Bookmark /> <Bookmark />
<div>{t('Bookmarks list')}</div> <div>{t('Bookmarks list')}</div>
@ -92,6 +101,30 @@ const PersonalListsSettingsPage = forwardRef(
<ChevronRight /> <ChevronRight />
</SettingRow> </SettingRow>
) : null} ) : null}
{pubkey ? (
<SettingRow
className="clickable"
onClick={() => navigateToNotificationThreadFollowList(toNotificationThreadFollowList())}
>
<div className="flex items-center gap-3">
<Bell />
<div>{t('Notification thread follow list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
{pubkey ? (
<SettingRow
className="clickable"
onClick={() => navigateToNotificationThreadMuteList(toNotificationThreadMuteList())}
>
<div className="flex items-center gap-3">
<BellOff />
<div>{t('Notification thread mute list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
{pubkey ? ( {pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToPinList(toPinsList())}> <SettingRow className="clickable" onClick={() => navigateToPinList(toPinsList())}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

454
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<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)
}

12
src/routes.tsx

@ -13,6 +13,16 @@ const FollowingListPageLazy = lazy(() => import('./pages/secondary/FollowingList
const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage')) const GeneralSettingsPageLazy = lazy(() => import('./pages/secondary/GeneralSettingsPage'))
const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage')) const MuteListPageLazy = lazy(() => import('./pages/secondary/MuteListPage'))
const BookmarkListPageLazy = lazy(() => import('./pages/secondary/BookmarkListPage')) 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 PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage'))
const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage')) const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage')) const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
@ -91,6 +101,8 @@ const ROUTES = [
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) }, { path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/bookmarks', element: SR(BookmarkListPageLazy) }, { 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: '/pins', element: SR(PinListPageLazy) },
{ path: '/interests', element: SR(InterestListPageLazy) }, { path: '/interests', element: SR(InterestListPageLazy) },
{ path: '/user-emojis', element: SR(UserEmojiListPageLazy) }, { path: '/user-emojis', element: SR(UserEmojiListPageLazy) },

20
src/services/indexed-db.service.ts

@ -86,6 +86,10 @@ export const StoreNames = {
FOLLOW_SET_EVENTS: 'followSetEvents', FOLLOW_SET_EVENTS: 'followSetEvents',
MUTE_LIST_EVENTS: 'muteListEvents', MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents', 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', PIN_LIST_EVENTS: 'pinListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents', BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
INTEREST_LIST_EVENTS: 'interestListEvents', INTEREST_LIST_EVENTS: 'interestListEvents',
@ -168,7 +172,7 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set(
]) ])
/** Schema version we expect. When adding stores or migrations, bump this. */ /** 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). */ /** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 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)) { if (!db.objectStoreNames.contains(StoreNames.BOOKMARK_LIST_EVENTS)) {
db.createObjectStore(StoreNames.BOOKMARK_LIST_EVENTS, { keyPath: 'key' }) 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)) { if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
} }
@ -1073,6 +1083,10 @@ class IndexedDbService {
return StoreNames.MUTE_LIST_EVENTS return StoreNames.MUTE_LIST_EVENTS
case kinds.BookmarkList: case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS 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 case 10001: // Pin list
return StoreNames.PIN_LIST_EVENTS return StoreNames.PIN_LIST_EVENTS
case 10015: // Interest list case 10015: // Interest list
@ -2182,6 +2196,10 @@ class IndexedDbService {
if (storeName === StoreNames.FOLLOW_SET_EVENTS) return ExtendedKind.FOLLOW_SET if (storeName === StoreNames.FOLLOW_SET_EVENTS) return ExtendedKind.FOLLOW_SET
if (storeName === StoreNames.MUTE_LIST_EVENTS) return kinds.Mutelist if (storeName === StoreNames.MUTE_LIST_EVENTS) return kinds.Mutelist
if (storeName === StoreNames.BOOKMARK_LIST_EVENTS) return kinds.BookmarkList 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.PIN_LIST_EVENTS) return 10001
if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015 if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015
if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST

4
src/services/navigation.service.ts

@ -46,6 +46,8 @@ export type ViewType =
| 'pins' | 'pins'
| 'interests' | 'interests'
| 'user-emojis' | 'user-emojis'
| 'notification-thread-follow'
| 'notification-thread-mute'
| 'others-relay-settings' | 'others-relay-settings'
| null | null
@ -293,6 +295,8 @@ export class NavigationService {
if (viewType === 'following') return 'Following' if (viewType === 'following') return 'Following'
if (viewType === 'mute') return 'Muted Users' if (viewType === 'mute') return 'Muted Users'
if (viewType === 'bookmarks') return 'Bookmarks' 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 === 'pins') return 'Pinned notes'
if (viewType === 'interests') return 'Interests' if (viewType === 'interests') return 'Interests'
if (viewType === 'user-emojis') return 'Custom emoji list' if (viewType === 'user-emojis') return 'Custom emoji list'

Loading…
Cancel
Save