Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
a58224c55a
  1. 16
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  2. 13
      src/components/OthersRelayList/index.tsx
  3. 59
      src/components/ProfileOptions/index.tsx
  4. 66
      src/hooks/useFetchRelayList.tsx
  5. 6
      src/pages/secondary/OthersRelaySettingsPage/index.tsx
  6. 9
      src/pages/secondary/RelaySettingsPage/index.tsx
  7. 2
      src/providers/FavoriteRelaysProvider.tsx
  8. 19
      src/providers/NostrProvider/index.tsx
  9. 2
      src/providers/favorite-relays-context.tsx

16
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

@ -1,4 +1,5 @@
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@ -20,7 +21,9 @@ import RelayItem from './RelayItem'
export default function FavoriteRelayList() { export default function FavoriteRelayList() {
const { t } = useTranslation() const { t } = useTranslation()
const { favoriteRelays, blockedRelays, reorderFavoriteRelays } = useFavoriteRelays() const { pubkey } = useNostr()
const { favoriteRelays, blockedRelays, reorderFavoriteRelays, favoriteRelaysFromPublishedList } =
useFavoriteRelays()
// Show all relays including blocked ones (they'll be marked visually) // Show all relays including blocked ones (they'll be marked visually)
@ -46,6 +49,17 @@ export default function FavoriteRelayList() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div> <div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div>
{!!pubkey && !favoriteRelaysFromPublishedList && (
<p
className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
role="status"
>
{t('favoriteRelaysDefaultsBanner', {
defaultValue:
'No favorite-relays list (kind 10012) is loaded for this account yet. The relays below are app defaults and local relay sets, not a published list from your relays.'
})}
</p>
)}
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}

13
src/components/OthersRelayList/index.tsx

@ -11,7 +11,7 @@ import RelaySimpleInfo from '../RelaySimpleInfo'
export default function OthersRelayList({ userId }: { userId: string }) { export default function OthersRelayList({ userId }: { userId: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const pubkey = useMemo(() => userIdToPubkey(userId), [userId]) const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
const { relayList, isFetching } = useFetchRelayList(pubkey) const { relayList, isFetching, showingRelayListFallback } = useFetchRelayList(pubkey)
if (isFetching) { if (isFetching) {
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div> return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
@ -19,6 +19,17 @@ export default function OthersRelayList({ userId }: { userId: string }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{showingRelayListFallback && (
<p
className="rounded-md border border-amber-500/35 bg-amber-500/10 px-3 py-2 text-sm text-foreground"
role="status"
>
{t('othersRelayListKind10002Fallback', {
defaultValue:
'No NIP-65 relay list (kind 10002) was found for this user in local storage yet. The addresses below are default discovery relays, not this user’s published read/write list.'
})}
</p>
)}
{relayList.originalRelays.map((relay, index) => ( {relayList.originalRelays.map((relay, index) => (
<RelayItem key={`read-${relay.url}-${index}`} relay={relay} /> <RelayItem key={`read-${relay.url}-${index}`} relay={relay} />
))} ))}

59
src/components/ProfileOptions/index.tsx

@ -8,28 +8,27 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog' import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { import {
Bell, Bell,
BellOff, BellOff,
Code,
Copy, Copy,
Ellipsis, Ellipsis,
ThumbsUp, ThumbsUp,
MessageCircle, MessageCircle,
Send, Send,
Video,
SatelliteDish, SatelliteDish,
Code, Video,
LayoutGrid LayoutGrid
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
@ -40,7 +39,7 @@ import { useTranslation } from 'react-i18next'
import { useSmartProfileInteractionsNavigation } from '@/PageManager' import { useSmartProfileInteractionsNavigation } from '@/PageManager'
import { toProfileInteractionMap } from '@/lib/link' import { toProfileInteractionMap } from '@/lib/link'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
export default function ProfileOptions({ export default function ProfileOptions({
pubkey, pubkey,
@ -49,7 +48,7 @@ export default function ProfileOptions({
onSendCallInvite onSendCallInvite
}: { }: {
pubkey: string pubkey: string
/** Optional profile event (kind 0) for republishing and viewing JSON */ /** Optional profile event (kind 0): reply / like, republish to relays, view JSON */
profileEvent?: Event profileEvent?: Event
/** Opens the post editor in public message mode with this profile's pubkey in the mention list. */ /** Opens the post editor in public message mode with this profile's pubkey in the mention list. */
onSendPublicMessage?: () => void onSendPublicMessage?: () => void
@ -84,7 +83,7 @@ export default function ProfileOptions({
setLocalProfileEvent(event) setLocalProfileEvent(event)
} }
} catch (error) { } catch (error) {
// Silently fail - menu items just won't show // Silently fail: reply/like stay hidden until the event loads
} }
} }
@ -97,28 +96,32 @@ export default function ProfileOptions({
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ /** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => { const allAvailableRelayUrls = useMemo(() => {
const urls = [ const urls = [
...currentBrowsingRelayUrls.map(url => normalizeAnyRelayUrl(url) || url), ...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map(url => normalizeAnyRelayUrl(url) || url), ...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeAnyRelayUrl(url) || url)), ...relaySets.flatMap((set) => set.relayUrls.map((url) => normalizeAnyRelayUrl(url) || url)),
...FAST_READ_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url), ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map(url => normalizeAnyRelayUrl(url) || url) ...FAST_WRITE_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url)
].filter(Boolean) as string[] ].filter(Boolean) as string[]
return Array.from(new Set(urls)) return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) }, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
useEffect(() => { useEffect(() => {
nip66Service.getPublicLivelyRelayUrls().then((urls) => { void nip66Service.getPublicLivelyRelayUrls().then((urls) => {
setMonitoringListRelayCount(urls?.length ?? 0) setMonitoringListRelayCount(urls?.length ?? 0)
}) })
}, []) }, [])
const eventToUse = localProfileEvent || profileEvent
/** Kind 0 only; coerce `kind` in case deserialization yields a string. */
const kind0ForRelay =
eventToUse != null && Number(eventToUse.kind) === kinds.Metadata ? eventToUse : undefined
const handleRepublishToAllAvailable = async () => { const handleRepublishToAllAvailable = async () => {
const eventToPublish = localProfileEvent || profileEvent if (!kind0ForRelay) {
if (!eventToPublish) {
toast.error(t('Profile event not available')) toast.error(t('Profile event not available'))
return return
} }
const promise = client.publishEvent(allAvailableRelayUrls, eventToPublish).then((result) => { const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay).then((result) => {
if (result.successCount < 1) { if (result.successCount < 1) {
throw new Error(t('No relay accepted the event')) throw new Error(t('No relay accepted the event'))
} }
@ -132,8 +135,7 @@ export default function ProfileOptions({
} }
const handleRepublishToAllActive = async () => { const handleRepublishToAllActive = async () => {
const eventToPublish = localProfileEvent || profileEvent if (!kind0ForRelay) {
if (!eventToPublish) {
toast.error(t('Profile event not available')) toast.error(t('Profile event not available'))
return return
} }
@ -146,12 +148,14 @@ export default function ProfileOptions({
if (!relays?.length) { if (!relays?.length) {
throw new Error(t('No relays available')) throw new Error(t('No relays available'))
} }
const result = await client.publishEvent(relays, eventToPublish) const result = await client.publishEvent(relays, kind0ForRelay)
const minRequired = usedMonitoringList ? 5 : 1 const minRequired = usedMonitoringList ? 5 : 1
if (result.successCount < minRequired) { if (result.successCount < minRequired) {
throw new Error( throw new Error(
usedMonitoringList usedMonitoringList
? t('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', { count: result.successCount }) ? t('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', {
count: result.successCount
})
: t('No relay accepted the event') : t('No relay accepted the event')
) )
} }
@ -164,8 +168,6 @@ export default function ProfileOptions({
}) })
} }
const eventToUse = localProfileEvent || profileEvent
const handleLike = () => { const handleLike = () => {
if (!eventToUse) return if (!eventToUse) return
checkLogin(async () => { checkLogin(async () => {
@ -258,7 +260,7 @@ export default function ProfileOptions({
<Copy /> <Copy />
{t('Copy user ID')} {t('Copy user ID')}
</DropdownMenuItem> </DropdownMenuItem>
{(localProfileEvent || profileEvent) && ( {kind0ForRelay && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRepublishToAllAvailable}> <DropdownMenuItem onClick={handleRepublishToAllAvailable}>
@ -268,7 +270,8 @@ export default function ProfileOptions({
<DropdownMenuItem onClick={handleRepublishToAllActive}> <DropdownMenuItem onClick={handleRepublishToAllActive}>
<SatelliteDish /> <SatelliteDish />
{t('Republish to all active relays')} {t('Republish to all active relays')}
{monitoringListRelayCount !== null && ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`} {monitoringListRelayCount !== null &&
` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}> <DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code /> <Code />
@ -310,9 +313,9 @@ export default function ProfileOptions({
setOpen={setOpenReply} setOpen={setOpenReply}
/> />
)} )}
{(localProfileEvent || profileEvent) && ( {kind0ForRelay && (
<RawEventDialog <RawEventDialog
event={(localProfileEvent || profileEvent)!} event={kind0ForRelay}
isOpen={isRawEventDialogOpen} isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)} onClose={() => setIsRawEventDialogOpen(false)}
/> />

66
src/hooks/useFetchRelayList.tsx

@ -1,45 +1,87 @@
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { TRelayList } from '@/types' import { TRelayList } from '@/types'
import { kinds } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
export function useFetchRelayList(pubkey?: string | null) { const emptyRelayList = (): TRelayList => ({
const [relayList, setRelayList] = useState<TRelayList>({
write: [], write: [],
read: [], read: [],
originalRelays: [], originalRelays: [],
httpRead: [], httpRead: [],
httpWrite: [], httpWrite: [],
httpOriginalRelays: [] httpOriginalRelays: []
}) })
export function useFetchRelayList(pubkey?: string | null) {
const [relayList, setRelayList] = useState<TRelayList>(emptyRelayList)
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
/** True when IndexedDB has this author's kind 10002 (even if `originalRelays` is empty after merge). */
const [hasKind10002InStorage, setHasKind10002InStorage] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false
const targetPk = pubkey?.trim() || null
const fetchRelayList = async () => { const fetchRelayList = async () => {
setIsFetching(true) setIsFetching(true)
if (!pubkey) { setHasKind10002InStorage(false)
if (!targetPk) {
setRelayList(emptyRelayList())
setIsFetching(false) setIsFetching(false)
return return
} }
setRelayList(emptyRelayList())
try { try {
const fromStorage = await client.peekRelayListFromStorage(pubkey) const [fromStorage, k10002] = await Promise.all([
client.peekRelayListFromStorage(targetPk),
indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
])
if (cancelled) return
setHasKind10002InStorage(!!k10002)
setRelayList(fromStorage) setRelayList(fromStorage)
const relayList = await client.fetchRelayList(pubkey)
setRelayList(relayList) const merged = await client.fetchRelayList(targetPk)
if (cancelled) return
setRelayList(merged)
const k10002After = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
if (!cancelled) {
setHasKind10002InStorage(!!k10002After)
}
} catch (err) { } catch (err) {
logger.error('Failed to fetch relay list', { error: err, pubkey }) logger.error('Failed to fetch relay list', { error: err, pubkey: targetPk })
try { try {
setRelayList(await client.peekRelayListFromStorage(pubkey)) const fallback = await client.peekRelayListFromStorage(targetPk)
const k10002 = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
if (!cancelled) {
setRelayList(fallback)
setHasKind10002InStorage(!!k10002)
}
} catch { } catch {
/* keep last good state */ if (!cancelled) {
setRelayList(emptyRelayList())
}
} }
} finally { } finally {
if (!cancelled) {
setIsFetching(false) setIsFetching(false)
} }
} }
}
fetchRelayList() void fetchRelayList()
return () => {
cancelled = true
}
}, [pubkey]) }, [pubkey])
return { relayList, isFetching } const showingRelayListFallback =
!isFetching &&
!hasKind10002InStorage &&
relayList.originalRelays.length === 0
return { relayList, isFetching, hasKind10002InStorage, showingRelayListFallback }
} }

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

@ -53,6 +53,12 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
setJsonOpen(true) setJsonOpen(true)
}, [profile?.pubkey, relayList]) }, [profile?.pubkey, relayList])
useEffect(() => {
if (profile?.pubkey) {
setListKey((k) => k + 1)
}
}, [profile?.pubkey])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)

9
src/pages/secondary/RelaySettingsPage/index.tsx

@ -29,6 +29,13 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
const { account, relayList } = useNostr() const { account, relayList } = useNostr()
const [contentKey, setContentKey] = useState(0) const [contentKey, setContentKey] = useState(0)
const bump = useCallback(() => setContentKey((k) => k + 1), []) const bump = useCallback(() => setContentKey((k) => k + 1), [])
useEffect(() => {
if (account?.pubkey) {
setContentKey((k) => k + 1)
}
}, [account?.pubkey])
const [tabValue, setTabValue] = useState('favorite-relays') const [tabValue, setTabValue] = useState('favorite-relays')
const [jsonOpen, setJsonOpen] = useState(false) const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null) const [jsonPayload, setJsonPayload] = useState<unknown>(null)
@ -75,7 +82,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
setTabValue('cache-relays') setTabValue('cache-relays')
break break
} }
}, []) }, [account?.pubkey])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {

2
src/providers/FavoriteRelaysProvider.tsx

@ -310,6 +310,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
favoriteRelaysFromPublishedList: !!favoriteRelaysEvent,
favoriteRelays, favoriteRelays,
addFavoriteRelays, addFavoriteRelays,
deleteFavoriteRelays, deleteFavoriteRelays,
@ -325,6 +326,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
reorderRelaySets reorderRelaySets
}), }),
[ [
favoriteRelaysEvent,
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
relaySets, relaySets,

19
src/providers/NostrProvider/index.tsx

@ -462,6 +462,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
limit: 1 limit: 1
}) })
]) ])
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
}
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent
const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null const httpRelayListEventFetched = getLatestEvent(httpRelayListEvents) ?? storedHttpRelayListEvent ?? null
@ -487,6 +490,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
// Fetch updated relay list (merges 10002, 10432, 10243) // Fetch updated relay list (merges 10002, 10432, 10243)
const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
}
setRelayList(mergedRelayList) setRelayList(mergedRelayList)
const normalizedRelays = [ const normalizedRelays = [
@ -512,6 +518,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey] authors: [account.pubkey]
} }
]) ])
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
}
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata) const profileEvent = sortedEvents.find((e) => e.kind === kinds.Metadata)
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
@ -554,6 +563,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
safePutReplaceable(userEmojiListEvent) safePutReplaceable(userEmojiListEvent)
]) ])
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) {
return controller
}
if (profileEvent) { if (profileEvent) {
const resolvedProfileEvent = resolvedProfilePut ?? profileEvent const resolvedProfileEvent = resolvedProfilePut ?? profileEvent
try { try {
@ -639,7 +652,11 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
if (favoriteRelaysEvent) { if (favoriteRelaysEvent) {
if (resolvedFavoritePut && resolvedFavoritePut.id === favoriteRelaysEvent.id) { if (
hydrationGenForThisRun === accountHydrationGenerationRef.current &&
resolvedFavoritePut &&
resolvedFavoritePut.id === favoriteRelaysEvent.id
) {
setFavoriteRelaysEvent(favoriteRelaysEvent) setFavoriteRelaysEvent(favoriteRelaysEvent)
} }
} }

2
src/providers/favorite-relays-context.tsx

@ -8,6 +8,8 @@ import { Event } from 'nostr-tools'
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
export type TFavoriteRelaysContext = { export type TFavoriteRelaysContext = {
/** True when rows come from a published kind 10012 (favorite relays) event, not app defaults. */
favoriteRelaysFromPublishedList: boolean
favoriteRelays: string[] favoriteRelays: string[]
addFavoriteRelays: (relayUrls: string[]) => Promise<void> addFavoriteRelays: (relayUrls: string[]) => Promise<void>
deleteFavoriteRelays: (relayUrls: string[]) => Promise<void> deleteFavoriteRelays: (relayUrls: string[]) => Promise<void>

Loading…
Cancel
Save