Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
98764541f5
  1. 6
      src/components/BookmarkButton/index.tsx
  2. 21
      src/components/PersonalListBech32List/index.tsx
  3. 147
      src/components/PersonalListNoteRefRow/index.tsx
  4. 63
      src/hooks/useRemovePinListEntry.ts
  5. 4
      src/i18n/locales/en.ts
  6. 40
      src/lib/personal-list-mutations.ts
  7. 25
      src/lib/replaceable-list-latest.ts
  8. 2
      src/pages/secondary/BookmarkListPage/index.tsx
  9. 6
      src/pages/secondary/PinListPage/index.tsx
  10. 46
      src/providers/BookmarksProvider.tsx
  11. 5
      src/providers/bookmarks-context.tsx

6
src/components/BookmarkButton/index.tsx

@ -15,7 +15,11 @@ export default function BookmarkButton({ event }: { event: Event }) {
const accountPubkey = nostrContext?.pubkey ?? null const accountPubkey = nostrContext?.pubkey ?? null
const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null
const checkLogin = nostrContext?.checkLogin ?? (async () => {}) const checkLogin = nostrContext?.checkLogin ?? (async () => {})
const { addBookmark, removeBookmark } = bookmarksContext ?? { addBookmark: async () => {}, removeBookmark: async () => {} } const { addBookmark, removeBookmark } = bookmarksContext ?? {
addBookmark: async () => {},
removeBookmark: async () => false,
removeBookmarkByBech32: async () => false
}
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isBookmarked = useMemo(() => { const isBookmarked = useMemo(() => {
const isReplaceable = isReplaceableEvent(event.kind) const isReplaceable = isReplaceableEvent(event.kind)

21
src/components/PersonalListBech32List/index.tsx

@ -3,8 +3,18 @@ import { useEffect, useRef, useState } from 'react'
const PAGE = 10 const PAGE = 10
type TListMode = 'bookmark' | 'pin'
/** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */ /** Paginated list of nevent/naddr ids (same infinite-scroll pattern as mute list / {@link ProfileList}). */
export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: string[] }) { export default function PersonalListBech32List({
bech32Ids,
listMode,
onEntryRemoved
}: {
bech32Ids: string[]
listMode?: TListMode
onEntryRemoved?: () => void
}) {
const [visible, setVisible] = useState<string[]>([]) const [visible, setVisible] = useState<string[]>([])
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
@ -28,9 +38,14 @@ export default function PersonalListBech32List({ bech32Ids }: { bech32Ids: strin
}, [visible, bech32Ids]) }, [visible, bech32Ids])
return ( return (
<div className="space-y-0 divide-y divide-border/60"> <div className="space-y-0">
{visible.map((id) => ( {visible.map((id) => (
<PersonalListNoteRefRow key={id} bech32Id={id} /> <PersonalListNoteRefRow
key={id}
bech32Id={id}
listMode={listMode}
onEntryRemoved={onEntryRemoved}
/>
))} ))}
{bech32Ids.length > visible.length ? <div ref={bottomRef} className="h-4" /> : null} {bech32Ids.length > visible.length ? <div ref={bottomRef} className="h-4" /> : null}
</div> </div>

147
src/components/PersonalListNoteRefRow/index.tsx

@ -1,21 +1,41 @@
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { useRemovePinListEntry } from '@/hooks/useRemovePinListEntry'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useSmartNoteNavigation } from '@/PageManager' import { useSmartNoteNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { ChevronRight } from 'lucide-react' import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useMemo } from 'react' import { useNostr } from '@/providers/NostrProvider'
import { ChevronRight, Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { Event } from 'nostr-tools'
type TListMode = 'bookmark' | 'pin'
/** /**
* One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists). * One row in bookmark / pin list pages (same idea as {@link UserItem} on mute/follow lists).
*/ */
export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string }) { export default function PersonalListNoteRefRow({
bech32Id,
listMode,
onEntryRemoved
}: {
bech32Id: string
listMode?: TListMode
onEntryRemoved?: () => void
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(bech32Id) const { event, isFetching } = useFetchEvent(bech32Id)
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
const { checkLogin } = useNostr()
const bookmarks = useBookmarksOptional()
const removePinEntry = useRemovePinListEntry(onEntryRemoved)
const [removing, setRemoving] = useState(false)
const preview = useMemo(() => { const preview = useMemo(() => {
const c = event?.content?.trim() const c = event?.content?.trim()
if (!c) return '' if (!c) return ''
@ -24,6 +44,59 @@ export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string
const onOpen = () => navigateToNote(toNote(bech32Id)) const onOpen = () => navigateToNote(toNote(bech32Id))
const handleRemove = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!listMode || removing) return
void checkLogin(async () => {
setRemoving(true)
try {
if (listMode === 'bookmark') {
if (!bookmarks) {
toast.error(t('Remove bookmark failed'))
return
}
const ok = event
? await bookmarks.removeBookmark(event as Event)
: await bookmarks.removeBookmarkByBech32(bech32Id)
if (ok) {
toast.success(t('Removed from bookmarks'))
} else {
toast.info(t('Bookmark not in list'))
}
} else {
const ok = await removePinEntry(bech32Id, event as Event | null)
if (ok) {
toast.success(t('Note unpinned'))
} else {
toast.info(t('Pin not in list'))
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(
listMode === 'bookmark'
? `${t('Remove bookmark failed')}: ${msg}`
: `${t('Failed to remove pin')}: ${msg}`
)
} finally {
setRemoving(false)
}
})
},
[
bech32Id,
bookmarks,
checkLogin,
event,
listMode,
removePinEntry,
removing,
t
]
)
if (isFetching) { if (isFetching) {
return ( return (
<div className="flex items-center gap-2 px-4 py-2"> <div className="flex items-center gap-2 px-4 py-2">
@ -37,33 +110,49 @@ export default function PersonalListNoteRefRow({ bech32Id }: { bech32Id: string
} }
return ( return (
<Button <div className="flex min-h-[3.5rem] w-full items-stretch border-b border-border/60 last:border-b-0">
type="button" <Button
variant="ghost" type="button"
className="h-auto min-h-[3.5rem] w-full justify-start gap-2 rounded-none px-4 py-2 font-normal hover:bg-muted/60" variant="ghost"
onClick={onOpen} className="h-auto min-h-[3.5rem] min-w-0 flex-1 justify-start gap-2 rounded-none px-4 py-2 font-normal hover:bg-muted/60"
> onClick={onOpen}
{event ? ( >
<> {event ? (
<UserAvatar userId={event.pubkey} className="shrink-0" /> <>
<div className="min-w-0 flex-1 text-left"> <UserAvatar userId={event.pubkey} className="shrink-0" />
<Username <div className="min-w-0 flex-1 text-left">
userId={event.pubkey} <Username
className="max-w-full truncate font-semibold" userId={event.pubkey}
skeletonClassName="h-4" className="max-w-full truncate font-semibold"
/> skeletonClassName="h-4"
<div className="truncate text-sm text-muted-foreground"> />
{preview || t('Event kind label', { kind: event.kind })} <div className="truncate text-sm text-muted-foreground">
{preview || t('Event kind label', { kind: event.kind })}
</div>
</div> </div>
</>
) : (
<div className="min-w-0 flex-1 text-left font-mono text-xs text-muted-foreground">
{bech32Id.length > 36 ? `${bech32Id.slice(0, 28)}` : bech32Id}
<div className="mt-0.5 text-[11px]">{t('Event not loaded')}</div>
</div> </div>
</> )}
) : ( <ChevronRight className="size-4 shrink-0 opacity-50" />
<div className="min-w-0 flex-1 text-left font-mono text-xs text-muted-foreground"> </Button>
{bech32Id.length > 36 ? `${bech32Id.slice(0, 28)}` : bech32Id} {listMode ? (
<div className="mt-0.5 text-[11px]">{t('Event not loaded')}</div> <Button
</div> type="button"
)} variant="ghost"
<ChevronRight className="size-4 shrink-0 opacity-50" /> size="icon"
</Button> className="h-auto min-w-[44px] shrink-0 rounded-none text-muted-foreground hover:text-destructive"
disabled={removing}
title={listMode === 'bookmark' ? t('Remove bookmark') : t('Unpin note')}
aria-label={listMode === 'bookmark' ? t('Remove bookmark') : t('Unpin note')}
onClick={handleRemove}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
) )
} }

63
src/hooks/useRemovePinListEntry.ts

@ -0,0 +1,63 @@
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import {
buildPinListTagsAfterRemovingRef,
buildPinListTagsAfterToggle,
fetchNewestPinListForPubkey,
isEventInPinList
} from '@/lib/replaceable-list-latest'
import { decodePersonalListBech32Ref } from '@/lib/personal-list-mutations'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import { useCallback } from 'react'
import type { Event } from 'nostr-tools'
/**
* Publish an updated kind 10001 pin list without the given entry (by loaded event or NIP-19 ref).
*/
export function useRemovePinListEntry(onSuccess?: () => void) {
const { publish, pubkey } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const removePinEntry = useCallback(
async (bech32Id: string, loadedEvent: Event | null): Promise<boolean> => {
if (!pubkey) return false
const comprehensiveRelays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
if (!comprehensiveRelays.length) return false
const latest = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays)
if (!latest) return false
let newTags: string[][] | null = null
if (loadedEvent) {
if (!isEventInPinList(latest, loadedEvent)) return false
newTags = buildPinListTagsAfterToggle(latest, loadedEvent, false)
} else {
const ref = decodePersonalListBech32Ref(bech32Id)
if (!ref) return false
newTags = buildPinListTagsAfterRemovingRef(latest.tags, ref)
}
if (!newTags) return false
const published = await publish(
{
kind: 10001,
tags: newTags,
content: '',
created_at: Math.floor(Date.now() / 1000)
},
{ specifiedRelayUrls: comprehensiveRelays }
)
await indexedDb.putReplaceableEvent(published as Event)
onSuccess?.()
return true
},
[blockedRelays, favoriteRelays, onSuccess, publish, pubkey]
)
return removePinEntry
}

4
src/i18n/locales/en.ts

@ -679,6 +679,10 @@ export default {
'Lightning Invoice': 'Lightning Invoice', 'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed', 'Bookmark failed': 'Bookmark failed',
'Remove bookmark failed': 'Remove bookmark failed', 'Remove bookmark failed': 'Remove bookmark failed',
'Removed from bookmarks': 'Removed from bookmarks',
'Bookmark not in list': 'This bookmark is not in your list (already removed or out of sync).',
'Pin not in list': 'This pin is not in your list (already removed or out of sync).',
'Failed to remove pin': 'Failed to remove pin',
Translation: 'Translation', Translation: 'Translation',
Balance: 'Balance', Balance: 'Balance',
characters: 'characters', characters: 'characters',

40
src/lib/personal-list-mutations.ts

@ -0,0 +1,40 @@
import { nip19 } from 'nostr-tools'
/** Decoded target for one bookmark/pin list entry (NIP-19 nevent/note or naddr). */
export type TPersonalListBech32Ref = { eIdLower?: string; aCoordLower?: string }
export function decodePersonalListBech32Ref(bech32Id: string): TPersonalListBech32Ref | null {
try {
const dec = nip19.decode(bech32Id.trim())
if (dec.type === 'nevent') {
return { eIdLower: dec.data.id.toLowerCase() }
}
if (dec.type === 'note') {
return { eIdLower: dec.data.toLowerCase() }
}
if (dec.type === 'naddr') {
const { kind, pubkey, identifier } = dec.data
return { aCoordLower: `${kind}:${pubkey}:${identifier}`.toLowerCase() }
}
} catch {
return null
}
return null
}
/**
* Next bookmark list (kind 10003) tags after dropping one `e` or `a` ref.
* Returns null if nothing matched (list unchanged).
*/
export function bookmarkListTagsAfterRemovingRef(
tags: string[][],
ref: TPersonalListBech32Ref
): string[][] | null {
if (!ref.eIdLower && !ref.aCoordLower) return null
const next = tags.filter((tag) => {
if (ref.eIdLower && tag[0] === 'e' && tag[1]?.toLowerCase() === ref.eIdLower) return false
if (ref.aCoordLower && tag[0] === 'a' && tag[1]?.toLowerCase() === ref.aCoordLower) return false
return true
})
return next.length === tags.length ? null : next
}

25
src/lib/replaceable-list-latest.ts

@ -2,6 +2,7 @@ import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEO
import { normalizeHexPubkey } from '@/lib/pubkey' import { normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client, { queryService } from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
/** /**
@ -104,6 +105,30 @@ export function buildPinListTagsAfterToggle(
return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])] return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])]
} }
/**
* Pin list tags after removing an entry identified only by nevent/note id and/or naddr coordinate
* (when the pinned event is not loaded). Returns null if nothing matched.
*/
export function buildPinListTagsAfterRemovingRef(
tags: string[][],
ref: TPersonalListBech32Ref
): string[][] | null {
if (!ref.eIdLower && !ref.aCoordLower) return null
const meta = tags.filter((t) => t[0] !== 'e' && t[0] !== 'a')
let aKeep = tags.filter((t) => t[0] === 'a' && t[1])
const origALen = aKeep.length
if (ref.aCoordLower) {
aKeep = aKeep.filter((t) => t[1]!.toLowerCase() !== ref.aCoordLower)
}
let eIds = orderedUniqueEHexIds(tags)
const origELen = eIds.length
if (ref.eIdLower) {
eIds = eIds.filter((x) => x !== ref.eIdLower)
}
if (aKeep.length === origALen && eIds.length === origELen) return null
return [...meta, ...aKeep, ...eIds.map((eid) => ['e', eid] as string[])]
}
/** Dedupe `p` tags (case-insensitive hex), preserve other tags and first-seen `p` casing. */ /** Dedupe `p` tags (case-insensitive hex), preserve other tags and first-seen `p` casing. */
function dedupePTags(tags: string[][]): string[][] { function dedupePTags(tags: string[][]): string[][] {
const nonP = tags.filter((t) => t[0] !== 'p') const nonP = tags.filter((t) => t[0] !== 'p')

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

@ -131,7 +131,7 @@ const BookmarkListPage = forwardRef(
{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>
) : ( ) : (
<PersonalListBech32List bech32Ids={bech32Ids} /> <PersonalListBech32List bech32Ids={bech32Ids} listMode="bookmark" />
)} )}
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

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

@ -139,7 +139,11 @@ const PinListPage = forwardRef(
{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>
) : ( ) : (
<PersonalListBech32List bech32Ids={bech32Ids} /> <PersonalListBech32List
bech32Ids={bech32Ids}
listMode="pin"
onEntryRemoved={() => void loadPins()}
/>
)} )}
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

46
src/providers/BookmarksProvider.tsx

@ -1,6 +1,10 @@
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { buildATag, buildETag, createBookmarkDraftEvent } from '@/lib/draft-event' import { buildATag, buildETag, createBookmarkDraftEvent } from '@/lib/draft-event'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import {
bookmarkListTagsAfterRemovingRef,
decodePersonalListBech32Ref
} from '@/lib/personal-list-mutations'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -59,8 +63,8 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) {
await updateBookmarkListEvent(newBookmarkEvent) await updateBookmarkListEvent(newBookmarkEvent)
} }
const removeBookmark = async (event: Event) => { const removeBookmark = async (event: Event): Promise<boolean> => {
if (!accountPubkey) return if (!accountPubkey) return false
const comprehensiveRelays = await buildComprehensiveRelayList() const comprehensiveRelays = await buildComprehensiveRelayList()
let bookmarkListEvent = let bookmarkListEvent =
@ -68,7 +72,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) {
if (!bookmarkListEvent) { if (!bookmarkListEvent) {
bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null
} }
if (!bookmarkListEvent) return if (!bookmarkListEvent) return false
const isReplaceable = isReplaceableEvent(event.kind) const isReplaceable = isReplaceableEvent(event.kind)
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
@ -76,7 +80,7 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) {
const newTags = bookmarkListEvent.tags.filter((tag) => const newTags = bookmarkListEvent.tags.filter((tag) =>
isReplaceable ? tag[0] !== 'a' || tag[1] !== eventKey : tag[0] !== 'e' || tag[1] !== eventKey isReplaceable ? tag[0] !== 'a' || tag[1] !== eventKey : tag[0] !== 'e' || tag[1] !== eventKey
) )
if (newTags.length === bookmarkListEvent.tags.length) return if (newTags.length === bookmarkListEvent.tags.length) return false
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content) const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content)
@ -86,13 +90,45 @@ export function BookmarksProvider({ children }: { children: React.ReactNode }) {
specifiedRelayUrls: comprehensiveRelays specifiedRelayUrls: comprehensiveRelays
}) })
await updateBookmarkListEvent(newBookmarkEvent) await updateBookmarkListEvent(newBookmarkEvent)
return true
}
const removeBookmarkByBech32 = async (bech32Id: string): Promise<boolean> => {
if (!accountPubkey) return false
const ref = decodePersonalListBech32Ref(bech32Id)
if (!ref) return false
const comprehensiveRelays = await buildComprehensiveRelayList()
let bookmarkListEvent =
(await fetchLatestReplaceableListEvent(accountPubkey, kinds.BookmarkList, comprehensiveRelays)) ?? null
if (!bookmarkListEvent) {
bookmarkListEvent = (await client.fetchBookmarkListEvent(accountPubkey)) ?? null
}
if (!bookmarkListEvent) return false
const newTags = bookmarkListTagsAfterRemovingRef(bookmarkListEvent.tags, ref)
if (!newTags) return false
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content)
logger.component('BookmarksProvider', 'Publishing bookmark list update (remove by bech32)', {
count: comprehensiveRelays.length
})
const newBookmarkEvent = await publish(newBookmarkDraftEvent, {
specifiedRelayUrls: comprehensiveRelays
})
await updateBookmarkListEvent(newBookmarkEvent)
return true
} }
return ( return (
<BookmarksContext.Provider <BookmarksContext.Provider
value={{ value={{
addBookmark, addBookmark,
removeBookmark removeBookmark,
removeBookmarkByBech32
}} }}
> >
{children} {children}

5
src/providers/bookmarks-context.tsx

@ -8,7 +8,10 @@ import { createContext, useContext } from 'react'
export type TBookmarksContext = { export type TBookmarksContext = {
addBookmark: (event: Event) => Promise<void> addBookmark: (event: Event) => Promise<void>
removeBookmark: (event: Event) => Promise<void> /** `true` if a new list event was published. */
removeBookmark: (event: Event) => Promise<boolean>
/** Remove by nevent / note / naddr id when the row has no loaded event (or as fallback). */
removeBookmarkByBech32: (bech32Id: string) => Promise<boolean>
} }
export const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined) export const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined)

Loading…
Cancel
Save