14 changed files with 822 additions and 50 deletions
@ -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' }) |
||||||
|
) |
||||||
|
} |
||||||
@ -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 |
||||||
Loading…
Reference in new issue