Browse Source

fix payto bugs

fix reload red toast
imwald
Silberengel 4 weeks ago
parent
commit
bf31471f4c
  1. 16
      package-lock.json
  2. 2
      package.json
  3. 51
      src/components/Nip07ExtensionKeyMismatchToast/index.tsx
  4. 2
      src/components/Note/index.tsx
  5. 6
      src/components/NoteInteractions/index.tsx
  6. 101
      src/components/NoteStats/index.tsx
  7. 41
      src/components/Profile/index.tsx
  8. 72
      src/components/ui/sonner.tsx
  9. 5
      src/constants.ts
  10. 2
      src/i18n/locales/de.ts
  11. 2
      src/i18n/locales/en.ts
  12. 2
      src/layouts/SecondaryPageLayout/index.tsx
  13. 28
      src/lib/nip07-extension-key-mismatch-toast.tsx
  14. 37
      src/pages/secondary/ProfileEditorPage/index.tsx
  15. 20
      src/providers/NostrProvider/index.tsx
  16. 4
      src/services/client-events.service.ts
  17. 43
      src/services/client-replaceable-events.service.ts
  18. 52
      src/services/client.service.ts

16
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.12.0", "version": "23.12.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.12.0", "version": "23.12.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -8040,9 +8040,9 @@
"optional": true "optional": true
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
@ -17784,9 +17784,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.12.0", "version": "23.12.1",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -0,0 +1,51 @@
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function Nip07ExtensionKeyMismatchToast({
toastId,
onReload,
onUseExtensionIdentity
}: {
toastId: string | number
onReload: () => void
onUseExtensionIdentity: () => void
}) {
const { t } = useTranslation()
return (
<div
role="alert"
className="relative w-[min(22rem,calc(100vw-2rem))] max-w-[420px] rounded-lg border border-destructive/50 bg-background p-4 pr-10 text-foreground shadow-lg"
>
<button
type="button"
className="absolute right-2 top-2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={t('Close')}
onClick={() => toast.dismiss(toastId)}
>
<X className="size-4" aria-hidden />
</button>
<p className="text-sm font-semibold text-destructive">
{t('nip07.extensionKeyMismatchTitle', {
defaultValue: 'Extension key mismatch'
})}
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{t('nip07.extensionKeyMismatchBody', {
defaultValue:
'Your browser extension is using a different key than this tab. Switch keys in the extension, reload the page, or sign in with the extension’s current key.'
})}
</p>
<div className="mt-3 flex flex-col gap-2">
<Button type="button" size="sm" variant="secondary" className="w-full justify-center" onClick={onReload}>
{t('nip07.reloadPage')}
</Button>
<Button type="button" size="sm" className="w-full justify-center" onClick={onUseExtensionIdentity}>
{t('nip07.useExtensionIdentity')}
</Button>
</div>
</div>
)
}

2
src/components/Note/index.tsx

@ -606,7 +606,7 @@ export default function Note({
navigateToNote(toNote(event), event, getCachedThreadContextEvents(event)) navigateToNote(toNote(event), event, getCachedThreadContextEvents(event))
}} }}
> >
<div className="flex justify-between items-start gap-2"> <div className="flex flex-wrap justify-between items-start gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{isNip25ReactionKind(event.kind) ? ( {isNip25ReactionKind(event.kind) ? (
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2">

6
src/components/NoteInteractions/index.tsx

@ -39,9 +39,9 @@ export default function NoteInteractions({
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center justify-between gap-2 min-w-0">
<div className="flex-1 w-0 min-w-0"> <div className="min-w-0 flex-1 basis-full sm:basis-0">
<div className="py-2 px-2 sm:px-4 md:px-6 font-semibold text-xs sm:text-sm md:text-base text-foreground whitespace-nowrap"> <div className="py-2 px-2 sm:px-4 md:px-6 font-semibold text-xs sm:text-sm md:text-base text-foreground">
{t('Replies')} {t('Replies')}
</div> </div>
</div> </div>

101
src/components/NoteStats/index.tsx

@ -1,6 +1,5 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNearViewport } from '@/hooks/useNearViewport' import { useNearViewport } from '@/hooks/useNearViewport'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
@ -46,17 +45,16 @@ export default function NoteStats({
*/ */
useIconOnlyLikeTrigger?: boolean useIconOnlyLikeTrigger?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id) const noteStats = useNoteStatsById(event.id)
const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints() const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays() const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions // Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
// Hide interaction counts if event is in quiet mode // Hide interaction counts if event is in quiet mode
const hideInteractions = shouldHideInteractions(event) const hideInteractions = shouldHideInteractions(event)
@ -99,72 +97,49 @@ export default function NoteStats({
currentRelaysKey currentRelaysKey
]) ])
if (isSmallScreen) { const interactionButtons = (
return ( <>
<div <ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
ref={containerRef} {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
className={cn('select-none', className)} <RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
data-note-stats )}
onClick={(e) => e.stopPropagation()} <LikeButtonWithStats
> event={event}
<div hideCount={hideInteractions}
className={cn( noteStats={noteStats}
'flex justify-between items-center h-5 [&_svg]:size-5', isReplyToDiscussion={isReplyToDiscussion}
loading ? 'animate-pulse' : '', useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
classNames?.buttonBar />
)} {!isRssArticleRoot && !isZapPoll && (
> <ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> )}
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( </>
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> )
)}
<LikeButtonWithStats const utilityButtons = !isRssArticleRoot ? (
event={event} <>
hideCount={hideInteractions} <NotificationThreadWatchButtons event={event} />
noteStats={noteStats} <BookmarkButton event={event} />
isReplyToDiscussion={isReplyToDiscussion} </>
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger} ) : null
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} />
</div>
</div>
)
}
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn('select-none', className)} className={cn('select-none min-w-0', className)}
data-note-stats data-note-stats
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex justify-between h-5 [&_svg]:size-4"> <div
<div className={cn(
className={cn('flex items-center', loading ? 'animate-pulse' : '')} 'flex min-w-0 flex-wrap items-center justify-between gap-x-1 gap-y-2 [&_svg]:size-4 max-sm:[&_button]:pr-2',
> loading ? 'animate-pulse' : '',
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> classNames?.buttonBar
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && ( )}
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> >
)} <div className="flex min-w-0 flex-wrap items-center">{interactionButtons}</div>
<LikeButtonWithStats <div className="flex shrink-0 flex-wrap items-center">
event={event} {utilityButtons}
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
</div>
<div className="flex items-center">
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>
</div> </div>

41
src/components/Profile/index.tsx

@ -244,8 +244,10 @@ export default function Profile({
const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts') const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications' | 'liked'>('posts')
/** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */ /** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */
const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0) const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0)
const profilePubkeyRef = useRef<string | null>(null)
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
profilePubkeyRef.current = profile?.pubkey ?? null
const { pubkey: accountPubkey, publish, checkLogin } = useNostr() const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null) const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined) const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined)
@ -426,6 +428,12 @@ export default function Profile({
mediaFeedRef.current?.refresh() mediaFeedRef.current?.refresh()
publicationsFeedRef.current?.refresh() publicationsFeedRef.current?.refresh()
likedFeedRef.current?.refresh() likedFeedRef.current?.refresh()
const pk = profilePubkeyRef.current
if (pk) {
void client.refreshAuthorPublishedReplaceablesOnProfileView(pk).finally(() => {
setAuthorReplaceablesSyncGen((g) => g + 1)
})
}
} }
} }
return () => { return () => {
@ -557,7 +565,7 @@ export default function Profile({
)} )}
</div> </div>
<div className="px-4"> <div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center"> <div className="flex flex-wrap justify-end gap-2 items-center min-w-0">
<ProfileOptions <ProfileOptions
pubkey={pubkey} pubkey={pubkey}
profileEvent={profileEvent} profileEvent={profileEvent}
@ -672,8 +680,8 @@ export default function Profile({
) : null} ) : null}
</div> </div>
<div className="pt-2 md:pl-56"> <div className="pt-2 md:pl-56">
<div className="flex gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center min-w-0">
<div className="text-xl font-semibold truncate select-text">{username}</div> <div className="text-xl font-semibold truncate select-text max-w-full">{username}</div>
{isFollowingYou && ( {isFollowingYou && (
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0"> <div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
{t('Follows you')} {t('Follows you')}
@ -785,8 +793,8 @@ export default function Profile({
setOpen={setOpenZapDialog} setOpen={setOpenZapDialog}
pubkey={pubkey} pubkey={pubkey}
/> />
<div className="flex justify-between items-center mt-2 text-sm"> <div className="flex flex-wrap justify-between items-center gap-x-4 gap-y-2 mt-2 text-sm min-w-0">
<div className="flex gap-4 items-center"> <div className="flex flex-wrap gap-4 items-center min-w-0">
<SmartFollowings pubkey={pubkey} /> <SmartFollowings pubkey={pubkey} />
<SmartRelays pubkey={pubkey} /> <SmartRelays pubkey={pubkey} />
{isSelf && <SmartMuteLink />} {isSelf && <SmartMuteLink />}
@ -805,11 +813,24 @@ export default function Profile({
}} }}
className="min-w-0 pt-4" className="min-w-0 pt-4"
> >
<TabsList className="mb-2 ml-1 w-auto justify-start md:ml-4"> <TabsList className="mb-2 ml-1 h-auto min-h-9 w-full max-w-full justify-start flex-wrap gap-1 md:ml-4">
<TabsTrigger value="posts">{t('Posts')}</TabsTrigger> <TabsTrigger value="posts" className="shrink-0">
<TabsTrigger value="media">{t('Media')}</TabsTrigger> {t('Posts')}
<TabsTrigger value="publications">{t('Articles and Publications')}</TabsTrigger> </TabsTrigger>
{isSelf && <TabsTrigger value="liked">{t('Liked')}</TabsTrigger>} <TabsTrigger value="media" className="shrink-0">
{t('Media')}
</TabsTrigger>
<TabsTrigger
value="publications"
className="shrink whitespace-normal text-center leading-tight max-sm:px-2 max-sm:text-xs"
>
{t('Articles and Publications')}
</TabsTrigger>
{isSelf && (
<TabsTrigger value="liked" className="shrink-0">
{t('Liked')}
</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="posts" className="min-w-0 focus-visible:outline-none"> <TabsContent value="posts" className="min-w-0 focus-visible:outline-none">
<ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} /> <ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} />

72
src/components/ui/sonner.tsx

@ -17,31 +17,55 @@ const Toaster = ({ ...props }: ToasterProps) => {
if (!mounted) return null if (!mounted) return null
return createPortal( return createPortal(
<Sonner <>
theme={themeSetting} <style>{`
className="toaster group" /* Long messages + action/cancel buttons: keep text full-width (prevents 1-char-per-line strip). */
richColors [data-sonner-toast]:not([data-styled='false']) {
mobileOffset={64} flex-wrap: wrap !important;
style={ width: min(22rem, calc(100vw - 2rem)) !important;
{ max-width: min(420px, calc(100vw - 2rem)) !important;
'--width': '22rem',
zIndex: 9999
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg group-[.toaster]:min-w-[min(22rem,calc(100vw-2rem))]',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
},
style: {
maxWidth: 'min(420px, calc(100vw - 2rem))'
} }
}} [data-sonner-toast]:not([data-styled='false']) [data-content] {
{...props} flex: 1 1 100% !important;
/>, min-width: 0;
max-width: 100%;
}
[data-sonner-toast]:not([data-styled='false']) [data-title],
[data-sonner-toast]:not([data-styled='false']) [data-description] {
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
[data-sonner-toast]:not([data-styled='false']) [data-button] {
flex-shrink: 0;
}
`}</style>
<Sonner
theme={themeSetting}
className="toaster group"
richColors
mobileOffset={64}
style={
{
'--width': '22rem',
zIndex: 9999
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg group-[.toaster]:min-w-[min(22rem,calc(100vw-2rem))]',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
},
style: {
maxWidth: 'min(420px, calc(100vw - 2rem))'
}
}}
{...props}
/>
</>,
document.body document.body
) )
} }

5
src/constants.ts

@ -591,6 +591,11 @@ export const ExtendedKind = {
EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132 EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132
} }
/** Kind 0 + NIP-A3 payment: publish to profile mirrors, full outbox (NIP-65 + HTTP + cache), and IndexedDB. */
export function isAuthorProfileMetadataPublishKind(kind: number): boolean {
return kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
}
/** /**
* Relay-local experiment: event `id` is the standard Nostr hash, but `sig` is empty. * Relay-local experiment: event `id` is the standard Nostr hash, but `sig` is empty.
* Not verifiable on the public relay network; relays that accept writes should require NIP-42 AUTH first. * Not verifiable on the public relay network; relays that accept writes should require NIP-42 AUTH first.

2
src/i18n/locales/de.ts

@ -1615,6 +1615,8 @@ export default {
"Log in to run this spell (it uses $me or $contacts).": "Zum Ausführen anmelden (verwendet $me oder $contacts).", "Log in to run this spell (it uses $me or $contacts).": "Zum Ausführen anmelden (verwendet $me oder $contacts).",
"Login failed": "Login failed", "Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Ihre Browser-Erweiterung verwendet auf diesem Tab einen anderen Schlüssel. Wechseln Sie in der Erweiterung zum passenden Schlüssel, laden Sie die Seite neu, um die aktuelle Erweiterungsauswahl zu übernehmen, oder nutzen Sie die andere Aktion in dieser Meldung, um sich mit dem in der Erweiterung gewählten Schlüssel anzumelden.", "nip07.extensionKeyMismatch": "Ihre Browser-Erweiterung verwendet auf diesem Tab einen anderen Schlüssel. Wechseln Sie in der Erweiterung zum passenden Schlüssel, laden Sie die Seite neu, um die aktuelle Erweiterungsauswahl zu übernehmen, oder nutzen Sie die andere Aktion in dieser Meldung, um sich mit dem in der Erweiterung gewählten Schlüssel anzumelden.",
"nip07.extensionKeyMismatchTitle": "Erweiterungsschlüssel passt nicht",
"nip07.extensionKeyMismatchBody": "Die Erweiterung nutzt einen anderen Schlüssel als dieser Tab. Schlüssel in der Erweiterung wechseln, Seite neu laden oder mit dem aktuell in der Erweiterung gewählten Schlüssel anmelden.",
"nip07.reloadPage": "Seite neu laden", "nip07.reloadPage": "Seite neu laden",
"nip07.useExtensionIdentity": "Erweiterungs-Identität verwenden", "nip07.useExtensionIdentity": "Erweiterungs-Identität verwenden",
"nip07.switchedToExtensionIdentity": "Auf die aktuelle Identität Ihrer Erweiterung umgestellt.", "nip07.switchedToExtensionIdentity": "Auf die aktuelle Identität Ihrer Erweiterung umgestellt.",

2
src/i18n/locales/en.ts

@ -1662,6 +1662,8 @@ export default {
"Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).", "Log in to run this spell (it uses $me or $contacts).": "Log in to run this spell (it uses $me or $contacts).",
"Login failed": "Login failed", "Login failed": "Login failed",
"nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.", "nip07.extensionKeyMismatch": "Your browser extension is using a different key on this tab. Switch to the matching key in the extension, reload the page to apply your extension's current selection, or use the other action on this message to log in with the key currently selected in your extension.",
"nip07.extensionKeyMismatchTitle": "Extension key mismatch",
"nip07.extensionKeyMismatchBody": "Your browser extension is using a different key than this tab. Switch keys in the extension, reload the page, or sign in with the extension's current key.",
"nip07.reloadPage": "Reload page", "nip07.reloadPage": "Reload page",
"nip07.useExtensionIdentity": "Use extension identity", "nip07.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.", "nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",

2
src/layouts/SecondaryPageLayout/index.tsx

@ -182,7 +182,7 @@ function SecondaryPageTitlebar({
<BackButton>{title}</BackButton> <BackButton>{title}</BackButton>
</div> </div>
)} )}
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-0.5 min-w-0 max-w-[min(100%,14rem)] sm:max-w-none">
{controls} {controls}
{isSmallScreen ? <ActiveRelaysTitlebarButton /> : null} {isSmallScreen ? <ActiveRelaysTitlebarButton /> : null}
</div> </div>

28
src/lib/nip07-extension-key-mismatch-toast.tsx

@ -0,0 +1,28 @@
import { Nip07ExtensionKeyMismatchToast } from '@/components/Nip07ExtensionKeyMismatchToast'
import { toast } from 'sonner'
/** Stacked layout with dismiss — avoids Sonner squeezing long text beside action buttons. */
export function showNip07ExtensionKeyMismatchToast(opts: {
onReload: () => void
onUseExtensionIdentity: () => void
}): void {
toast.custom(
(id) => (
<Nip07ExtensionKeyMismatchToast
toastId={id}
onReload={() => {
toast.dismiss(id)
opts.onReload()
}}
onUseExtensionIdentity={() => {
toast.dismiss(id)
void opts.onUseExtensionIdentity()
}}
/>
),
{
duration: 35_000,
unstyled: true
}
)
}

37
src/pages/secondary/ProfileEditorPage/index.tsx

@ -36,7 +36,7 @@ import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -131,6 +131,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<Array<{ type: string; authority: string }>>([]) const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<Array<{ type: string; authority: string }>>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false) const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false) const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
const savingPaymentInfoRef = useRef(false)
const [profileEventJson, setProfileEventJson] = useState<string>('') const [profileEventJson, setProfileEventJson] = useState<string>('')
const [savingFullProfile, setSavingFullProfile] = useState(false) const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false) const [refreshingCache, setRefreshingCache] = useState(false)
@ -234,19 +235,20 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}, [paymentInfoEvent]) }, [paymentInfoEvent])
const savePaymentInfo = useCallback(async () => { const savePaymentInfo = useCallback(async () => {
if (savingPaymentInfoRef.current) return
const tags: string[][] = paymentInfoEditMethods const tags: string[][] = paymentInfoEditMethods
.filter((m) => m.authority.trim()) .filter((m) => m.authority.trim())
.map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()]) .map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()])
savingPaymentInfoRef.current = true
setSavingPaymentInfo(true) setSavingPaymentInfo(true)
try { try {
const contentStr = paymentInfoEditContent.trim() || '{}' const contentStr = paymentInfoEditContent.trim() || '{}'
try { JSON.parse(contentStr) } catch { try { JSON.parse(contentStr) } catch {
toast.error(t('Invalid content JSON')) toast.error(t('Invalid content JSON'))
setSavingPaymentInfo(false)
return return
} }
const draft = createPaymentInfoDraftEvent(contentStr, tags) const draft = createPaymentInfoDraftEvent(contentStr, tags)
const published = await publish(draft) const published = await publish(draft, { skipCompanionPublish: true })
await client.updatePaymentInfoCache(published) await client.updatePaymentInfoCache(published)
setPaymentInfoEvent(published) setPaymentInfoEvent(published)
setPaymentInfoEditOpen(false) setPaymentInfoEditOpen(false)
@ -254,6 +256,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} catch { } catch {
toast.error(t('Failed to publish payment info')) toast.error(t('Failed to publish payment info'))
} finally { } finally {
savingPaymentInfoRef.current = false
setSavingPaymentInfo(false) setSavingPaymentInfo(false)
} }
}, [paymentInfoEditContent, paymentInfoEditMethods, publish, t]) }, [paymentInfoEditContent, paymentInfoEditMethods, publish, t])
@ -287,13 +290,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
if (!profile) { if (!profile) {
const loadingControls = ( const loadingControls = (
<div className="pr-3 flex items-center gap-2"> <div className="pr-3 flex flex-wrap items-center justify-end gap-2 min-w-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={forceRefreshProfileAndPaymentCache} onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache} disabled={refreshingCache}
className="gap-1.5" className="gap-1.5 max-w-full"
> >
{refreshingCache ? ( {refreshingCache ? (
<Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> <Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden />
@ -459,13 +462,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Controls ───────────────────────────────────────────────────────────────── // ─── Controls ─────────────────────────────────────────────────────────────────
const controls = ( const controls = (
<div className="pr-3 flex items-center gap-2"> <div className="pr-3 flex flex-wrap items-center justify-end gap-2 min-w-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={forceRefreshProfileAndPaymentCache} onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache} disabled={refreshingCache}
className="gap-1.5" className="gap-1.5 max-w-full"
title={t('profileEditorRefreshCacheHint', { title={t('profileEditorRefreshCacheHint', {
defaultValue: defaultValue:
'Full account sync from relays (like Settings → Cache), deletion tombstones, then profile and payment info.' 'Full account sync from relays (like Settings → Cache), deletion tombstones, then profile and payment info.'
@ -478,7 +481,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
)} )}
{t('Refresh cache')} {t('Refresh cache')}
</Button> </Button>
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}> <Button className="min-w-16 shrink-0 rounded-full" onClick={save} disabled={saving || !hasChanged}>
{saving ? <Skeleton className="mx-auto h-4 w-12 rounded-md" aria-hidden /> : t('Save')} {saving ? <Skeleton className="mx-auto h-4 w-12 rounded-md" aria-hidden /> : t('Save')}
</Button> </Button>
</div> </div>
@ -598,9 +601,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
return ( return (
<div key={idx} className="flex gap-2 items-start"> <div key={idx} className="flex flex-wrap gap-2 items-start min-w-0">
{/* Tag name: fixed label for known, editable input for custom */} {/* Tag name: fixed label for known, editable input for custom */}
<div className="flex-none w-28 shrink-0"> <div className="w-full shrink-0 sm:w-28 sm:flex-none">
{isKnown ? ( {isKnown ? (
<p <p
className="text-xs font-medium text-muted-foreground pt-2 truncate" className="text-xs font-medium text-muted-foreground pt-2 truncate"
@ -651,9 +654,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
})} })}
{/* Add-tag row: dropdown + single + button */} {/* Add-tag row: dropdown + single + button */}
<div className="flex gap-2 pt-1 items-center"> <div className="flex flex-wrap gap-2 pt-1 items-center min-w-0">
<Select value={tagToAdd} onValueChange={setTagToAdd}> <Select value={tagToAdd} onValueChange={setTagToAdd}>
<SelectTrigger className="flex-1 h-8 text-sm"> <SelectTrigger className="min-w-0 flex-1 basis-full h-8 text-sm sm:basis-0">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -723,8 +726,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{/* ── Payment info (kind 10133) ── */} {/* ── Payment info (kind 10133) ── */}
<Item> <Item>
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-muted-foreground">{t('Payment info')} (kind 10133)</Label> <Label className="text-muted-foreground shrink-0">{t('Payment info')} (kind 10133)</Label>
<Button variant="outline" size="sm" onClick={openPaymentInfoEditor} className="shrink-0"> <Button variant="outline" size="sm" onClick={openPaymentInfoEditor} className="shrink-0">
<Pencil className="h-3.5 w-3.5 mr-1" /> <Pencil className="h-3.5 w-3.5 mr-1" />
{paymentInfoEvent ? t('Edit payment info') : t('Add payment info')} {paymentInfoEvent ? t('Edit payment info') : t('Add payment info')}
@ -916,7 +919,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}> <Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2"> <Button type="button" onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2">
{savingPaymentInfo && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />} {savingPaymentInfo && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />}
{savingPaymentInfo ? t('Saving…') : t('Save')} {savingPaymentInfo ? t('Saving…') : t('Save')}
</Button> </Button>
@ -1048,8 +1051,8 @@ function ProfileImageTagRow({
}) { }) {
const label = TAG_LABELS[tagName] || tagName const label = TAG_LABELS[tagName] || tagName
return ( return (
<div className="flex gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center min-w-0">
<p className="flex-none w-28 text-xs font-medium text-muted-foreground truncate" title={label}> <p className="w-full shrink-0 text-xs font-medium text-muted-foreground truncate sm:w-28" title={label}>
{label} {label}
</p> </p>
<Input <Input

20
src/providers/NostrProvider/index.tsx

@ -60,6 +60,7 @@ import { NostrContext, type TNostrContext } from '@/providers/nostr-context'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEventCallback } from '@/hooks/use-event-callback' import { useEventCallback } from '@/hooks/use-event-callback'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { showNip07ExtensionKeyMismatchToast } from '@/lib/nip07-extension-key-mismatch-toast'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
import { Nip07Signer } from './nip-07.signer' import { Nip07Signer } from './nip-07.signer'
@ -1002,6 +1003,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (httpRel) setHttpRelayListEvent(httpRel) if (httpRel) setHttpRelayListEvent(httpRel)
const blossom = await loadOk(ExtendedKind.BLOSSOM_SERVER_LIST) const blossom = await loadOk(ExtendedKind.BLOSSOM_SERVER_LIST)
if (blossom) void client.updateBlossomServerListEventCache(blossom) if (blossom) void client.updateBlossomServerListEventCache(blossom)
const payment = await loadOk(ExtendedKind.PAYMENT_INFO)
if (payment) {
void replaceableEventService.updateReplaceableEventCache(payment).catch(() => {})
}
const merged = await client.fetchRelayList(acc.pubkey) const merged = await client.fetchRelayList(acc.pubkey)
setRelayList(merged) setRelayList(merged)
@ -1408,17 +1413,13 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const fireNip07ExtensionKeyMismatchToast = useCallback(() => { const fireNip07ExtensionKeyMismatchToast = useCallback(() => {
if (nip07KeyMismatchToastShownRef.current) return if (nip07KeyMismatchToastShownRef.current) return
nip07KeyMismatchToastShownRef.current = true nip07KeyMismatchToastShownRef.current = true
toast.error(t('nip07.extensionKeyMismatch'), { showNip07ExtensionKeyMismatchToast({
duration: 35_000, onReload: () => window.location.reload(),
action: { label: t('nip07.reloadPage'), onClick: () => window.location.reload() }, onUseExtensionIdentity: () => {
cancel: { void adoptCurrentExtensionNip07Identity()
label: t('nip07.useExtensionIdentity'),
onClick: () => {
void adoptCurrentExtensionNip07Identity()
}
} }
}) })
}, [t, adoptCurrentExtensionNip07Identity]) }, [adoptCurrentExtensionNip07Identity])
/** /**
* If session restore temporarily fell back to read-only (`npub`) while the stored * If session restore temporarily fell back to read-only (`npub`) while the stored
@ -1842,6 +1843,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} catch (e) { } catch (e) {
logger.warn('[NostrProvider] updateProfileEvent: putReplaceableEvent failed', { error: e }) logger.warn('[NostrProvider] updateProfileEvent: putReplaceableEvent failed', { error: e })
} }
void replaceableEventService.updateReplaceableEventCache(profileEvent).catch(() => {})
// Always apply the just-published event to state regardless of IDB's newer-wins result, // Always apply the just-published event to state regardless of IDB's newer-wins result,
// so the UI is never left showing a stale event that IDB preferred over what we just saved. // so the UI is never left showing a stale event that IDB preferred over what we just saved.
setProfileEvent(profileEvent) setProfileEvent(profileEvent)

4
src/services/client-events.service.ts

@ -649,7 +649,9 @@ export class EventService {
// NIP-65 (10002) and contacts (3) are not “document” replaceables; without this they never hit IndexedDB // NIP-65 (10002) and contacts (3) are not “document” replaceables; without this they never hit IndexedDB
// from timeline/REQ ingest—only the logged-in account’s list was hydrated in NostrProvider / prewarm. // from timeline/REQ ingest—only the logged-in account’s list was hydrated in NostrProvider / prewarm.
if ( if (
(cleanEvent.kind === kinds.RelayList || cleanEvent.kind === kinds.Contacts) && (cleanEvent.kind === kinds.RelayList ||
cleanEvent.kind === kinds.Contacts ||
cleanEvent.kind === ExtendedKind.PAYMENT_INFO) &&
indexedDb.hasReplaceableEventStoreForKind(cleanEvent.kind) indexedDb.hasReplaceableEventStoreForKind(cleanEvent.kind)
) { ) {
const coord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent)) const coord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent))

43
src/services/client-replaceable-events.service.ts

@ -82,6 +82,8 @@ export class ReplaceableEventService {
max: 50, max: 50,
ttl: 1000 * 60 * 60 ttl: 1000 * 60 * 60
}) })
/** One in-flight profile replaceables pull per author (avoids stacked REQs when profile UI remounts). */
private authorReplaceablesRefreshByPubkey = new Map<string, Promise<void>>()
private replaceableEventFromBigRelaysDataloader: DataLoader< private replaceableEventFromBigRelaysDataloader: DataLoader<
{ pubkey: string; kind: number }, { pubkey: string; kind: number },
NEvent | null, NEvent | null,
@ -188,8 +190,8 @@ export class ReplaceableEventService {
} }
} }
// Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh. // Kind 3 / NIP-65 / 10133: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) { if (!d && (kind === kinds.Contacts || kind === kinds.RelayList || kind === ExtendedKind.PAYMENT_INFO)) {
let idbEv: NEvent | undefined | null let idbEv: NEvent | undefined | null
try { try {
idbEv = await indexedDb.getReplaceableEvent(pubkey, kind, d) idbEv = await indexedDb.getReplaceableEvent(pubkey, kind, d)
@ -484,7 +486,13 @@ export class ReplaceableEventService {
for (let mi = missingParams.length - 1; mi >= 0; mi--) { for (let mi = missingParams.length - 1; mi >= 0; mi--) {
const m = missingParams[mi]! const m = missingParams[mi]!
if (m.kind !== kinds.Contacts && m.kind !== kinds.RelayList) continue if (
m.kind !== kinds.Contacts &&
m.kind !== kinds.RelayList &&
m.kind !== ExtendedKind.PAYMENT_INFO
) {
continue
}
const hits = client.eventService.listSessionEventsAuthoredBy(m.pubkey, { const hits = client.eventService.listSessionEventsAuthoredBy(m.pubkey, {
kinds: [m.kind], kinds: [m.kind],
limit: 20 limit: 20
@ -619,8 +627,10 @@ export class ReplaceableEventService {
// (many `authors` in one filter) that stops the subscription while most profiles are still in flight. // (many `authors` in one filter) that stops the subscription while most profiles are still in flight.
// Kind 0: never race — first relay may answer without Damus/mirrors; wait for EOSE window so the // Kind 0: never race — first relay may answer without Damus/mirrors; wait for EOSE window so the
// newest metadata across relays is collected (same as multi-author batches). // newest metadata across relays is collected (same as multi-author batches).
// Slow replaceables (10133 payment, pins, contacts, …): never race — a single-author fetch used to
// set replaceableRace=true and close after 100ms EOSE, missing events on profile mirrors.
const useReplaceableRace = const useReplaceableRace =
kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !multiAuthorBatch kind === kinds.Metadata || isSlowReplaceableBatch ? false : !multiAuthorBatch
const queryOpts = { const queryOpts = {
replaceableRace: useReplaceableRace, replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
@ -1343,13 +1353,14 @@ export class ReplaceableEventService {
} }
/** /**
* Force refresh profile and payment info cache * Force refresh profile and payment info: clear in-memory loaders, pull from relays (incl. 10133), persist to IndexedDB.
*/ */
async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise<void> { async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise<void> {
await Promise.all([ const pk = pubkey.trim().toLowerCase()
this.fetchReplaceableEvent(pubkey, kinds.Metadata), if (!/^[0-9a-f]{64}$/.test(pk)) return
this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO) this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: kinds.Metadata })
]) this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: ExtendedKind.PAYMENT_INFO })
await this.refreshAuthorPublishedReplaceablesFromRelays(pk)
} }
/** /**
@ -1381,6 +1392,20 @@ export class ReplaceableEventService {
const pk = pubkey.trim().toLowerCase() const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return if (!/^[0-9a-f]{64}$/.test(pk)) return
const inFlight = this.authorReplaceablesRefreshByPubkey.get(pk)
if (inFlight) return inFlight
const run = this.refreshAuthorPublishedReplaceablesFromRelaysBody(pk)
this.authorReplaceablesRefreshByPubkey.set(pk, run)
void run.finally(() => {
if (this.authorReplaceablesRefreshByPubkey.get(pk) === run) {
this.authorReplaceablesRefreshByPubkey.delete(pk)
}
})
return run
}
private async refreshAuthorPublishedReplaceablesFromRelaysBody(pk: string): Promise<void> {
await ReplaceableEventService.acquireProfileFallbackNetworkSlot() await ReplaceableEventService.acquireProfileFallbackNetworkSlot()
try { try {
let relayUrls: string[] let relayUrls: string[]

52
src/services/client.service.ts

@ -6,6 +6,7 @@ import {
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
HTTP_TIMELINE_POLL_INTERVAL_MS, HTTP_TIMELINE_POLL_INTERVAL_MS,
HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC, HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC,
isAuthorProfileMetadataPublishKind,
isDocumentRelayKind, isDocumentRelayKind,
isSocialKindBlockedKind, isSocialKindBlockedKind,
relayFilterIncludesDocumentRelayKind, relayFilterIncludesDocumentRelayKind,
@ -35,6 +36,7 @@ import {
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { getCacheRelayUrls } from '@/lib/private-relays'
import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
@ -667,6 +669,32 @@ class ClientService extends EventTarget {
) )
} }
/**
* Author kind 0 / 10133 publish: NIP-65 WS outbox + HTTP write (10243) + cache relays (10432).
* {@link fetchRelayList} usually merges cache into `write`; this also appends 10432 tags when missing.
*/
private async resolveFullMailboxWriteUrlsForPublish(
pubkey: string,
relayList: TRelayList
): Promise<string[]> {
const ws = (relayList.write ?? [])
.map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u)
const http = (relayList.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u)
let merged = dedupeNormalizeRelayUrlsOrdered([...http, ...ws])
try {
const cache = await getCacheRelayUrls(pubkey)
if (cache.length > 0) {
merged = dedupeNormalizeRelayUrlsOrdered([...merged, ...cache])
}
} catch {
/* ignore */
}
return merged
}
/** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */ /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / social-kind blocks). */
private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> { private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> {
try { try {
@ -682,13 +710,16 @@ class ClientService extends EventTarget {
}) })
return [] return []
} }
const wsOut = (relayList?.write ?? []) const raw = isAuthorProfileMetadataPublishKind(event.kind)
.map((u) => normalizeUrl(u) || u) ? await this.resolveFullMailboxWriteUrlsForPublish(event.pubkey, relayList)
.filter((u): u is string => !!u) : dedupeNormalizeRelayUrlsOrdered([
const httpOut = (relayList?.httpWrite ?? []) ...(relayList.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u) .map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u) .filter((u): u is string => !!u),
const raw = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut]) ...(relayList.write ?? [])
.map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u)
])
return this.filterPublishingRelays(raw, event) return this.filterPublishingRelays(raw, event)
} catch { } catch {
return [] return []
@ -1244,6 +1275,7 @@ class ClientService extends EventTarget {
} }
}) })
if ( if (
isAuthorProfileMetadataPublishKind(event.kind) ||
[ [
kinds.RelayList, kinds.RelayList,
ExtendedKind.CACHE_RELAYS, ExtendedKind.CACHE_RELAYS,
@ -1256,7 +1288,7 @@ class ClientService extends EventTarget {
bootstrapExtras.push( bootstrapExtras.push(
...(useGlobalRelayDefaults ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer()) ...(useGlobalRelayDefaults ? PROFILE_RELAY_URLS : profileFetchRelayUrlsWithoutFastReadLayer())
) )
logger.debug('[DetermineTargetRelays] Relay list event detected, adding PROFILE_RELAY_URLS', { logger.debug('[DetermineTargetRelays] Profile / list event: adding profile-fetch relays', {
kind: event.kind, kind: event.kind,
profileFetchRelays: useGlobalRelayDefaults profileFetchRelays: useGlobalRelayDefaults
? PROFILE_RELAY_URLS ? PROFILE_RELAY_URLS
@ -1305,7 +1337,9 @@ class ClientService extends EventTarget {
const httpWrites = (relayList?.httpWrite ?? []) const httpWrites = (relayList?.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u) .map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u) .filter((u): u is string => !!u)
const userWritesOrdered = dedupeNormalizeRelayUrlsOrdered([...httpWrites, ...wsWrites]) const userWritesOrdered = isAuthorProfileMetadataPublishKind(event.kind)
? await this.resolveFullMailboxWriteUrlsForPublish(event.pubkey, relayList ?? this.emptyRelayListForPublish())
: dedupeNormalizeRelayUrlsOrdered([...httpWrites, ...wsWrites])
relays = this.filterPublishingRelays( relays = this.filterPublishingRelays(
buildPrioritizedWriteRelayUrls({ buildPrioritizedWriteRelayUrls({
userWriteRelays: userWritesOrdered, userWriteRelays: userWritesOrdered,

Loading…
Cancel
Save