diff --git a/package-lock.json b/package-lock.json
index 61607f25..ff820ef0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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 @@
"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 @@
"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": {
diff --git a/package.json b/package.json
index ccc974b5..f036f0ba 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Nip07ExtensionKeyMismatchToast/index.tsx b/src/components/Nip07ExtensionKeyMismatchToast/index.tsx
new file mode 100644
index 00000000..e58745ba
--- /dev/null
+++ b/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 (
+
+
toast.dismiss(toastId)}
+ >
+
+
+
+ {t('nip07.extensionKeyMismatchTitle', {
+ defaultValue: 'Extension key mismatch'
+ })}
+
+
+ {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.'
+ })}
+
+
+
+ {t('nip07.reloadPage')}
+
+
+ {t('nip07.useExtensionIdentity')}
+
+
+
+ )
+}
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 1cb78336..7d8e826a 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -606,7 +606,7 @@ export default function Note({
navigateToNote(toNote(event), event, getCachedThreadContextEvents(event))
}}
>
-
+
{isNip25ReactionKind(event.kind) ? (
diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx
index 549a0e34..886eb6dc 100644
--- a/src/components/NoteInteractions/index.tsx
+++ b/src/components/NoteInteractions/index.tsx
@@ -39,9 +39,9 @@ export default function NoteInteractions({
return (
<>
-
-
-
+
+
diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx
index dd27ff9c..f72915a1 100644
--- a/src/components/NoteStats/index.tsx
+++ b/src/components/NoteStats/index.tsx
@@ -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,17 +45,16 @@ export default function NoteStats({
*/
useIconOnlyLikeTrigger?: boolean
}) {
- const { isSmallScreen } = useScreenSize()
const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id)
const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false)
-
+
// Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
-
+
// Hide interaction counts if event is in quiet mode
const hideInteractions = shouldHideInteractions(event)
@@ -99,72 +97,49 @@ export default function NoteStats({
currentRelaysKey
])
- if (isSmallScreen) {
- return (
-
e.stopPropagation()}
- >
-
-
- {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
-
- )}
-
- {!isRssArticleRoot && !isZapPoll && (
-
- )}
- {!isRssArticleRoot && }
- {!isRssArticleRoot && }
-
-
-
- )
- }
+ const interactionButtons = (
+ <>
+
+ {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
+
+ )}
+
+ {!isRssArticleRoot && !isZapPoll && (
+
+ )}
+ >
+ )
+
+ const utilityButtons = !isRssArticleRoot ? (
+ <>
+
+
+ >
+ ) : null
return (
e.stopPropagation()}
>
-
-
-
- {!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot && (
-
- )}
-
- {!isRssArticleRoot && !isZapPoll && (
-
- )}
-
-
- {!isRssArticleRoot &&
}
- {!isRssArticleRoot &&
}
+
+
{interactionButtons}
+
+ {utilityButtons}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 818db316..624c3091 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -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
(null)
const { profile, isFetching } = useFetchProfile(id)
+ profilePubkeyRef.current = profile?.pubkey ?? null
const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
const [paymentInfo, setPaymentInfo] = useState | null>(null)
const [profileEvent, setProfileEvent] = useState(undefined)
@@ -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({
)}
-
+
-
-
{username}
+
+
{username}
{isFollowingYou && (
{t('Follows you')}
@@ -785,8 +793,8 @@ export default function Profile({
setOpen={setOpenZapDialog}
pubkey={pubkey}
/>
-
-
+
+
{isSelf &&
}
@@ -805,11 +813,24 @@ export default function Profile({
}}
className="min-w-0 pt-4"
>
-
- {t('Posts')}
- {t('Media')}
- {t('Articles and Publications')}
- {isSelf && {t('Liked')} }
+
+
+ {t('Posts')}
+
+
+ {t('Media')}
+
+
+ {t('Articles and Publications')}
+
+ {isSelf && (
+
+ {t('Liked')}
+
+ )}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
index f69bcfd6..98dbc868 100644
--- a/src/components/ui/sonner.tsx
+++ b/src/components/ui/sonner.tsx
@@ -17,31 +17,55 @@ const Toaster = ({ ...props }: ToasterProps) => {
if (!mounted) return null
return createPortal(
-
+
+
+ >,
document.body
)
}
diff --git a/src/constants.ts b/src/constants.ts
index c3ca9c46..7cbf4944 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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.
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 23e273b1..9617ee26 100644
--- a/src/i18n/locales/de.ts
+++ b/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).",
"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.",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index a3911965..ba981555 100644
--- a/src/i18n/locales/en.ts
+++ b/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).",
"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.",
diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx
index 9fce53d8..8561d3d1 100644
--- a/src/layouts/SecondaryPageLayout/index.tsx
+++ b/src/layouts/SecondaryPageLayout/index.tsx
@@ -182,7 +182,7 @@ function SecondaryPageTitlebar({
{title}
)}
-
+
{controls}
{isSmallScreen ?
: null}
diff --git a/src/lib/nip07-extension-key-mismatch-toast.tsx b/src/lib/nip07-extension-key-mismatch-toast.tsx
new file mode 100644
index 00000000..45d07a7a
--- /dev/null
+++ b/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) => (
+
{
+ toast.dismiss(id)
+ opts.onReload()
+ }}
+ onUseExtensionIdentity={() => {
+ toast.dismiss(id)
+ void opts.onUseExtensionIdentity()
+ }}
+ />
+ ),
+ {
+ duration: 35_000,
+ unstyled: true
+ }
+ )
+}
diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx
index b94c49cc..06af2006 100644
--- a/src/pages/secondary/ProfileEditorPage/index.tsx
+++ b/src/pages/secondary/ProfileEditorPage/index.tsx
@@ -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) => {
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState>([])
const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false)
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
+ const savingPaymentInfoRef = useRef(false)
const [profileEventJson, setProfileEventJson] = useState('')
const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false)
@@ -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) => {
} 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) => {
if (!profile) {
const loadingControls = (
-
+
{refreshingCache ? (
@@ -459,13 +462,13 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Controls ─────────────────────────────────────────────────────────────────
const controls = (
-
+
{
)}
{t('Refresh cache')}
-
+
{saving ? : t('Save')}
@@ -598,9 +601,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
return (
-
+
{/* Tag name: fixed label for known, editable input for custom */}
-
+
{isKnown ? (
{
})}
{/* Add-tag row: dropdown + single + button */}
-
+
-
+
@@ -723,8 +726,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{/* ── Payment info (kind 10133) ── */}
-
-
-
{t('Payment info')} (kind 10133)
+
+
{t('Payment info')} (kind 10133)
{paymentInfoEvent ? t('Edit payment info') : t('Add payment info')}
@@ -916,7 +919,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setPaymentInfoEditOpen(false)}>
{t('Cancel')}
-
+
{savingPaymentInfo && }
{savingPaymentInfo ? t('Saving…') : t('Save')}
@@ -1048,8 +1051,8 @@ function ProfileImageTagRow({
}) {
const label = TAG_LABELS[tagName] || tagName
return (
-
-
+
+
{label}
{})
+ }
const merged = await client.fetchRelayList(acc.pubkey)
setRelayList(merged)
@@ -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: () => {
- void adoptCurrentExtensionNip07Identity()
- }
+ 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 }) {
} 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)
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index e188fb3d..e10bf36c 100644
--- a/src/services/client-events.service.ts
+++ b/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
// 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))
diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts
index 1a217507..d1b14281 100644
--- a/src/services/client-replaceable-events.service.ts
+++ b/src/services/client-replaceable-events.service.ts
@@ -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
>()
private replaceableEventFromBigRelaysDataloader: DataLoader<
{ pubkey: string; kind: number },
NEvent | null,
@@ -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 {
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 {
// (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 {
}
/**
- * 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 {
- 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 {
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 {
await ReplaceableEventService.acquireProfileFallbackNetworkSlot()
try {
let relayUrls: string[]
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 0b34b6ca..d33f973d 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -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 {
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 {
)
}
+ /**
+ * 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 {
+ 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 {
try {
@@ -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 ?? [])
- .map((u) => normalizeHttpRelayUrl(u) || u)
- .filter((u): u is string => !!u)
- const raw = dedupeNormalizeRelayUrlsOrdered([...httpOut, ...wsOut])
+ 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)
+ ])
return this.filterPublishingRelays(raw, event)
} catch {
return []
@@ -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 {
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 {
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,