From 0db35655ccfe0578576a65edc5d34fa674b296c1 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 6 Apr 2026 16:44:13 +0200 Subject: [PATCH] bug-fixes --- .../secondary/ProfileEditorPage/index.tsx | 222 +++++++++++++++++- src/providers/NostrProvider/index.tsx | 29 ++- 2 files changed, 239 insertions(+), 12 deletions(-) diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 19f3def9..0f70f90f 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -26,7 +26,7 @@ import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { ChevronDown, 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 { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -70,6 +70,9 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const [refreshingCache, setRefreshingCache] = useState(false) /** Editable tag list for kind 0 (e.g. lud16, nip05, website). Each row is [name, value]. */ const [profileTags, setProfileTags] = useState([]) + /** Dialog to set picture/banner URL from JSON fields (alternative to top uploaders). */ + const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null) + const [imageUrlDraft, setImageUrlDraft] = useState('') const defaultImage = useMemo( () => (account ? generateImageByPubkey(account.pubkey) : undefined), [account] @@ -221,7 +224,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const tagsToSave = profileTags .filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0].trim() && tag[1].trim()) + .filter((tag) => !isPictureOrBannerTagName(tag[0])) .map((tag) => [tag[0].trim(), tag[1].trim(), ...(tag.slice(2) || [])]) + if (avatar.trim()) tagsToSave.push(['picture', avatar.trim()]) + if (banner.trim()) tagsToSave.push(['banner', banner.trim()]) setSaving(true) setHasChanged(false) const profileDraftEvent = createProfileDraftEvent( @@ -265,7 +271,51 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t]) - if (!account || !profile) return null + if (!account) return null + + // Profile still loading: show the header with the Refresh Cache button so the user isn't stuck. + if (!profile) { + const loadingControls = ( +
+ +
+ ) + return ( + +
+ +

+ {t('profileEditorProfileNotLoaded', { + defaultValue: 'Profile not loaded. Try refreshing the cache.' + })} +

+
+
+ ) + } + + const openImageUrlEditor = (field: 'picture' | 'banner') => { + setImageUrlField(field) + setImageUrlDraft(field === 'picture' ? avatar : banner) + } + + const applyImageUrlDraft = () => { + if (!imageUrlField) return + const v = imageUrlDraft.trim() + if (imageUrlField === 'picture') setAvatar(v) + else setBanner(v) + setHasChanged(true) + setImageUrlField(null) + } const saveFullProfile = async () => { let parsed: { kind?: number; content?: string; tags?: string[][] } @@ -428,7 +478,38 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { {t('Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.')}

- {profileTags.map((tag, idx) => ( + openImageUrlEditor('picture')} + onInsertThumb={() => { + const next = insertNostrBuildThumbUrl(avatar) + if (next) { + setAvatar(next) + setHasChanged(true) + } + }} + showThumbButton={canInsertNostrBuildThumb(avatar)} + t={t} + /> + openImageUrlEditor('banner')} + onInsertThumb={() => { + const next = insertNostrBuildThumbUrl(banner) + if (next) { + setBanner(next) + setHasChanged(true) + } + }} + showThumbButton={false} + t={t} + /> + {profileTags + .map((tag, idx) => ({ tag, idx })) + .filter(({ tag }) => !isPictureOrBannerTagName(tag[0])) + .map(({ tag, idx }) => (
{
+ {/* Set picture/banner URL (kind 0 JSON content) */} + { + if (!open) setImageUrlField(null) + }} + > + + + + {imageUrlField === 'picture' + ? t('profileEditorEditPictureUrl', { defaultValue: 'Edit profile picture URL' }) + : t('profileEditorEditBannerUrl', { defaultValue: 'Edit banner URL' })} + + +
+ + setImageUrlDraft(e.target.value)} + placeholder="https://" + /> +

+ {t('profileEditorImageUrlHint', { + defaultValue: + 'Saved in kind 0 content as picture or banner. You can paste a link from a previous upload instead of using the uploader above.' + })} +

+
+ + + + +
+
+ {/* Edit payment info dialog */} @@ -671,6 +792,101 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { ProfileEditorPage.displayName = 'ProfileEditorPage' export default ProfileEditorPage +function isPictureOrBannerTagName(name: string | undefined): boolean { + const n = (name ?? '').toLowerCase() + return n === 'picture' || n === 'banner' +} + +/** Host is *.nostr.build, path does not already use /thumb/. */ +function canInsertNostrBuildThumb(url: string): boolean { + const t = url.trim() + if (!t) return false + try { + const u = new URL(t) + if (!u.hostname.endsWith('nostr.build')) return false + const p = u.pathname + return p !== '/thumb' && !p.startsWith('/thumb/') + } catch { + return false + } +} + +function insertNostrBuildThumbUrl(url: string): string | null { + const t = url.trim() + if (!canInsertNostrBuildThumb(t)) return null + try { + const u = new URL(t) + const p = u.pathname || '/' + u.pathname = '/thumb' + (p.startsWith('/') ? p : `/${p}`) + return u.toString() + } catch { + return null + } +} + +function ProfileContentImageTagRow({ + tagName, + value, + onEdit, + onInsertThumb, + showThumbButton, + t +}: { + tagName: 'picture' | 'banner' + value: string + onEdit: () => void + onInsertThumb: () => void + showThumbButton: boolean + t: (key: string, opts?: { defaultValue?: string }) => string +}) { + return ( +
+ + + {showThumbButton && ( + + )} + +
+ ) +} + function Item({ children }: { children: React.ReactNode }) { return
{children}
} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index d5825d93..d8582b43 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -512,13 +512,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { ) const userEmojiListEvent = sortedEvents.find((e) => e.kind === kinds.UserEmojiList) if (profileEvent) { - const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) - if (updatedProfileEvent.id === profileEvent.id) { - // Update in-memory cache so it's immediately available - await replaceableEventService.updateReplaceableEventCache(updatedProfileEvent) - setProfileEvent(updatedProfileEvent) - setProfile(getProfileFromEvent(updatedProfileEvent)) + let resolvedProfileEvent = profileEvent + try { + const updatedProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) + resolvedProfileEvent = updatedProfileEvent + await replaceableEventService.updateReplaceableEventCache(resolvedProfileEvent) + } catch (e) { + // IDB write failed (e.g. tombstone or store error) — still apply the fetched event in memory + logger.warn('[NostrProvider] putReplaceableEvent failed for profile; using fetched event in memory', { error: e }) + try { await replaceableEventService.updateReplaceableEventCache(profileEvent) } catch {} } + setProfileEvent(resolvedProfileEvent) + setProfile(getProfileFromEvent(resolvedProfileEvent)) } else if (!storedProfileEvent) { setProfile({ pubkey: account.pubkey, @@ -1488,9 +1493,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const updateProfileEvent = async (profileEvent: Event) => { - const newProfileEvent = await indexedDb.putReplaceableEvent(profileEvent) - setProfileEvent(newProfileEvent) - setProfile(getProfileFromEvent(newProfileEvent)) + try { + await indexedDb.putReplaceableEvent(profileEvent) + } catch (e) { + logger.warn('[NostrProvider] updateProfileEvent: putReplaceableEvent failed', { error: e }) + } + // 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) + setProfile(getProfileFromEvent(profileEvent)) } const updateFollowListEvent = async (followListEvent: Event) => {