Browse Source

make lists cleanable

imwald
Silberengel 1 month ago
parent
commit
48ef3ee74d
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 42
      src/PageManager.tsx
  4. 3
      src/constants.ts
  5. 8
      src/i18n/locales/en.ts
  6. 68
      src/pages/secondary/BookmarkListPage/index.tsx
  7. 62
      src/pages/secondary/FollowSetsSettingsPage/index.tsx
  8. 94
      src/pages/secondary/FollowingListPage/index.tsx
  9. 66
      src/pages/secondary/InterestListPage/index.tsx
  10. 71
      src/pages/secondary/MuteListPage/index.tsx
  11. 71
      src/pages/secondary/PinListPage/index.tsx
  12. 50
      src/providers/NostrProvider/index.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.2.1", "version": "21.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.2.1", "version": "21.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.2.1", "version": "21.3.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

42
src/PageManager.tsx

@ -981,6 +981,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null) const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [singlePaneSheetOpen, setSinglePaneSheetOpen] = useState(false)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
/** Latest primary page for async callbacks (drawer-close timer) without resubscribing effects on every primary change. */ /** Latest primary page for async callbacks (drawer-close timer) without resubscribing effects on every primary change. */
const currentPrimaryPageRef = useRef<TPrimaryPageName>(currentPrimaryPage) const currentPrimaryPageRef = useRef<TPrimaryPageName>(currentPrimaryPage)
@ -1924,16 +1925,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
} }
const clearSecondaryPages = () => { const hardCloseSecondaryPanel = useCallback(() => {
if (secondaryStackRef.current.length === 0) return if (drawerOpen) setDrawerOpen(false)
const stackLength = secondaryStackRef.current.length setSinglePaneSheetOpen(false)
flushSync(() => { setSecondaryStack((prev) => (prev.length ? [] : prev))
setSecondaryStack([])
})
secondaryStackRef.current = [] secondaryStackRef.current = []
window.history.go(-stackLength) const page = currentPrimaryPageRef.current
replaceHistoryWithPrimaryPageUrl(
page,
primaryPagePropsRef.current.get(page) as { spell?: string } | undefined
)
}, [drawerOpen])
const clearSecondaryPages = () => {
hardCloseSecondaryPanel()
} }
useEffect(() => {
const shouldBeOpen =
panelMode === 'single' &&
!isSmallScreen &&
secondaryStack.length > 0 &&
!drawerOpen
setSinglePaneSheetOpen(shouldBeOpen)
}, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen])
const primaryPageContextValue: PrimaryPageContextValue = { const primaryPageContextValue: PrimaryPageContextValue = {
navigate: navigatePrimaryPage, navigate: navigatePrimaryPage,
current: currentPrimaryPage, current: currentPrimaryPage,
@ -2167,14 +2183,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
/> />
)} )}
{/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && ( {panelMode === 'single' &&
!isSmallScreen &&
secondaryStack.length > 0 &&
!drawerOpen && (
<Sheet <Sheet
open={true} open={singlePaneSheetOpen}
registerWithModalManager={false} registerWithModalManager={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
// Close drawer and go back setSinglePaneSheetOpen(false)
popSecondaryPage() // Close side panel immediately and clear the whole secondary stack.
hardCloseSecondaryPanel()
} }
}} }}
> >

3
src/constants.ts

@ -299,7 +299,8 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol' 'wss://nos.lol',
'wss://nostr.einundzwanzig.space'
] ]
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. /** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.

8
src/i18n/locales/en.ts

@ -1654,6 +1654,14 @@ export default {
'Delete follow set?': 'Delete this follow set?', 'Delete follow set?': 'Delete this follow set?',
'Delete follow set confirm': '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.', '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.',
'Clean list': 'Clean list',
'Clean this list?': 'Clean this list?',
'Clean list confirm':
'This will publish a fresh, empty replacement for this list (all entries removed). This cannot be undone.',
'Clean follows list confirm with backup':
'Before cleaning your follows (kind 3), the current list snapshot will be published to follows history relays. Then a fresh, empty follows list will be published. Continue?',
'List cleaned': 'List cleaned',
'Failed to clean list': 'Failed to clean list',
'Remove feed': 'Remove feed', 'Remove feed': 'Remove feed',
'RSS Feeds': 'RSS Feeds', 'RSS Feeds': 'RSS Feeds',
'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file', 'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file',

68
src/pages/secondary/BookmarkListPage/index.tsx

@ -1,6 +1,16 @@
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import PersonalListBech32List from '@/components/PersonalListBech32List' import PersonalListBech32List from '@/components/PersonalListBech32List'
import { RefreshButton } from '@/components/RefreshButton' 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 { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@ -11,6 +21,7 @@ import {
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { bookmarkBech32IdsFromListEvent } from '@/lib/personal-list-refs' import { bookmarkBech32IdsFromListEvent } from '@/lib/personal-list-refs'
import { createBookmarkDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { getLatestEvent } from '@/lib/event' import { getLatestEvent } from '@/lib/event'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
@ -18,10 +29,11 @@ import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { PROFILE_FETCH_RELAY_URLS } from '@/constants' import { PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { Code, MoreVertical } from 'lucide-react' import { Code, Eraser, MoreVertical } from 'lucide-react'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -29,10 +41,12 @@ const BookmarkListPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, bookmarkListEvent, relayList, updateBookmarkListEvent } = useNostr() const { profile, pubkey, bookmarkListEvent, relayList, publish, updateBookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null) const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent]) const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent])
@ -90,6 +104,28 @@ const BookmarkListPage = forwardRef(
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays]) }, [hideTitlebar, registerPrimaryPanelRefresh, refreshFromRelays])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createBookmarkDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateBookmarkListEvent(published)
await refreshFromRelays()
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, favoriteRelays, blockedRelays, publish, updateBookmarkListEvent, refreshFromRelays, t])
if (!profile || !pubkey) { if (!profile || !pubkey) {
return <NotFoundPage /> return <NotFoundPage />
} }
@ -119,6 +155,13 @@ const BookmarkListPage = forwardRef(
<Code className="mr-2 size-4" /> <Code className="mr-2 size-4" />
{t('View JSON')} {t('View JSON')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -127,6 +170,27 @@ const BookmarkListPage = forwardRef(
displayScrollToTopButton displayScrollToTopButton
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> <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={bookmarkListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1"> <div key={bookmarkListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? ( {bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No entries in bookmark list')}</p> <p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No entries in bookmark list')}</p>

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

@ -45,7 +45,7 @@ import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { Pencil, Plus, Trash2, Users } from 'lucide-react' import { Eraser, Pencil, Plus, Trash2, Users } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -73,6 +73,8 @@ const FollowSetsSettingsPage = forwardRef(
const [formPubkeys, setFormPubkeys] = useState<string[]>([]) const [formPubkeys, setFormPubkeys] = useState<string[]>([])
const [deleteTarget, setDeleteTarget] = useState<Event | null>(null) const [deleteTarget, setDeleteTarget] = useState<Event | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [cleanTarget, setCleanTarget] = useState<Event | null>(null)
const [cleaning, setCleaning] = useState(false)
const canSignEvents = account != null && account.signerType !== 'npub' const canSignEvents = account != null && account.signerType !== 'npub'
@ -216,6 +218,31 @@ const FollowSetsSettingsPage = forwardRef(
}) })
} }
const handleConfirmClean = async () => {
if (!cleanTarget) return
await checkLogin(async () => {
setCleaning(true)
try {
const fields = extractFollowSetEditorFields(cleanTarget)
let createdAt = dayjs().unix()
if (createdAt === cleanTarget.created_at) {
await new Promise((r) => setTimeout(r, 1100))
createdAt = dayjs().unix()
}
const tags = buildFollowSetTags({ d: fields.d, pubkeys: [] })
const draft = createFollowSetDraftEvent(tags, '', createdAt)
await publish(draft)
toast.success(t('List cleaned'))
setCleanTarget(null)
await loadLists()
} catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setCleaning(false)
}
})
}
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
@ -267,6 +294,17 @@ const FollowSetsSettingsPage = forwardRef(
</div> </div>
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCleanTarget(ev)}
title={t('Clean list')}
className="text-destructive hover:text-destructive"
>
<Eraser className="size-4" />
<span className="sr-only">{t('Clean list')}</span>
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -388,6 +426,28 @@ const FollowSetsSettingsPage = forwardRef(
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog open={!!cleanTarget} onOpenChange={(o) => !o && setCleanTarget(null)}>
<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 handleConfirmClean()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }

94
src/pages/secondary/FollowingListPage/index.tsx

@ -1,6 +1,16 @@
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import ProfileList from '@/components/ProfileList' import ProfileList from '@/components/ProfileList'
import { RefreshButton } from '@/components/RefreshButton' 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 { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@ -11,18 +21,29 @@ import {
import { useFetchFollowings, useFetchProfile } from '@/hooks' import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { Code, MoreVertical } from 'lucide-react' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { FOLLOWS_HISTORY_RELAY_URLS } from '@/constants'
import { createFollowListDraftEvent } from '@/lib/draft-event'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Code, Eraser, MoreVertical } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useState } from 'react' import { forwardRef, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => { const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { pubkey: accountPubkey, publish, updateFollowListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [listRefreshNonce, setListRefreshNonce] = useState(0) const [listRefreshNonce, setListRefreshNonce] = useState(0)
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const { followings, followListEvent } = useFetchFollowings(profile?.pubkey, listRefreshNonce) const { followings, followListEvent } = useFetchFollowings(profile?.pubkey, listRefreshNonce)
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [followJsonPayload, setFollowJsonPayload] = useState<unknown>(null) const [followJsonPayload, setFollowJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const isOwnList = !!accountPubkey && profile?.pubkey === accountPubkey
const bumpList = useCallback(() => setListRefreshNonce((n) => n + 1), []) const bumpList = useCallback(() => setListRefreshNonce((n) => n + 1), [])
@ -45,6 +66,45 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpList]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpList])
const handleCleanList = useCallback(async () => {
if (!accountPubkey || !isOwnList || cleaning) return
setCleaning(true)
try {
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
if (followListEvent) {
const historyDraft = createFollowListDraftEvent(followListEvent.tags ?? [], followListEvent.content ?? '')
await publish(historyDraft, { specifiedRelayUrls: FOLLOWS_HISTORY_RELAY_URLS })
}
const draft = createFollowListDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateFollowListEvent(published)
bumpList()
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)
}
}, [
accountPubkey,
isOwnList,
cleaning,
followListEvent,
publish,
updateFollowListEvent,
favoriteRelays,
blockedRelays,
bumpList,
t
])
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
@ -72,6 +132,15 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
<Code className="size-4 mr-2" /> <Code className="size-4 mr-2" />
{t('View JSON')} {t('View JSON')}
</DropdownMenuItem> </DropdownMenuItem>
{isOwnList ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="size-4 mr-2" />
{t('Clean list')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -81,6 +150,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
> >
<JsonViewDialog value={followJsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> <JsonViewDialog value={followJsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<ProfileList pubkeys={followings} /> <ProfileList pubkeys={followings} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('Clean follows list confirm with backup')}
</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>
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

66
src/pages/secondary/InterestListPage/index.tsx

@ -1,5 +1,15 @@
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import { RefreshButton } from '@/components/RefreshButton' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
@ -11,6 +21,7 @@ import {
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createInterestListDraftEvent } from '@/lib/draft-event'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
@ -20,7 +31,7 @@ import { useInterestList } from '@/providers/InterestListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Code, MoreVertical, Trash2 } from 'lucide-react' import { Code, Eraser, MoreVertical, Trash2 } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -33,12 +44,14 @@ const InterestListPage = forwardRef(
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToHashtag } = useSmartHashtagNavigation()
const { profile, pubkey, interestListEvent, updateInterestListEvent } = useNostr() const { profile, pubkey, interestListEvent, publish, updateInterestListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { subscribedTopics, subscribe, unsubscribe, changing } = useInterestList() const { subscribedTopics, subscribe, unsubscribe, changing } = useInterestList()
const [topicInput, setTopicInput] = useState('') const [topicInput, setTopicInput] = useState('')
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null) const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const topicsSorted = useMemo( const topicsSorted = useMemo(
() => [...subscribedTopics].sort((a, b) => a.localeCompare(b)), () => [...subscribedTopics].sort((a, b) => a.localeCompare(b)),
@ -92,6 +105,27 @@ const InterestListPage = forwardRef(
setTopicInput('') setTopicInput('')
} }
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createInterestListDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateInterestListEvent(published)
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, favoriteRelays, blockedRelays, publish, updateInterestListEvent, t])
if (!profile || !pubkey) { if (!profile || !pubkey) {
return <NotFoundPage /> return <NotFoundPage />
} }
@ -124,6 +158,13 @@ const InterestListPage = forwardRef(
<Code className="mr-2 size-4" /> <Code className="mr-2 size-4" />
{t('View JSON')} {t('View JSON')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -132,6 +173,27 @@ const InterestListPage = forwardRef(
displayScrollToTopButton displayScrollToTopButton
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> <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 className="min-w-0 space-y-3 px-3 pb-4 pt-2"> <div className="min-w-0 space-y-3 px-3 pb-4 pt-2">
<p className="text-sm text-muted-foreground">{t('Interests list section subtitle')}</p> <p className="text-sm text-muted-foreground">{t('Interests list section subtitle')}</p>
<form onSubmit={(ev) => void onAddTopic(ev)} className="flex flex-wrap items-center gap-2"> <form onSubmit={(ev) => void onAddTopic(ev)} className="flex flex-wrap items-center gap-2">

71
src/pages/secondary/MuteListPage/index.tsx

@ -2,6 +2,16 @@ import JsonViewDialog from '@/components/JsonViewDialog'
import MuteButton from '@/components/MuteButton' import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05' import Nip05 from '@/components/Nip05'
import { RefreshButton } from '@/components/RefreshButton' 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 { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@ -15,24 +25,31 @@ import Username from '@/components/Username'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createMuteListDraftEvent } from '@/lib/draft-event'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Code, Lock, MoreVertical, Unlock } from 'lucide-react' import { Code, Eraser, Lock, MoreVertical, Unlock } from 'lucide-react'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, muteListEvent } = useNostr() const { profile, pubkey, muteListEvent, publish, updateMuteListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { getMutePubkeys } = useMuteList() const { getMutePubkeys } = useMuteList()
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null) const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const [listRefreshKey, setListRefreshKey] = useState(0) const [listRefreshKey, setListRefreshKey] = useState(0)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), []) const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), [])
@ -98,6 +115,28 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
} }
}, [visibleMutePubkeys, mutePubkeys]) }, [visibleMutePubkeys, mutePubkeys])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createMuteListDraftEvent([], '')
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
await updateMuteListEvent(published, [])
bumpList()
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, favoriteRelays, blockedRelays, publish, updateMuteListEvent, bumpList, t])
if (!profile) { if (!profile) {
return <NotFoundPage /> return <NotFoundPage />
} }
@ -123,6 +162,13 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
<Code className="size-4 mr-2" /> <Code className="size-4 mr-2" />
{t('View JSON')} {t('View JSON')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="size-4 mr-2" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -135,6 +181,27 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
isOpen={jsonOpen} isOpen={jsonOpen}
onClose={() => setJsonOpen(false)} 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={listRefreshKey} className="space-y-2 px-4 pt-2"> <div key={listRefreshKey} className="space-y-2 px-4 pt-2">
{visibleMutePubkeys.map((pubkey, index) => ( {visibleMutePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />

71
src/pages/secondary/PinListPage/index.tsx

@ -1,6 +1,16 @@
import JsonViewDialog from '@/components/JsonViewDialog' import JsonViewDialog from '@/components/JsonViewDialog'
import PersonalListBech32List from '@/components/PersonalListBech32List' import PersonalListBech32List from '@/components/PersonalListBech32List'
import { RefreshButton } from '@/components/RefreshButton' 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 { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
@ -16,21 +26,24 @@ import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { Code, MoreVertical } from 'lucide-react' import { Code, Eraser, MoreVertical } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
const PinListPage = forwardRef( const PinListPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey } = useNostr() const { profile, pubkey, publish } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [pinListEvent, setPinListEvent] = useState<Event | null>(null) const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null) const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const loadPins = useCallback(async () => { const loadPins = useCallback(async () => {
if (!pubkey) { if (!pubkey) {
@ -95,6 +108,32 @@ const PinListPage = forwardRef(
return () => registerPrimaryPanelRefresh(null) return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, loadPins]) }, [hideTitlebar, registerPrimaryPanelRefresh, loadPins])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = { kind: 10001, content: '', tags: [], created_at: Math.floor(Date.now() / 1000) }
const published = await publish(draft, { specifiedRelayUrls: comprehensiveRelays })
setPinListEvent(published as Event)
try {
await indexedDb.putReplaceableEvent(published as Event)
} catch {
/* ignore */
}
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, favoriteRelays, blockedRelays, publish, t])
if (!profile || !pubkey) { if (!profile || !pubkey) {
return <NotFoundPage /> return <NotFoundPage />
} }
@ -127,6 +166,13 @@ const PinListPage = forwardRef(
<Code className="mr-2 size-4" /> <Code className="mr-2 size-4" />
{t('View JSON')} {t('View JSON')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@ -135,6 +181,27 @@ const PinListPage = forwardRef(
displayScrollToTopButton displayScrollToTopButton
> >
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> <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={pinListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1"> <div key={pinListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? ( {bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No pinned notes in list')}</p> <p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No pinned notes in list')}</p>

50
src/providers/NostrProvider/index.tsx

@ -1031,27 +1031,50 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
return null return null
} }
const normalizeDraftEventTags = (draftEvent: TDraftEvent): TDraftEvent => {
const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent
const jumbleAttributionAlt = buildAltTag()[1]
const existingTags = Array.isArray(draft.tags) ? draft.tags : []
const sanitizedTags = existingTags.filter(
(tag) =>
Array.isArray(tag) &&
tag[0] !== 'client' &&
!(tag[0] === 'alt' && tag[1] === jumbleAttributionAlt)
)
draft.tags = [...sanitizedTags, buildClientTag(), buildAltTag()]
return draft
}
const setupNewUser = async (signer: ISigner) => { const setupNewUser = async (signer: ISigner) => {
await Promise.allSettled([ await Promise.allSettled([
client.publishEvent(FAST_READ_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))), client.publishEvent(
client.publishEvent(FAST_READ_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))), FAST_READ_RELAY_URLS,
await signer.signEvent(normalizeDraftEventTags(createFollowListDraftEvent([])))
),
client.publishEvent(
FAST_READ_RELAY_URLS,
await signer.signEvent(normalizeDraftEventTags(createMuteListDraftEvent([])))
),
client.publishEvent( client.publishEvent(
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
await signer.signEvent( await signer.signEvent(
createRelayListDraftEvent(FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'both' }))) normalizeDraftEventTags(
createRelayListDraftEvent(FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
)
) )
) )
]) ])
} }
const signEvent = async (draftEvent: TDraftEvent) => { const signEvent = async (draftEvent: TDraftEvent) => {
const normalizedDraft = normalizeDraftEventTags(draftEvent)
// Add timeout to prevent hanging // Add timeout to prevent hanging
const signEventWithTimeout = new Promise((resolve, reject) => { const signEventWithTimeout = new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reject(new Error('Signing request timed out. Your Nostr extension may be waiting for authorization. Try closing this tab and restarting your browser to surface any pending authorization requests from your extension.')) reject(new Error('Signing request timed out. Your Nostr extension may be waiting for authorization. Try closing this tab and restarting your browser to surface any pending authorization requests from your extension.'))
}, 30000) // 30 second timeout }, 30000) // 30 second timeout
signer?.signEvent(draftEvent) signer?.signEvent(normalizedDraft)
.then((event) => { .then((event) => {
clearTimeout(timeout) clearTimeout(timeout)
resolve(event) resolve(event)
@ -1100,24 +1123,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
throw new Error('Invalid account state - pubkey is missing or invalid') throw new Error('Invalid account state - pubkey is missing or invalid')
} }
const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent const draft = normalizeDraftEventTags(draftEvent)
// 1) Remove any existing "client" tag so we control the only one
if (draft.tags?.length) {
draft.tags = draft.tags.filter((tag) => Array.isArray(tag) && tag[0] !== 'client')
}
// 2) If user has allowed adding a client tag, add our own (and drop prior Jumble "alt" lines —
// unlike "client", we used not to strip "alt", so follow-list merges accumulated duplicates).
const addClientTag =
typeof options.addClientTag === 'boolean'
? options.addClientTag
: (typeof window !== 'undefined' && storage.getAddClientTag())
if (addClientTag) {
const jumbleAttributionAlt = buildAltTag()[1]
draft.tags = (draft.tags ?? []).filter(
(tag) => Array.isArray(tag) && !(tag[0] === 'alt' && tag[1] === jumbleAttributionAlt)
)
draft.tags.push(buildClientTag(), buildAltTag())
}
let event: VerifiedEvent let event: VerifiedEvent
if (minPow > 0) { if (minPow > 0) {
const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow) const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)

Loading…
Cancel
Save