Browse Source

handle lists

imwald
Silberengel 1 month ago
parent
commit
38ae434ff0
  1. 13
      src/components/Settings/SettingsMenuBody.tsx
  2. 2
      src/constants.ts
  3. 21
      src/i18n/locales/de.ts
  4. 21
      src/i18n/locales/en.ts
  5. 10
      src/lib/draft-event.ts
  6. 103
      src/lib/follow-set-spell.ts
  7. 1
      src/lib/link.ts
  8. 55
      src/pages/primary/NoteListPage/index.tsx
  9. 239
      src/pages/primary/SpellsPage/index.tsx
  10. 381
      src/pages/secondary/FollowSetsSettingsPage/index.tsx
  11. 2
      src/routes.tsx
  12. 6
      src/services/client.service.ts
  13. 15
      src/services/indexed-db.service.ts
  14. 3
      src/services/navigation.service.ts

13
src/components/Settings/SettingsMenuBody.tsx

@ -6,7 +6,8 @@ import { @@ -6,7 +6,8 @@ import {
toCacheSettings,
toTranslation,
toWallet,
toRssFeedSettings
toRssFeedSettings,
toFollowSetsSettings
} from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager'
@ -23,6 +24,7 @@ import { @@ -23,6 +24,7 @@ import {
Rss,
Server,
Settings2,
Users,
Wallet
} from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
@ -98,6 +100,15 @@ export default function SettingsMenuBody({ className }: { className?: string }) @@ -98,6 +100,15 @@ export default function SettingsMenuBody({ className }: { className?: string })
<ChevronRight />
</SettingItem>
)}
{!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}>
<div className="flex items-center gap-4">
<Users />
<div>{t('Follow sets')}</div>
</div>
<ChevronRight />
</SettingItem>
)}
{!!nsec && (
<SettingItem
className="clickable"

2
src/constants.ts

@ -300,6 +300,8 @@ export const ExtendedKind = { @@ -300,6 +300,8 @@ export const ExtendedKind = {
RELAY_REVIEW: 31987,
GROUP_METADATA: 39000,
GROUP_LIST: 10009, // NIP-51 Group List
/** NIP-51 follow sets (addressable); `p` tags name pubkeys in the set */
FOLLOW_SET: 30000,
ZAP_REQUEST: 9734,
ZAP_RECEIPT: 9735,
PUBLICATION: 30040,

21
src/i18n/locales/de.ts

@ -357,6 +357,9 @@ export default { @@ -357,6 +357,9 @@ export default {
'Login to set': 'Anmelden zum Set',
'Please login to view following feed':
'Bitte melde dich an, um den Feed der Folgenden zu sehen',
'Follow set': 'Folgenliste',
'Follow set feed empty':
'Diese NIP-51-Liste ist leer, wurde nicht gefunden, oder die Relays konnten sie noch nicht laden.',
'Send only to r': 'Nur an {{r}} senden',
'Send only to these relays': 'Nur an diese Relays senden',
Explore: 'Entdecken',
@ -1290,6 +1293,24 @@ export default { @@ -1290,6 +1293,24 @@ export default {
'Quiet Tags': 'Quiet Tags',
'RSS Feed': 'RSS Feed',
'RSS Feed Settings': 'RSS Feed Settings',
'Follow sets': 'Folgenlisten',
'Follow sets settings intro':
'NIP-51-Folgenlisten (Kind 30000) gruppieren Personen für eigene Feeds (z. B. in Zaubersprüchen). Die Listen werden auf deine NIP-65-Outbox-Relays und Profil-Suchrelays veröffentlicht.',
'New follow set': 'Neue Folgenliste',
'Edit follow set': 'Folgenliste bearbeiten',
'No follow sets yet': 'Du hast noch keine Folgenlisten angelegt.',
'Follow set saved': 'Folgenliste gespeichert',
'Follow set deleted': 'Folgenliste gelöscht',
'Failed to load follow sets': 'Folgenlisten konnten nicht geladen werden',
members: 'Mitglieder',
'Optional display title': 'Optionaler Anzeigename',
'List id (d tag)': 'Listen-ID (d-Tag)',
'Follow set d tag hint':
'Stabile Kennung dieser Liste. Nach der ersten Veröffentlichung nicht mehr änderbar.',
'People in this list': 'Personen in dieser Liste',
'Delete follow set?': 'Diese Folgenliste löschen?',
'Delete follow set confirm':
'Es wird eine Löschanfrage (Kind 5) für die Liste gesendet. Relays, die sie annehmen, entfernen die Liste; andere Clients können noch zwischengespeicherte Daten anzeigen, bis sie neu laden.',
'RSS Feeds': 'RSS Feeds',
'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file',
'RSS feeds saved': 'RSS feeds saved',

21
src/i18n/locales/en.ts

@ -350,6 +350,9 @@ export default { @@ -350,6 +350,9 @@ export default {
'Calculate optimal read relays': 'Calculate optimal read relays',
'Login to set': 'Login to set',
'Please login to view following feed': 'Please login to view following feed',
'Follow set': 'Follow set',
'Follow set feed empty':
'This NIP-51 list is empty, was not found, or relays could not load it yet.',
'Send only to r': 'Send only to {{r}}',
'Send only to these relays': 'Send only to these relays',
Explore: 'Explore',
@ -1306,6 +1309,24 @@ export default { @@ -1306,6 +1309,24 @@ export default {
standardRssFeed_substack: 'Substack',
standardRssFeed_medium: 'Medium',
'RSS Feed Settings': 'RSS Feed Settings',
'Follow sets': 'Follow sets',
'Follow sets settings intro':
'NIP-51 follow sets (kind 30000) group people for custom feeds (for example in Spells). Lists are published to your NIP-65 outboxes and profile discovery relays.',
'New follow set': 'New follow set',
'Edit follow set': 'Edit follow set',
'No follow sets yet': 'You have not created any follow sets yet.',
'Follow set saved': 'Follow set saved',
'Follow set deleted': 'Follow set deleted',
'Failed to load follow sets': 'Failed to load follow sets',
members: 'members',
'Optional display title': 'Optional display title',
'List id (d tag)': 'List id (d tag)',
'Follow set d tag hint':
'Stable identifier for this list. It cannot be changed after the first publish.',
'People in this list': 'People in this list',
'Delete follow set?': 'Delete this follow set?',
'Delete follow set confirm':
'This sends a deletion request (kind 5) for the list. Relays that accept it will drop the list; other clients may still show a cached copy until they refresh.',
'Remove feed': 'Remove feed',
'RSS Feeds': 'RSS Feeds',
'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file',

10
src/lib/draft-event.ts

@ -804,6 +804,16 @@ export function createMuteListDraftEvent(tags: string[][], content?: string): TD @@ -804,6 +804,16 @@ export function createMuteListDraftEvent(tags: string[][], content?: string): TD
}
}
/** NIP-51 follow set (kind 30000, addressable). Tags must include `d`; use {@link buildFollowSetTags}. */
export function createFollowSetDraftEvent(tags: string[][], content = '', created_at?: number): TDraftEvent {
return {
kind: ExtendedKind.FOLLOW_SET,
content,
created_at: created_at ?? dayjs().unix(),
tags
}
}
export function createProfileDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
return {
kind: kinds.Metadata,

103
src/lib/follow-set-spell.ts

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
import { tagNameEquals } from '@/lib/tag'
import type { Event } from 'nostr-tools'
export const FOLLOW_SET_SPELL_PREFIX = 'followset:' as const
export function isFollowSetSpellId(s: string): boolean {
return s.startsWith(FOLLOW_SET_SPELL_PREFIX)
}
export function encodeFollowSetSpellId(dTag: string): string {
return `${FOLLOW_SET_SPELL_PREFIX}${encodeURIComponent(dTag)}`
}
export function decodeFollowSetSpellId(spellId: string): string | null {
if (!isFollowSetSpellId(spellId)) return null
try {
const d = decodeURIComponent(spellId.slice(FOLLOW_SET_SPELL_PREFIX.length))
return d.length > 0 ? d : null
} catch {
return null
}
}
export function getFollowSetDTag(event: Event): string | undefined {
return event.tags.find(tagNameEquals('d'))?.[1]
}
export function labelFollowSetEvent(event: Event): string {
const title = event.tags.find(tagNameEquals('title'))?.[1]?.trim()
if (title) return title
const d = getFollowSetDTag(event)
return d ?? 'follow set'
}
/** Hex pubkeys from `p` tags (NIP-51 follow sets). */
export function pubkeysFromFollowSetEvent(event: Event): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const t of event.tags) {
if (t[0] !== 'p' || !t[1]) continue
const pk = t[1].trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) continue
if (seen.has(pk)) continue
seen.add(pk)
out.push(pk)
}
return out
}
/**
* Latest event per `d` tag. Skips deprecated NIP-51 kind 30000 + `d`=mute (use kind 10000 mute list).
*/
/** Build NIP-51 kind 30000 tags (`d` required; optional metadata; then `p` in order). */
export function buildFollowSetTags(params: {
d: string
title?: string
description?: string
image?: string
pubkeys: string[]
}): string[][] {
const d = params.d.trim()
if (!d || d === 'mute') throw new Error('Invalid list id')
const tags: string[][] = [['d', d]]
const title = params.title?.trim()
if (title) tags.push(['title', title])
const description = params.description?.trim()
if (description) tags.push(['description', description])
const image = params.image?.trim()
if (image) tags.push(['image', image])
for (const pk of params.pubkeys) {
const hex = pk.trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(hex)) tags.push(['p', hex])
}
return tags
}
export function extractFollowSetEditorFields(event: Event): {
d: string
title: string
description: string
image: string
pubkeys: string[]
} {
return {
d: getFollowSetDTag(event) ?? '',
title: event.tags.find(tagNameEquals('title'))?.[1] ?? '',
description: event.tags.find(tagNameEquals('description'))?.[1] ?? '',
image: event.tags.find(tagNameEquals('image'))?.[1] ?? '',
pubkeys: pubkeysFromFollowSetEvent(event)
}
}
export function dedupeFollowSetEventsByD(events: Event[]): Event[] {
const byD = new Map<string, Event>()
for (const e of [...events].sort((a, b) => b.created_at - a.created_at)) {
const d = getFollowSetDTag(e)
if (!d || d === 'mute') continue
if (!byD.has(d)) byD.set(d, e)
}
return [...byD.values()].sort((a, b) =>
labelFollowSetEvent(a).localeCompare(labelFollowSetEvent(b), undefined, { sensitivity: 'base' })
)
}

1
src/lib/link.ts

@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts' @@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts'
export const toGeneralSettings = () => '/settings/general'
export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toFollowSetsSettings = () => '/settings/follow-sets'
export const toCacheSettings = () => '/settings/cache'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`

55
src/pages/primary/NoteListPage/index.tsx

@ -11,7 +11,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -11,7 +11,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types'
import { Compass, Info } from 'lucide-react'
import { Compass, Info, UsersRound } from 'lucide-react'
import React, {
Dispatch,
forwardRef,
@ -224,28 +224,47 @@ function NoteListPageTitlebar({ @@ -224,28 +224,47 @@ function NoteListPageTitlebar({
const { navigate, current, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
const exploreActive = display && current === 'explore' && primaryViewType === null
const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null
return (
<div className="relative flex gap-1 items-center h-full justify-between">
<div className="flex min-w-0 flex-1 items-center gap-1 h-full pl-1 sm:pl-3">
{isSmallScreen && (
<Button
variant="ghost"
size="titlebar-icon"
title={t('Explore')}
aria-label={t('Explore')}
className={exploreActive ? 'bg-accent/50' : ''}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('explore')
}
}}
>
<Compass />
</Button>
<>
<Button
variant="ghost"
size="titlebar-icon"
title={t('Explore')}
aria-label={t('Explore')}
className={exploreActive ? 'bg-accent/50' : ''}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
} else {
navigate('explore')
}
}}
>
<Compass />
</Button>
<Button
variant="ghost"
size="titlebar-icon"
title={t('Follows latest nav label')}
aria-label={t('Follows latest nav label')}
className={followsLatestActive ? 'bg-accent/50' : ''}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
}
navigate('follows-latest')
}}
>
<UsersRound />
</Button>
</>
)}
</div>
{isSmallScreen && (

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

@ -30,7 +30,16 @@ import { useKindFilter } from '@/providers/KindFilterProvider' @@ -30,7 +30,16 @@ import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import client from '@/services/client.service'
import {
decodeFollowSetSpellId,
dedupeFollowSetEventsByD,
encodeFollowSetSpellId,
getFollowSetDTag,
isFollowSetSpellId,
labelFollowSetEvent,
pubkeysFromFollowSetEvent
} from '@/lib/follow-set-spell'
import client, { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import {
@ -199,10 +208,20 @@ function SpellSheetOptionRow({ @@ -199,10 +208,20 @@ function SpellSheetOptionRow({
type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number]
function isFauxSpellName(s: string): s is FauxSpellName {
function isSpellsPageBuiltinFauxSpell(s: string): s is FauxSpellName {
return (FAUX_SPELL_ORDER as readonly string[]).includes(s)
}
function isSpellsPageFauxSpellParam(s: string): boolean {
if (isSpellsPageBuiltinFauxSpell(s)) return true
if (!isFollowSetSpellId(s)) return false
return decodeFollowSetSpellId(s) != null
}
function isFollowFeedFauxSpellId(s: string | null): boolean {
return s === 'following' || (!!s && isFollowSetSpellId(s))
}
function useNoteListHideReplies() {
const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts')
@ -269,7 +288,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -269,7 +288,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const [spells, setSpells] = useState<Event[]>([])
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set())
const [selectedSpell, setSelectedSpell] = useState<Event | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<FauxSpellName | null>(null)
const [selectedFauxSpell, setSelectedFauxSpell] = useState<string | null>(null)
const [followSetListEvents, setFollowSetListEvents] = useState<Event[]>([])
const [followSetCatalogLoading, setFollowSetCatalogLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [spellToEdit, setSpellToEdit] = useState<Event | null>(null)
const [spellToClone, setSpellToClone] = useState<Event | null>(null)
@ -291,6 +312,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -291,6 +312,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const spellFeedInstrT0Ref = useRef(0)
const spellFeedInstrLabelRef = useRef('')
const [spellFeedInstrumentToken, setSpellFeedInstrumentToken] = useState(0)
const [followSetManualRefreshKey, setFollowSetManualRefreshKey] = useState(0)
const logSpellFeedPickerSelection = useCallback((label: string, extra?: Record<string, unknown>) => {
spellFeedInstrT0Ref.current = performance.now()
@ -309,7 +331,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -309,7 +331,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */
const fauxSpellUrlSyncFromPickerRef = useRef<string | null>(null)
useEffect(() => {
if (spellProp && isFauxSpellName(spellProp)) {
if (spellProp && isSpellsPageFauxSpellParam(spellProp)) {
if (fauxSpellUrlSyncFromPickerRef.current === spellProp) {
fauxSpellUrlSyncFromPickerRef.current = null
urlFauxSpellInstrumentedRef.current = spellProp
@ -343,7 +365,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -343,7 +365,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const refreshSpellsFeedAndCatalog = useCallback(() => {
void loadSpells()
if (pubkey) setSpellCatalogManualRefreshKey((k) => k + 1)
if (pubkey) {
setSpellCatalogManualRefreshKey((k) => k + 1)
setFollowSetManualRefreshKey((k) => k + 1)
}
spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey])
@ -396,6 +421,44 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -396,6 +421,44 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[blockedRelays]
)
useEffect(() => {
if (!pubkey) {
setFollowSetListEvents([])
setFollowSetCatalogLoading(false)
return
}
let cancelled = false
setFollowSetCatalogLoading(true)
void (async () => {
try {
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
)
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
if (!urls.length) {
if (!cancelled) setFollowSetListEvents([])
return
}
const events = await queryService.fetchEvents(
urls,
{ authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 },
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
)
if (!cancelled) setFollowSetListEvents(dedupeFollowSetEventsByD(events))
} catch {
if (!cancelled) setFollowSetListEvents([])
} finally {
if (!cancelled) setFollowSetCatalogLoading(false)
}
})()
return () => {
cancelled = true
}
}, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey])
/**
* Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer
* this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL.
@ -598,18 +661,58 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -598,18 +661,58 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([]))
}, [pubkey])
const followSetListStableKey = useMemo(
() =>
followSetListEvents
.map((e) => {
const d = getFollowSetDTag(e) ?? ''
return `${d}:${e.id}:${e.created_at}`
})
.sort()
.join('|'),
[followSetListEvents]
)
useEffect(() => {
if (selectedFauxSpell !== 'following' || !pubkey) {
if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) {
setFollowingSubRequests([])
setFollowingFeedLoading(false)
return
}
const followSetD =
selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell)
? decodeFollowSetSpellId(selectedFauxSpell)
: null
if (followSetD && followSetCatalogLoading) {
setFollowingSubRequests([])
setFollowingFeedLoading(true)
return
}
let cancelled = false
setFollowingFeedLoading(true)
void (async () => {
try {
const followings = await client.fetchFollowings(pubkey)
const req = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey)
let authorPubkeys: string[]
if (selectedFauxSpell === 'following') {
const followings = await client.fetchFollowings(pubkey)
authorPubkeys = [pubkey, ...followings]
} else if (followSetD) {
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD)
if (!ev) {
if (!cancelled) setFollowingSubRequests([])
return
}
const listed = pubkeysFromFollowSetEvent(ev)
authorPubkeys = [pubkey, ...listed]
} else {
if (!cancelled) setFollowingSubRequests([])
return
}
const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
const merged = augmentSubRequestsWithFavoritesFastReadAndInbox(
req,
favoriteRelays,
@ -636,7 +739,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -636,7 +739,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
pubkey,
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
relayMailboxStableKey
relayMailboxStableKey,
followSetCatalogLoading,
followSetListStableKey
])
const interestTagsStableKey = interestListEvent
@ -663,7 +768,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -663,7 +768,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedFauxSpell || selectedFauxSpell === 'following') return []
if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell)) return []
/** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */
const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' ||
@ -726,8 +831,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -726,8 +831,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, pubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base =
selectedFauxSpell === 'following' ? followingSubRequests : syncFauxSubRequests
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests
: syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
@ -852,7 +958,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -852,7 +958,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Avoid depending on `kindFilterShowKinds` ref for faux spells that don’t use it (e.g. Discussions). */
const followingShowKindsKey =
selectedFauxSpell === 'following' ? JSON.stringify(kindFilterShowKinds) : ''
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? JSON.stringify(kindFilterShowKinds)
: ''
const showKinds = useMemo(() => {
if (selectedFauxSpell === 'notifications') {
@ -861,7 +969,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -861,7 +969,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'discussions') {
return [ExtendedKind.DISCUSSION]
}
if (selectedFauxSpell === 'following') {
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) {
// Profile feed kinds omit boosts; show reposts as cards in this faux spell only.
const k = kindFilterShowKinds
if (k.includes(nostrKinds.Repost)) return k
@ -895,11 +1003,25 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -895,11 +1003,25 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[favoriteIds]
)
const selectedFauxSpellDisplayLabel = useMemo(() => {
if (!selectedFauxSpell) return ''
if (isFollowSetSpellId(selectedFauxSpell)) {
const d = decodeFollowSetSpellId(selectedFauxSpell)
if (!d) return t('Follow set')
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === d)
return ev ? labelFollowSetEvent(ev) : d
}
if (isSpellsPageBuiltinFauxSpell(selectedFauxSpell)) {
return t(fauxSpellLabelKey(selectedFauxSpell))
}
return selectedFauxSpell
}, [selectedFauxSpell, followSetListEvents, t])
const spellsTitlebarTitle = useMemo(() => {
if (selectedFauxSpell) return t(fauxSpellLabelKey(selectedFauxSpell))
if (selectedFauxSpell) return selectedFauxSpellDisplayLabel
if (selectedSpell) return spellMenuLabel(selectedSpell)
return t('Spells')
}, [selectedFauxSpell, selectedSpell, spellMenuLabel, t])
}, [selectedFauxSpell, selectedSpell, selectedFauxSpellDisplayLabel, spellMenuLabel, t])
const pickSpell = useCallback(
(spell: Event | null) => {
@ -930,9 +1052,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -930,9 +1052,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [logSpellFeedPickerSelection, navigatePrimary])
const pickFauxSpell = useCallback(
(name: FauxSpellName | null) => {
(name: string | null) => {
setSpellPickerOpen(false)
if (name) {
if (!isSpellsPageFauxSpellParam(name)) return
// Re-selecting the same built-in feed from the picker should not clear + resubscribe (toggle used to call
// pickFauxSpell(null) and wipe the timeline when the row was already selected).
if (selectedFauxSpell === name && selectedSpell === null) {
@ -972,7 +1095,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -972,7 +1095,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const fauxNoteListUseFilterAsIs = useMemo(() => {
if (!selectedFauxSpell) return true
return selectedFauxSpell !== 'following' && selectedFauxSpell !== 'bookmarks'
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false
return selectedFauxSpell !== 'bookmarks'
}, [selectedFauxSpell])
const notificationsMentionExtraHide = useCallback(
@ -985,12 +1109,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -985,12 +1109,21 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks') return t('No bookmarked notes with id tags yet.')
if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t])
const showFollowFeedLoading = !!(
pubkey &&
selectedFauxSpell &&
isFollowFeedFauxSpellId(selectedFauxSpell) &&
(followingFeedLoading ||
(isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading))
)
const spellPickerList = (
<>
{FAUX_SPELL_ORDER.map((name) => {
{FAUX_SPELL_ORDER.flatMap((name) => {
if (
(name === 'notifications' ||
name === 'following' ||
@ -998,11 +1131,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -998,11 +1131,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
name === 'interests') &&
!pubkey
) {
return null
return []
}
const Icon = FAUX_SPELL_ICON[name]
const selected = selectedFauxSpell === name
return (
const builtinRow = (
<button
key={name}
type="button"
@ -1024,6 +1157,38 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1024,6 +1157,38 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
</span>
</button>
)
if (name !== 'following' || !pubkey || followSetListEvents.length === 0) {
return [builtinRow]
}
const setRows = followSetListEvents.flatMap((ev) => {
const d = getFollowSetDTag(ev)
if (!d) return []
const spellId = encodeFollowSetSpellId(d)
const setSelected = selectedFauxSpell === spellId
return [
<button
key={spellId}
type="button"
role="option"
aria-selected={setSelected}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 pl-8 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
setSelected && 'bg-accent/50'
)}
onClick={() => pickFauxSpell(spellId)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{setSelected ? <Check className="size-4" aria-hidden /> : null}
</span>
<Users className="size-4 shrink-0 opacity-80" />
<span className="min-w-0 flex-1 truncate text-left font-medium">
{labelFollowSetEvent(ev)}
</span>
</button>
]
})
return [builtinRow, ...setRows]
})}
<button
type="button"
@ -1126,7 +1291,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1126,7 +1291,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
? selectedFauxSpellDisplayLabel
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
@ -1135,7 +1300,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1135,7 +1300,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
? selectedFauxSpellDisplayLabel
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
@ -1348,7 +1513,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1348,7 +1513,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground">
{t('Please log in to view notifications.')}
</div>
) : selectedFauxSpell === 'following' && !pubkey ? (
) : isFollowFeedFauxSpellId(selectedFauxSpell ?? '') && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view following feed')}
</div>
@ -1356,7 +1521,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1356,7 +1521,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')}
</div>
) : selectedFauxSpell === 'following' && followingFeedLoading ? (
) : showFollowFeedLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t('loading...')}</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
@ -1384,10 +1549,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1384,10 +1549,26 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
clientSideKindFilter={selectedFauxSpell === 'notifications'}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false}
showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true}
showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true}
showKind1111={selectedFauxSpell === 'following' ? showKind1111 : true}
hideReplies={selectedFauxSpell === 'following' ? hideRepliesFollowing : false}
showKind1OPs={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1OPs
: true
}
showKind1Replies={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1Replies
: true
}
showKind1111={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? showKind1111
: true
}
hideReplies={
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? hideRepliesFollowing
: false
}
extraShouldHideEvent={
selectedFauxSpell === 'notifications' && pubkey
? notificationsMentionExtraHide

381
src/pages/secondary/FollowSetsSettingsPage/index.tsx

@ -0,0 +1,381 @@ @@ -0,0 +1,381 @@
import { InviteePicker } from '@/components/InviteePicker'
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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import {
buildFollowSetTags,
dedupeFollowSetEventsByD,
extractFollowSetEditorFields,
labelFollowSetEvent
} from '@/lib/follow-set-spell'
import { randomString } from '@/lib/random'
import { showPublishingError } from '@/lib/publishing-feedback'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { createFollowSetDraftEvent } from '@/lib/draft-event'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { queryService } from '@/services/client.service'
import dayjs from 'dayjs'
import type { Event } from 'nostr-tools'
import { Pencil, Plus, Trash2, Users } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const FOLLOW_SET_FETCH_OPTS = {
eoseTimeout: 2000,
globalTimeout: 15000,
firstRelayResultGraceMs: false
} as const
const FollowSetsSettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { pubkey, publish, attemptDelete, checkLogin, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [lists, setLists] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [editing, setEditing] = useState<Event | null>(null)
const [formD, setFormD] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formImage, setFormImage] = useState('')
const [formPubkeys, setFormPubkeys] = useState<string[]>([])
const [deleteTarget, setDeleteTarget] = useState<Event | null>(null)
const [deleting, setDeleting] = useState(false)
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const buildReadRelays = useCallback((): string[] => {
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] }
)
return appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
}, [favoriteRelays, blockedRelays, relayList?.read, relayList?.write])
const loadLists = useCallback(async () => {
if (!pubkey) {
setLists([])
setLoading(false)
return
}
setLoading(true)
try {
const urls = buildReadRelays()
if (!urls.length) {
setLists([])
return
}
const events = await queryService.fetchEvents(
urls,
{ authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 },
FOLLOW_SET_FETCH_OPTS
)
setLists(dedupeFollowSetEventsByD(events))
} catch (e) {
logger.warn('[FollowSetsSettings] Failed to load follow sets', e)
toast.error(t('Failed to load follow sets'))
setLists([])
} finally {
setLoading(false)
}
}, [pubkey, buildReadRelays, t])
useEffect(() => {
void loadLists()
}, [loadLists])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => void loadLists())
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, loadLists])
const openNew = () => {
setEditing(null)
setFormD(randomString(16))
setFormTitle('')
setFormDescription('')
setFormImage('')
setFormPubkeys([])
setDialogOpen(true)
}
const openEdit = (ev: Event) => {
const f = extractFollowSetEditorFields(ev)
setEditing(ev)
setFormD(f.d)
setFormTitle(f.title)
setFormDescription(f.description)
setFormImage(f.image)
setFormPubkeys(f.pubkeys)
setDialogOpen(true)
}
const closeDialog = () => {
setDialogOpen(false)
setEditing(null)
}
const handleSave = async () => {
if (!(await checkLogin())) return
if (!pubkey) return
let tags: string[][]
try {
tags = buildFollowSetTags({
d: formD,
title: formTitle,
description: formDescription,
image: formImage,
pubkeys: formPubkeys
})
} catch (e) {
toast.error((e as Error).message)
return
}
setSaving(true)
try {
let createdAt = dayjs().unix()
if (editing && createdAt === editing.created_at) {
await new Promise((r) => setTimeout(r, 1100))
createdAt = dayjs().unix()
}
const draft = createFollowSetDraftEvent(tags, '', createdAt)
await publish(draft)
toast.success(t('Follow set saved'))
closeDialog()
await loadLists()
} catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setSaving(false)
}
}
const handleConfirmDelete = async () => {
if (!deleteTarget) return
if (!(await checkLogin())) return
setDeleting(true)
try {
await attemptDelete(deleteTarget)
toast.success(t('Follow set deleted'))
setDeleteTarget(null)
await loadLists()
} catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setDeleting(false)
}
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Follow sets')}
hideBackButton={hideTitlebar}
controls={hideTitlebar ? undefined : <RefreshButton onClick={() => void loadLists()} />}
displayScrollToTopButton
>
<div className="min-w-0 space-y-4 px-4 pb-8 pt-2">
<p className="text-sm text-muted-foreground leading-relaxed">
{t('Follow sets settings intro')}
</p>
{!pubkey ? (
<p className="text-sm text-muted-foreground">{t('Login to set')}</p>
) : (
<>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={openNew} className="gap-2">
<Plus className="size-4" />
{t('New follow set')}
</Button>
</div>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : lists.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('No follow sets yet')}</p>
) : (
<ul className="space-y-2">
{lists.map((ev) => (
<li
key={extractFollowSetEditorFields(ev).d}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/80 bg-card px-3 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<Users className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{labelFollowSetEvent(ev)}</div>
<div className="truncate text-xs text-muted-foreground">
{extractFollowSetEditorFields(ev).pubkeys.length} {t('members')}
<span className="mx-1">·</span>
<code className="text-[11px]">d={extractFollowSetEditorFields(ev).d}</code>
</div>
</div>
</div>
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => openEdit(ev)}
title={t('Edit')}
>
<Pencil className="size-4" />
<span className="sr-only">{t('Edit')}</span>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(ev)}
title={t('Delete')}
>
<Trash2 className="size-4" />
<span className="sr-only">{t('Delete')}</span>
</Button>
</div>
</li>
))}
</ul>
)}
</>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent className="max-h-[min(90dvh,36rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? t('Edit follow set') : t('New follow set')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label htmlFor="follow-set-d">{t('List id (d tag)')}</Label>
<Input
id="follow-set-d"
value={formD}
onChange={(e) => setFormD(e.target.value)}
disabled={!!editing}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">{t('Follow set d tag hint')}</p>
</div>
<div className="space-y-1">
<Label htmlFor="follow-set-title">{t('Title')}</Label>
<Input
id="follow-set-title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder={t('Optional display title')}
/>
</div>
<div className="space-y-1">
<Label htmlFor="follow-set-desc">{t('Description')}</Label>
<Textarea
id="follow-set-desc"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={2}
placeholder={t('Optional')}
/>
</div>
<div className="space-y-1">
<Label htmlFor="follow-set-image">{t('Image URL')}</Label>
<Input
id="follow-set-image"
value={formImage}
onChange={(e) => setFormImage(e.target.value)}
placeholder="https://…"
/>
</div>
<div className="space-y-1">
<Label id="follow-set-members-label">{t('People in this list')}</Label>
<InviteePicker
labelId="follow-set-members-label"
value={formPubkeys}
onChange={setFormPubkeys}
placeholder={t('Search by name or npub…')}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={closeDialog}>
{t('Cancel')}
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={saving || !formD.trim()}>
{saving ? t('loading...') : t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Delete follow set?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('Delete follow set confirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
onClick={(e) => {
e.preventDefault()
void handleConfirmDelete()
}}
>
{deleting ? t('loading...') : t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SecondaryPageLayout>
)
}
)
FollowSetsSettingsPage.displayName = 'FollowSetsSettingsPage'
export default FollowSetsSettingsPage

2
src/routes.tsx

@ -24,6 +24,7 @@ const RelayReviewsPageLazy = lazy(() => import('./pages/secondary/RelayReviewsPa @@ -24,6 +24,7 @@ const RelayReviewsPageLazy = lazy(() => import('./pages/secondary/RelayReviewsPa
const RelaySettingsPageLazy = lazy(() => import('./pages/secondary/RelaySettingsPage'))
const CacheSettingsPageLazy = lazy(() => import('./pages/secondary/CacheSettingsPage'))
const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage'))
const FollowSetsSettingsPageLazy = lazy(() => import('./pages/secondary/FollowSetsSettingsPage'))
const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage'))
const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
@ -79,6 +80,7 @@ const ROUTES = [ @@ -79,6 +80,7 @@ const ROUTES = [
{ path: '/settings/general', element: SR(GeneralSettingsPageLazy) },
{ path: '/settings/translation', element: SR(TranslationPageLazy) },
{ path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) },
{ path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) },
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }

6
src/services/client.service.ts

@ -644,7 +644,10 @@ class ClientService extends EventTarget { @@ -644,7 +644,10 @@ class ClientService extends EventTarget {
const bootstrapExtras: string[] = [...(additionalRelayUrls ?? [])]
let authorInboxFromContext: string[] = []
if (!specifiedRelayUrls?.length && ![kinds.Contacts, kinds.Mutelist].includes(event.kind)) {
if (
!specifiedRelayUrls?.length &&
![kinds.Contacts, kinds.Mutelist, ExtendedKind.FOLLOW_SET].includes(event.kind)
) {
const ctxPubkeys = this.collectReplyAndMentionPubkeys(event)
if (ctxPubkeys.length > 0) {
const relayLists = await this.fetchRelayLists(ctxPubkeys)
@ -661,6 +664,7 @@ class ClientService extends EventTarget { @@ -661,6 +664,7 @@ class ClientService extends EventTarget {
kinds.RelayList,
ExtendedKind.CACHE_RELAYS,
kinds.Contacts,
ExtendedKind.FOLLOW_SET,
ExtendedKind.BLOSSOM_SERVER_LIST,
ExtendedKind.RELAY_REVIEW
].includes(event.kind)

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

@ -17,6 +17,8 @@ export const StoreNames = { @@ -17,6 +17,8 @@ export const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
FOLLOW_LIST_EVENTS: 'followListEvents',
/** NIP-51 follow sets (kind 30000). Key: pubkey:d */
FOLLOW_SET_EVENTS: 'followSetEvents',
MUTE_LIST_EVENTS: 'muteListEvents',
BOOKMARK_LIST_EVENTS: 'bookmarkListEvents',
PIN_LIST_EVENTS: 'pinListEvents',
@ -54,7 +56,7 @@ export const StoreNames = { @@ -54,7 +56,7 @@ export const StoreNames = {
}
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 28
const DB_VERSION = 29
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
@ -155,6 +157,9 @@ class IndexedDbService { @@ -155,6 +157,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_LIST_EVENTS)) {
db.createObjectStore(StoreNames.FOLLOW_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.FOLLOW_SET_EVENTS)) {
db.createObjectStore(StoreNames.FOLLOW_SET_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.MUTE_LIST_EVENTS)) {
db.createObjectStore(StoreNames.MUTE_LIST_EVENTS, { keyPath: 'key' })
}
@ -815,6 +820,8 @@ class IndexedDbService { @@ -815,6 +820,8 @@ class IndexedDbService {
return StoreNames.RELAY_LIST_EVENTS
case kinds.Contacts:
return StoreNames.FOLLOW_LIST_EVENTS
case ExtendedKind.FOLLOW_SET:
return StoreNames.FOLLOW_SET_EVENTS
case kinds.Mutelist:
return StoreNames.MUTE_LIST_EVENTS
case kinds.BookmarkList:
@ -1452,6 +1459,7 @@ class IndexedDbService { @@ -1452,6 +1459,7 @@ class IndexedDbService {
if (storeName === StoreNames.PROFILE_EVENTS) return kinds.Metadata
if (storeName === StoreNames.RELAY_LIST_EVENTS) return kinds.RelayList
if (storeName === StoreNames.FOLLOW_LIST_EVENTS) return kinds.Contacts
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.PIN_LIST_EVENTS) return 10001
@ -1478,6 +1486,7 @@ class IndexedDbService { @@ -1478,6 +1486,7 @@ class IndexedDbService {
kind === kinds.RelayList ||
kind === kinds.Mutelist ||
kind === kinds.BookmarkList ||
kind === ExtendedKind.FOLLOW_SET ||
(kind >= 10000 && kind < 20000) ||
kind === ExtendedKind.FAVORITE_RELAYS ||
kind === ExtendedKind.BLOCKED_RELAYS ||
@ -1553,6 +1562,10 @@ class IndexedDbService { @@ -1553,6 +1562,10 @@ class IndexedDbService {
name: StoreNames.FOLLOW_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day
},
{
name: StoreNames.FOLLOW_SET_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 day
},
{
name: StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days

3
src/services/navigation.service.ts

@ -15,6 +15,7 @@ import PostSettingsPage from '@/pages/secondary/PostSettingsPage' @@ -15,6 +15,7 @@ import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage'
import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage'
@ -131,6 +132,8 @@ export class ComponentFactory { @@ -131,6 +132,8 @@ export class ComponentFactory {
return React.createElement(TranslationPage, { index: 0, hideTitlebar: true })
case 'rss-feeds':
return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
case 'follow-sets':
return React.createElement(FollowSetsSettingsPage, { index: 0, hideTitlebar: true })
default:
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
}

Loading…
Cancel
Save