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 @@ @@ -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",

2
package.json

@ -1,6 +1,6 @@ @@ -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",

42
src/PageManager.tsx

@ -981,6 +981,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -981,6 +981,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(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<TPrimaryPageName>(currentPrimaryPage)
@ -1924,16 +1925,31 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -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 }) { @@ -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 && (
<Sheet
open={true}
open={singlePaneSheetOpen}
registerWithModalManager={false}
onOpenChange={(open) => {
if (!open) {
// Close drawer and go back
popSecondaryPage()
setSinglePaneSheetOpen(false)
// 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 = [ @@ -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.

8
src/i18n/locales/en.ts

@ -1654,6 +1654,14 @@ export default { @@ -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',

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

@ -1,6 +1,16 @@ @@ -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 { @@ -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' @@ -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( @@ -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<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bech32Ids = useMemo(() => bookmarkBech32IdsFromListEvent(bookmarkListEvent), [bookmarkListEvent])
@ -90,6 +104,28 @@ const BookmarkListPage = forwardRef( @@ -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 <NotFoundPage />
}
@ -119,6 +155,13 @@ const BookmarkListPage = forwardRef( @@ -119,6 +155,13 @@ const BookmarkListPage = forwardRef(
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -127,6 +170,27 @@ const BookmarkListPage = forwardRef( @@ -127,6 +170,27 @@ const BookmarkListPage = forwardRef(
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div key={bookmarkListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No entries in bookmark list')}</p>

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

@ -45,7 +45,7 @@ import { queryService } from '@/services/client.service' @@ -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( @@ -73,6 +73,8 @@ const FollowSetsSettingsPage = forwardRef(
const [formPubkeys, setFormPubkeys] = useState<string[]>([])
const [deleteTarget, setDeleteTarget] = useState<Event | null>(null)
const [deleting, setDeleting] = useState(false)
const [cleanTarget, setCleanTarget] = useState<Event | null>(null)
const [cleaning, setCleaning] = useState(false)
const canSignEvents = account != null && account.signerType !== 'npub'
@ -216,6 +218,31 @@ const FollowSetsSettingsPage = forwardRef( @@ -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 (
<SecondaryPageLayout
ref={ref}
@ -267,6 +294,17 @@ const FollowSetsSettingsPage = forwardRef( @@ -267,6 +294,17 @@ const FollowSetsSettingsPage = forwardRef(
</div>
</div>
<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
type="button"
variant="outline"
@ -388,6 +426,28 @@ const FollowSetsSettingsPage = forwardRef( @@ -388,6 +426,28 @@ const FollowSetsSettingsPage = forwardRef(
</AlertDialogFooter>
</AlertDialogContent>
</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>
)
}

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

@ -1,6 +1,16 @@ @@ -1,6 +1,16 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import ProfileList from '@/components/ProfileList'
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,18 +21,29 @@ import { @@ -11,18 +21,29 @@ import {
import { useFetchFollowings, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { pubkey: accountPubkey, publish, updateFollowListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [listRefreshNonce, setListRefreshNonce] = useState(0)
const { profile } = useFetchProfile(id)
const { followings, followListEvent } = useFetchFollowings(profile?.pubkey, listRefreshNonce)
const [jsonOpen, setJsonOpen] = useState(false)
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), [])
@ -45,6 +66,45 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? @@ -45,6 +66,45 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
return () => registerPrimaryPanelRefresh(null)
}, [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 (
<SecondaryPageLayout
ref={ref}
@ -72,6 +132,15 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? @@ -72,6 +132,15 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
{isOwnList ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="size-4 mr-2" />
{t('Clean list')}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -81,6 +150,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id? @@ -81,6 +150,29 @@ const FollowingListPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
>
<JsonViewDialog value={followJsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<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>
)
})

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

@ -1,5 +1,15 @@ @@ -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 { @@ -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' @@ -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( @@ -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<unknown>(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( @@ -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 <NotFoundPage />
}
@ -124,6 +158,13 @@ const InterestListPage = forwardRef( @@ -124,6 +158,13 @@ const InterestListPage = forwardRef(
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -132,6 +173,27 @@ const InterestListPage = forwardRef( @@ -132,6 +173,27 @@ const InterestListPage = forwardRef(
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div 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>
<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' @@ -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' @@ -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<unknown>(null)
const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey])
const [visibleMutePubkeys, setVisibleMutePubkeys] = useState<string[]>([])
const [listRefreshKey, setListRefreshKey] = useState(0)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const bumpList = useCallback(() => setListRefreshKey((k) => k + 1), [])
@ -98,6 +115,28 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb @@ -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 <NotFoundPage />
}
@ -123,6 +162,13 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb @@ -123,6 +162,13 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
<Code className="size-4 mr-2" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="size-4 mr-2" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -135,6 +181,27 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb @@ -135,6 +181,27 @@ const MuteListPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
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={listRefreshKey} className="space-y-2 px-4 pt-2">
{visibleMutePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />

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

@ -1,6 +1,16 @@ @@ -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' @@ -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<Event | null>(null)
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const loadPins = useCallback(async () => {
if (!pubkey) {
@ -95,6 +108,32 @@ const PinListPage = forwardRef( @@ -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 <NotFoundPage />
}
@ -127,6 +166,13 @@ const PinListPage = forwardRef( @@ -127,6 +166,13 @@ const PinListPage = forwardRef(
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -135,6 +181,27 @@ const PinListPage = forwardRef( @@ -135,6 +181,27 @@ const PinListPage = forwardRef(
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div key={pinListEvent?.id ?? 'none'} className="min-h-[30vh] pt-1">
{bech32Ids.length === 0 ? (
<p className="px-4 pt-4 text-center text-sm text-muted-foreground">{t('No pinned notes in list')}</p>

50
src/providers/NostrProvider/index.tsx

@ -1031,27 +1031,50 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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 }) { @@ -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)

Loading…
Cancel
Save