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. 63
      src/components/NoteStats/index.tsx
  7. 41
      src/components/Profile/index.tsx
  8. 26
      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. 18
      src/providers/NostrProvider/index.tsx
  16. 4
      src/services/client-events.service.ts
  17. 43
      src/services/client-replaceable-events.service.ts
  18. 48
      src/services/client.service.ts

16
package-lock.json generated

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

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"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",
"private": true,
"type": "module",

51
src/components/Nip07ExtensionKeyMismatchToast/index.tsx

@ -0,0 +1,51 @@ @@ -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({ @@ -606,7 +606,7 @@ export default function Note({
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">
{isNip25ReactionKind(event.kind) ? (
<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({ @@ -39,9 +39,9 @@ export default function NoteInteractions({
return (
<>
<div className="flex items-center justify-between">
<div className="flex-1 w-0 min-w-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="flex flex-wrap items-center justify-between gap-2 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">
{t('Replies')}
</div>
</div>

63
src/components/NoteStats/index.tsx

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNearViewport } from '@/hooks/useNearViewport'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
@ -46,7 +45,6 @@ export default function NoteStats({ @@ -46,7 +45,6 @@ export default function NoteStats({
*/
useIconOnlyLikeTrigger?: boolean
}) {
const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id)
const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
@ -99,21 +97,8 @@ export default function NoteStats({ @@ -99,21 +97,8 @@ export default function NoteStats({
currentRelaysKey
])
if (isSmallScreen) {
return (
<div
ref={containerRef}
className={cn('select-none', className)}
data-note-stats
onClick={(e) => e.stopPropagation()}
>
<div
className={cn(
'flex justify-between items-center h-5 [&_svg]:size-5',
loading ? 'animate-pulse' : '',
classNames?.buttonBar
)}
>
const interactionButtons = (
<>
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
@ -128,43 +113,33 @@ export default function NoteStats({ @@ -128,43 +113,33 @@ export default function NoteStats({
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} />
</div>
</div>
</>
)
}
const utilityButtons = !isRssArticleRoot ? (
<>
<NotificationThreadWatchButtons event={event} />
<BookmarkButton event={event} />
</>
) : null
return (
<div
ref={containerRef}
className={cn('select-none', className)}
className={cn('select-none min-w-0', className)}
data-note-stats
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between h-5 [&_svg]:size-4">
<div
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
>
<ReplyButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
{!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
<RepostButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)}
<LikeButtonWithStats
event={event}
hideCount={hideInteractions}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/>
{!isRssArticleRoot && !isZapPoll && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
className={cn(
'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' : '',
classNames?.buttonBar
)}
</div>
<div className="flex items-center">
{!isRssArticleRoot && <NotificationThreadWatchButtons event={event} />}
{!isRssArticleRoot && <BookmarkButton event={event} />}
>
<div className="flex min-w-0 flex-wrap items-center">{interactionButtons}</div>
<div className="flex shrink-0 flex-wrap items-center">
{utilityButtons}
<SeenOnButton event={event} />
</div>
</div>

41
src/components/Profile/index.tsx

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

26
src/components/ui/sonner.tsx

@ -17,6 +17,29 @@ const Toaster = ({ ...props }: ToasterProps) => { @@ -17,6 +17,29 @@ const Toaster = ({ ...props }: ToasterProps) => {
if (!mounted) return null
return createPortal(
<>
<style>{`
/* Long messages + action/cancel buttons: keep text full-width (prevents 1-char-per-line strip). */
[data-sonner-toast]:not([data-styled='false']) {
flex-wrap: wrap !important;
width: min(22rem, calc(100vw - 2rem)) !important;
max-width: min(420px, calc(100vw - 2rem)) !important;
}
[data-sonner-toast]:not([data-styled='false']) [data-content] {
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"
@ -41,7 +64,8 @@ const Toaster = ({ ...props }: ToasterProps) => { @@ -41,7 +64,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
}
}}
{...props}
/>,
/>
</>,
document.body
)
}

5
src/constants.ts

@ -591,6 +591,11 @@ export const ExtendedKind = { @@ -591,6 +591,11 @@ export const ExtendedKind = {
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.
* 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 { @@ -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).",
"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.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.useExtensionIdentity": "Erweiterungs-Identität verwenden",
"nip07.switchedToExtensionIdentity": "Auf die aktuelle Identität Ihrer Erweiterung umgestellt.",

2
src/i18n/locales/en.ts

@ -1662,6 +1662,8 @@ export default { @@ -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).",
"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.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.useExtensionIdentity": "Use extension identity",
"nip07.switchedToExtensionIdentity": "Switched to your extension's current identity.",

2
src/layouts/SecondaryPageLayout/index.tsx

@ -182,7 +182,7 @@ function SecondaryPageTitlebar({ @@ -182,7 +182,7 @@ function SecondaryPageTitlebar({
<BackButton>{title}</BackButton>
</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}
{isSmallScreen ? <ActiveRelaysTitlebarButton /> : null}
</div>

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

@ -0,0 +1,28 @@ @@ -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' @@ -36,7 +36,7 @@ import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
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 { toast } from 'sonner'
@ -131,6 +131,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -131,6 +131,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<Array<{ type: string; authority: string }>>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
const savingPaymentInfoRef = useRef(false)
const [profileEventJson, setProfileEventJson] = useState<string>('')
const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false)
@ -234,19 +235,20 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -234,19 +235,20 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}, [paymentInfoEvent])
const savePaymentInfo = useCallback(async () => {
if (savingPaymentInfoRef.current) return
const tags: string[][] = paymentInfoEditMethods
.filter((m) => m.authority.trim())
.map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()])
savingPaymentInfoRef.current = true
setSavingPaymentInfo(true)
try {
const contentStr = paymentInfoEditContent.trim() || '{}'
try { JSON.parse(contentStr) } catch {
toast.error(t('Invalid content JSON'))
setSavingPaymentInfo(false)
return
}
const draft = createPaymentInfoDraftEvent(contentStr, tags)
const published = await publish(draft)
const published = await publish(draft, { skipCompanionPublish: true })
await client.updatePaymentInfoCache(published)
setPaymentInfoEvent(published)
setPaymentInfoEditOpen(false)
@ -254,6 +256,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -254,6 +256,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} catch {
toast.error(t('Failed to publish payment info'))
} finally {
savingPaymentInfoRef.current = false
setSavingPaymentInfo(false)
}
}, [paymentInfoEditContent, paymentInfoEditMethods, publish, t])
@ -287,13 +290,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -287,13 +290,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
if (!profile) {
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
variant="outline"
size="sm"
onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache}
className="gap-1.5"
className="gap-1.5 max-w-full"
>
{refreshingCache ? (
<Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden />
@ -459,13 +462,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -459,13 +462,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── 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
variant="outline"
size="sm"
onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache}
className="gap-1.5"
className="gap-1.5 max-w-full"
title={t('profileEditorRefreshCacheHint', {
defaultValue:
'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) => { @@ -478,7 +481,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
)}
{t('Refresh cache')}
</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')}
</Button>
</div>
@ -598,9 +601,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -598,9 +601,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
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 */}
<div className="flex-none w-28 shrink-0">
<div className="w-full shrink-0 sm:w-28 sm:flex-none">
{isKnown ? (
<p
className="text-xs font-medium text-muted-foreground pt-2 truncate"
@ -651,9 +654,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -651,9 +654,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
})}
{/* 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}>
<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 />
</SelectTrigger>
<SelectContent>
@ -723,8 +726,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -723,8 +726,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{/* ── Payment info (kind 10133) ── */}
<Item>
<div className="flex items-center justify-between gap-2">
<Label className="text-muted-foreground">{t('Payment info')} (kind 10133)</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-muted-foreground shrink-0">{t('Payment info')} (kind 10133)</Label>
<Button variant="outline" size="sm" onClick={openPaymentInfoEditor} className="shrink-0">
<Pencil className="h-3.5 w-3.5 mr-1" />
{paymentInfoEvent ? t('Edit payment info') : t('Add payment info')}
@ -916,7 +919,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -916,7 +919,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}>
{t('Cancel')}
</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 ? t('Saving…') : t('Save')}
</Button>
@ -1048,8 +1051,8 @@ function ProfileImageTagRow({ @@ -1048,8 +1051,8 @@ function ProfileImageTagRow({
}) {
const label = TAG_LABELS[tagName] || tagName
return (
<div className="flex gap-2 items-center">
<p className="flex-none w-28 text-xs font-medium text-muted-foreground truncate" title={label}>
<div className="flex flex-wrap gap-2 items-center min-w-0">
<p className="w-full shrink-0 text-xs font-medium text-muted-foreground truncate sm:w-28" title={label}>
{label}
</p>
<Input

18
src/providers/NostrProvider/index.tsx

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

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

@ -649,7 +649,9 @@ export class EventService { @@ -649,7 +649,9 @@ export class EventService {
// 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.
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)
) {
const coord = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(cleanEvent as NEvent))

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

@ -82,6 +82,8 @@ export class ReplaceableEventService { @@ -82,6 +82,8 @@ export class ReplaceableEventService {
max: 50,
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<
{ pubkey: string; kind: number },
NEvent | null,
@ -188,8 +190,8 @@ export class ReplaceableEventService { @@ -188,8 +190,8 @@ export class ReplaceableEventService {
}
}
// Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) {
// Kind 3 / NIP-65 / 10133: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList || kind === ExtendedKind.PAYMENT_INFO)) {
let idbEv: NEvent | undefined | null
try {
idbEv = await indexedDb.getReplaceableEvent(pubkey, kind, d)
@ -484,7 +486,13 @@ export class ReplaceableEventService { @@ -484,7 +486,13 @@ export class ReplaceableEventService {
for (let mi = missingParams.length - 1; mi >= 0; 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, {
kinds: [m.kind],
limit: 20
@ -619,8 +627,10 @@ export class ReplaceableEventService { @@ -619,8 +627,10 @@ export class ReplaceableEventService {
// (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
// 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 =
kind === kinds.Metadata ? false : !isSlowReplaceableBatch || !multiAuthorBatch
kind === kinds.Metadata || isSlowReplaceableBatch ? false : !multiAuthorBatch
const queryOpts = {
replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
@ -1343,13 +1353,14 @@ export class ReplaceableEventService { @@ -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> {
await Promise.all([
this.fetchReplaceableEvent(pubkey, kinds.Metadata),
this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO)
])
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return
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 { @@ -1381,6 +1392,20 @@ export class ReplaceableEventService {
const pk = pubkey.trim().toLowerCase()
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()
try {
let relayUrls: string[]

48
src/services/client.service.ts

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
FIRST_RELAY_RESULT_GRACE_MS,
HTTP_TIMELINE_POLL_INTERVAL_MS,
HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC,
isAuthorProfileMetadataPublishKind,
isDocumentRelayKind,
isSocialKindBlockedKind,
relayFilterIncludesDocumentRelayKind,
@ -35,6 +36,7 @@ import { @@ -35,6 +36,7 @@ import {
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { getCacheRelayUrls } from '@/lib/private-relays'
import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
@ -667,6 +669,32 @@ class ClientService extends EventTarget { @@ -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). */
private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise<string[]> {
try {
@ -682,13 +710,16 @@ class ClientService extends EventTarget { @@ -682,13 +710,16 @@ class ClientService extends EventTarget {
})
return []
}
const wsOut = (relayList?.write ?? [])
.map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u)
const httpOut = (relayList?.httpWrite ?? [])
const raw = isAuthorProfileMetadataPublishKind(event.kind)
? await this.resolveFullMailboxWriteUrlsForPublish(event.pubkey, relayList)
: dedupeNormalizeRelayUrlsOrdered([
...(relayList.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || u)
.filter((u): u is string => !!u),
...(relayList.write ?? [])
.map((u) => normalizeUrl(u) || u)
.filter((u): u is string => !!u)
const raw = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut])
])
return this.filterPublishingRelays(raw, event)
} catch {
return []
@ -1244,6 +1275,7 @@ class ClientService extends EventTarget { @@ -1244,6 +1275,7 @@ class ClientService extends EventTarget {
}
})
if (
isAuthorProfileMetadataPublishKind(event.kind) ||
[
kinds.RelayList,
ExtendedKind.CACHE_RELAYS,
@ -1256,7 +1288,7 @@ class ClientService extends EventTarget { @@ -1256,7 +1288,7 @@ class ClientService extends EventTarget {
bootstrapExtras.push(
...(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,
profileFetchRelays: useGlobalRelayDefaults
? PROFILE_RELAY_URLS
@ -1305,7 +1337,9 @@ class ClientService extends EventTarget { @@ -1305,7 +1337,9 @@ class ClientService extends EventTarget {
const httpWrites = (relayList?.httpWrite ?? [])
.map((u) => normalizeHttpRelayUrl(u) || 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(
buildPrioritizedWriteRelayUrls({
userWriteRelays: userWritesOrdered,

Loading…
Cancel
Save