From 48ef3ee74df587999ac4133e03c7f436b5015683 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 31 Mar 2026 10:50:06 +0200 Subject: [PATCH] make lists cleanable --- package-lock.json | 4 +- package.json | 2 +- src/PageManager.tsx | 42 ++++++--- src/constants.ts | 3 +- src/i18n/locales/en.ts | 8 ++ .../secondary/BookmarkListPage/index.tsx | 68 +++++++++++++- .../FollowSetsSettingsPage/index.tsx | 62 +++++++++++- .../secondary/FollowingListPage/index.tsx | 94 ++++++++++++++++++- .../secondary/InterestListPage/index.tsx | 66 ++++++++++++- src/pages/secondary/MuteListPage/index.tsx | 71 +++++++++++++- src/pages/secondary/PinListPage/index.tsx | 71 +++++++++++++- src/providers/NostrProvider/index.tsx | 50 +++++----- 12 files changed, 494 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0301af1c..16ad1fa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "21.2.1", + "version": "21.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "21.2.1", + "version": "21.3.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 34ff1039..91ba492e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/PageManager.tsx b/src/PageManager.tsx index bf382aea..21ee1ff9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -981,6 +981,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [savedPrimaryPage, setSavedPrimaryPage] = useState(null) const [drawerOpen, setDrawerOpen] = useState(false) const [drawerNoteId, setDrawerNoteId] = useState(null) + const [singlePaneSheetOpen, setSinglePaneSheetOpen] = useState(false) const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) /** Latest primary page for async callbacks (drawer-close timer) without resubscribing effects on every primary change. */ const currentPrimaryPageRef = useRef(currentPrimaryPage) @@ -1924,16 +1925,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } } - const clearSecondaryPages = () => { - if (secondaryStackRef.current.length === 0) return - const stackLength = secondaryStackRef.current.length - flushSync(() => { - setSecondaryStack([]) - }) + const hardCloseSecondaryPanel = useCallback(() => { + if (drawerOpen) setDrawerOpen(false) + setSinglePaneSheetOpen(false) + setSecondaryStack((prev) => (prev.length ? [] : prev)) 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 = { navigate: navigatePrimaryPage, 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.) */} - {panelMode === 'single' && !isSmallScreen && secondaryStack.length > 0 && !drawerOpen && ( + {panelMode === 'single' && + !isSmallScreen && + secondaryStack.length > 0 && + !drawerOpen && ( { if (!open) { - // Close drawer and go back - popSecondaryPage() + setSinglePaneSheetOpen(false) + // Close side panel immediately and clear the whole secondary stack. + hardCloseSecondaryPanel() } }} > diff --git a/src/constants.ts b/src/constants.ts index 922a1330..7ecadbb6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -299,7 +299,8 @@ export const FAST_WRITE_RELAY_URLS = [ 'wss://relay.damus.io', 'wss://relay.primal.net', '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. diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9770313c..9e17bba0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1654,6 +1654,14 @@ export default { '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.', + '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', 'RSS Feeds': 'RSS Feeds', 'RSS feeds exported to OPML file': 'RSS feeds exported to OPML file', diff --git a/src/pages/secondary/BookmarkListPage/index.tsx b/src/pages/secondary/BookmarkListPage/index.tsx index 0ca6cd59..f5b56d89 100644 --- a/src/pages/secondary/BookmarkListPage/index.tsx +++ b/src/pages/secondary/BookmarkListPage/index.tsx @@ -1,6 +1,16 @@ import JsonViewDialog from '@/components/JsonViewDialog' import PersonalListBech32List from '@/components/PersonalListBech32List' import { RefreshButton } from '@/components/RefreshButton' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -11,6 +21,7 @@ import { import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { bookmarkBech32IdsFromListEvent } from '@/lib/personal-list-refs' +import { createBookmarkDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import { getLatestEvent } from '@/lib/event' 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 { PROFILE_FETCH_RELAY_URLS } from '@/constants' 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 { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import NotFoundPage from '../NotFoundPage' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -29,10 +41,12 @@ const BookmarkListPage = forwardRef( ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() - const { profile, pubkey, bookmarkListEvent, relayList, updateBookmarkListEvent } = useNostr() + const { profile, pubkey, bookmarkListEvent, relayList, publish, updateBookmarkListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [jsonOpen, setJsonOpen] = useState(false) const [jsonPayload, setJsonPayload] = useState(null) + const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) + const [cleaning, setCleaning] = useState(false) const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent]) @@ -90,6 +104,28 @@ const BookmarkListPage = forwardRef( return () => registerPrimaryPanelRefresh(null) }, [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) { return } @@ -119,6 +155,13 @@ const BookmarkListPage = forwardRef( {t('View JSON')} + setCleanConfirmOpen(true)} + > + + {t('Clean list')} + @@ -127,6 +170,27 @@ const BookmarkListPage = forwardRef( displayScrollToTopButton > setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + +
{bech32Ids.length === 0 ? (

{t('No entries in bookmark list')}

diff --git a/src/pages/secondary/FollowSetsSettingsPage/index.tsx b/src/pages/secondary/FollowSetsSettingsPage/index.tsx index c7b14d99..00848c38 100644 --- a/src/pages/secondary/FollowSetsSettingsPage/index.tsx +++ b/src/pages/secondary/FollowSetsSettingsPage/index.tsx @@ -45,7 +45,7 @@ import { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import dayjs from 'dayjs' 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 { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -73,6 +73,8 @@ const FollowSetsSettingsPage = forwardRef( const [formPubkeys, setFormPubkeys] = useState([]) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) + const [cleanTarget, setCleanTarget] = useState(null) + const [cleaning, setCleaning] = useState(false) 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 (
+
@@ -81,6 +150,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? > setJsonOpen(false)} /> + + + + {t('Clean this list?')} + + {t('Clean follows list confirm with backup')} + + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + + ) }) diff --git a/src/pages/secondary/InterestListPage/index.tsx b/src/pages/secondary/InterestListPage/index.tsx index d1bfaf63..18dae3e9 100644 --- a/src/pages/secondary/InterestListPage/index.tsx +++ b/src/pages/secondary/InterestListPage/index.tsx @@ -1,5 +1,15 @@ import JsonViewDialog from '@/components/JsonViewDialog' 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 { Input } from '@/components/ui/input' import { @@ -11,6 +21,7 @@ import { import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' +import { createInterestListDraftEvent } from '@/lib/draft-event' import { normalizeTopic } from '@/lib/discussion-topics' import { toNoteList } from '@/lib/link' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' @@ -20,7 +31,7 @@ import { useInterestList } from '@/providers/InterestListProvider' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -33,12 +44,14 @@ const InterestListPage = forwardRef( const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { navigateToHashtag } = useSmartHashtagNavigation() - const { profile, pubkey, interestListEvent, updateInterestListEvent } = useNostr() + const { profile, pubkey, interestListEvent, publish, updateInterestListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { subscribedTopics, subscribe, unsubscribe, changing } = useInterestList() const [topicInput, setTopicInput] = useState('') const [jsonOpen, setJsonOpen] = useState(false) const [jsonPayload, setJsonPayload] = useState(null) + const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) + const [cleaning, setCleaning] = useState(false) const topicsSorted = useMemo( () => [...subscribedTopics].sort((a, b) => a.localeCompare(b)), @@ -92,6 +105,27 @@ const InterestListPage = forwardRef( 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) { return } @@ -124,6 +158,13 @@ const InterestListPage = forwardRef( {t('View JSON')} + setCleanConfirmOpen(true)} + > + + {t('Clean list')} + @@ -132,6 +173,27 @@ const InterestListPage = forwardRef( displayScrollToTopButton > setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + +

{t('Interests list section subtitle')}

void onAddTopic(ev)} className="flex flex-wrap items-center gap-2"> diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 8d29b835..c8b23f59 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -2,6 +2,16 @@ import JsonViewDialog from '@/components/JsonViewDialog' import MuteButton from '@/components/MuteButton' import Nip05 from '@/components/Nip05' import { RefreshButton } from '@/components/RefreshButton' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -15,24 +25,31 @@ import Username from '@/components/Username' import { useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' 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 indexedDb from '@/services/indexed-db.service' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import NotFoundPage from '../NotFoundPage' const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() - const { profile, pubkey, muteListEvent } = useNostr() + const { profile, pubkey, muteListEvent, publish, updateMuteListEvent } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { getMutePubkeys } = useMuteList() const [jsonOpen, setJsonOpen] = useState(false) const [jsonPayload, setJsonPayload] = useState(null) const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState([]) const [listRefreshKey, setListRefreshKey] = useState(0) + const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) + const [cleaning, setCleaning] = useState(false) const bottomRef = useRef(null) const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), []) @@ -98,6 +115,28 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb } }, [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) { return } @@ -123,6 +162,13 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb {t('View JSON')} + setCleanConfirmOpen(true)} + > + + {t('Clean list')} +
@@ -135,6 +181,27 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + +
{visibleMutePubkeys.map((pubkey, index) => ( diff --git a/src/pages/secondary/PinListPage/index.tsx b/src/pages/secondary/PinListPage/index.tsx index acfba518..45dcef4c 100644 --- a/src/pages/secondary/PinListPage/index.tsx +++ b/src/pages/secondary/PinListPage/index.tsx @@ -1,6 +1,16 @@ import JsonViewDialog from '@/components/JsonViewDialog' import PersonalListBech32List from '@/components/PersonalListBech32List' import { RefreshButton } from '@/components/RefreshButton' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -16,21 +26,24 @@ import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' 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 { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import NotFoundPage from '../NotFoundPage' const PinListPage = forwardRef( ({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() const { registerPrimaryPanelRefresh } = usePrimaryNoteView() - const { profile, pubkey } = useNostr() + const { profile, pubkey, publish } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [pinListEvent, setPinListEvent] = useState(null) const [jsonOpen, setJsonOpen] = useState(false) const [jsonPayload, setJsonPayload] = useState(null) + const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) + const [cleaning, setCleaning] = useState(false) const loadPins = useCallback(async () => { if (!pubkey) { @@ -95,6 +108,32 @@ const PinListPage = forwardRef( return () => registerPrimaryPanelRefresh(null) }, [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) { return } @@ -127,6 +166,13 @@ const PinListPage = forwardRef( {t('View JSON')} + setCleanConfirmOpen(true)} + > + + {t('Clean list')} +
@@ -135,6 +181,27 @@ const PinListPage = forwardRef( displayScrollToTopButton > setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + +
{bech32Ids.length === 0 ? (

{t('No pinned notes in list')}

diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 6801c0c6..c5ebc54f 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1031,27 +1031,50 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { 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) => { await Promise.allSettled([ - client.publishEvent(FAST_READ_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))), - client.publishEvent(FAST_READ_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))), + client.publishEvent( + FAST_READ_RELAY_URLS, + await signer.signEvent(normalizeDraftEventTags(createFollowListDraftEvent([]))) + ), + client.publishEvent( + FAST_READ_RELAY_URLS, + await signer.signEvent(normalizeDraftEventTags(createMuteListDraftEvent([]))) + ), client.publishEvent( FAST_READ_RELAY_URLS, 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 normalizedDraft = normalizeDraftEventTags(draftEvent) // Add timeout to prevent hanging const signEventWithTimeout = new Promise((resolve, reject) => { 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.')) }, 30000) // 30 second timeout - signer?.signEvent(draftEvent) + signer?.signEvent(normalizedDraft) .then((event) => { clearTimeout(timeout) resolve(event) @@ -1100,24 +1123,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { throw new Error('Invalid account state - pubkey is missing or invalid') } - const draft = JSON.parse(JSON.stringify(draftEvent)) as TDraftEvent - // 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()) - } + const draft = normalizeDraftEventTags(draftEvent) let event: VerifiedEvent if (minPow > 0) { const unsignedEvent = await minePow({ ...draft, pubkey: account.pubkey }, minPow)