Browse Source

bug-fixes

expand profile and paymentInfo editors
imwald
Silberengel 2 months ago
parent
commit
133f8fa8d9
  1. 4
      nip66-cron/Dockerfile
  2. 5
      src/components/FavoriteRelaysSetting/RelaySet.tsx
  3. 15
      src/components/Profile/index.tsx
  4. 2
      src/components/RelayInfo/index.tsx
  5. 16
      src/components/WebPreview/index.tsx
  6. 4
      src/constants.ts
  7. 14
      src/hooks/useFetchWebMetadata.tsx
  8. 30
      src/i18n/locales/en.ts
  9. 10
      src/lib/draft-event.ts
  10. 6
      src/lib/error-suppression.ts
  11. 24
      src/lib/url.ts
  12. 283
      src/pages/secondary/ProfileEditorPage/index.tsx
  13. 26
      src/services/client.service.ts
  14. 41
      src/services/indexed-db.service.ts

4
nip66-cron/Dockerfile

@ -3,8 +3,8 @@ FROM node:20-alpine @@ -3,8 +3,8 @@ FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY index.mjs ./

5
src/components/FavoriteRelaysSetting/RelaySet.tsx

@ -154,9 +154,8 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) { @@ -154,9 +154,8 @@ function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
}
const copyShareLink = () => {
navigator.clipboard.writeText(
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
)
const query = relaySet.relayUrls.map((u) => 'r=' + encodeURIComponent(u)).join('&')
navigator.clipboard.writeText(`${window.location.origin}/?${query}`)
}
if (isSmallScreen) {

15
src/components/Profile/index.tsx

@ -21,7 +21,7 @@ import { @@ -21,7 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ExtendedKind, BIG_RELAY_URLS } from '@/constants'
import { ExtendedKind } from '@/constants'
import { useFetchProfile } from '@/hooks'
import { Event, kinds } from 'nostr-tools'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
@ -139,21 +139,16 @@ export default function Profile({ id }: { id?: string }) { @@ -139,21 +139,16 @@ export default function Profile({ id }: { id?: string }) {
})
}, [paymentInfo, profile])
// Fetch payment info (kind 10133) for this profile
// Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB
useEffect(() => {
if (!profile?.pubkey) {
setPaymentInfo(null)
return
}
const fetchPaymentInfo = async () => {
try {
const events = await client.fetchEvents(BIG_RELAY_URLS, [{
authors: [profile.pubkey],
kinds: [ExtendedKind.PAYMENT_INFO],
limit: 1
}])
const paymentEvent = events[0]
const paymentEvent = await client.fetchPaymentInfoEvent(profile.pubkey)
if (paymentEvent) {
setPaymentInfo(getPaymentInfoFromEvent(paymentEvent))
} else {
@ -164,7 +159,7 @@ export default function Profile({ id }: { id?: string }) { @@ -164,7 +159,7 @@ export default function Profile({ id }: { id?: string }) {
setPaymentInfo(null)
}
}
fetchPaymentInfo()
}, [profile?.pubkey])
const [activeTab, setActiveTab] = useState<ProfileTabValue>('posts')

2
src/components/RelayInfo/index.tsx

@ -263,7 +263,7 @@ function RelayControls({ url }: { url: string }) { @@ -263,7 +263,7 @@ function RelayControls({ url }: { url: string }) {
}
const handleCopyShareableUrl = () => {
navigator.clipboard.writeText(`https://jumble.social/?r=${url}`)
navigator.clipboard.writeText(`${window.location.origin}/?r=${encodeURIComponent(url)}`)
setCopiedShareableUrl(true)
toast.success('Shareable URL copied to clipboard')
setTimeout(() => setCopiedShareableUrl(false), 2000)

16
src/components/WebPreview/index.tsx

@ -12,7 +12,7 @@ import { nip19, kinds } from 'nostr-tools' @@ -12,7 +12,7 @@ import { nip19, kinds } from 'nostr-tools'
import { useMemo, useEffect, useState } from 'react'
import Image from '../Image'
import Username from '../Username'
import { cleanUrl } from '@/lib/url'
import { cleanUrl, isSafeMediaUrl } from '@/lib/url'
import { tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service'
import { Event } from 'nostr-tools'
@ -420,11 +420,11 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -420,11 +420,11 @@ export default function WebPreview({ url, className }: { url: string; className?
const [ogImageAspectRatio, setOgImageAspectRatio] = useState<number | null>(null)
useEffect(() => {
if (!displayImageForDetection) {
if (!displayImageForDetection || !isSafeMediaUrl(displayImageForDetection)) {
setImageAspectRatio(null)
return
}
const img = new window.Image()
img.onload = () => {
const aspectRatio = img.width / img.height
@ -438,11 +438,11 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -438,11 +438,11 @@ export default function WebPreview({ url, className }: { url: string; className?
// Detect OG image aspect ratio
useEffect(() => {
if (!image) {
if (!image || !isSafeMediaUrl(image)) {
setOgImageAspectRatio(null)
return
}
const img = new window.Image()
img.onload = () => {
const aspectRatio = img.width / img.height
@ -506,7 +506,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -506,7 +506,7 @@ export default function WebPreview({ url, className }: { url: string; className?
<div
className={cn('p-3 flex w-full border rounded-lg overflow-hidden gap-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 max-w-full', className)}
>
{displayImage && (
{displayImage && isSafeMediaUrl(displayImage) && (
<div className={cn(
"flex-shrink-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 -my-3 -ml-3 -mr-0 flex items-center justify-center rounded-l-lg overflow-hidden",
imageAspectRatio !== null && imageAspectRatio > 1 ? "w-24 sm:w-32 md:w-52 lg:w-[416px] max-w-[120px] sm:max-w-[160px] md:max-w-[208px] lg:max-w-none" : "w-20 sm:w-28 md:w-40 lg:w-52 max-w-[80px] sm:max-w-[112px] md:max-w-[160px] lg:max-w-none"
@ -705,7 +705,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -705,7 +705,7 @@ export default function WebPreview({ url, className }: { url: string; className?
// All OG images render on left with cropping
if (isSmallScreen && image) {
if (isSmallScreen && image && isSafeMediaUrl(image)) {
// Small screen: always use horizontal layout with image on left
return (
<div className="rounded-lg border mt-2 overflow-hidden flex w-full">
@ -748,7 +748,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -748,7 +748,7 @@ export default function WebPreview({ url, className }: { url: string; className?
// Render all OG images on left side, crop wider ones
return (
<div className={cn('p-2 flex w-full border rounded-lg overflow-hidden gap-0 max-w-full', className)}>
{image && (
{image && isSafeMediaUrl(image) && (
<div className={cn(
"flex-shrink-0 bg-muted flex items-center justify-center -my-2 -ml-2 -mr-0 rounded-l-lg overflow-hidden",
ogImageAspectRatio !== null && ogImageAspectRatio > 1 ? "w-32 sm:w-52 md:w-[416px]" : "w-20 sm:w-40 md:w-52"

4
src/constants.ts

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import { kinds } from 'nostr-tools'
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
/** API base URL; override with VITE_JUMBLE_API_BASE_URL for forks (e.g. https://api.jumble.imwald.eu). */
export const JUMBLE_API_BASE_URL =
(import.meta.env.VITE_JUMBLE_API_BASE_URL as string | undefined) ?? 'https://api.jumble.imwald.eu'
export const DEFAULT_FAVORITE_RELAYS = [
'wss://theforest.nostr1.com',

14
src/hooks/useFetchWebMetadata.tsx

@ -2,25 +2,25 @@ import { TWebMetadata } from '@/types' @@ -2,25 +2,25 @@ import { TWebMetadata } from '@/types'
import { useEffect, useState } from 'react'
import webService from '@/services/web.service'
import logger from '@/lib/logger'
import { isLikelyWebPageUrl } from '@/lib/url'
export function useFetchWebMetadata(url: string) {
const [metadata, setMetadata] = useState<TWebMetadata>({})
useEffect(() => {
if (!url) {
if (!url || !isLikelyWebPageUrl(url)) {
return
}
logger.info('[useFetchWebMetadata] Fetching OG metadata', { url })
// Pass original URL - web service will handle proxy conversion
logger.debug('[useFetchWebMetadata] Fetching OG metadata', { url })
webService.fetchWebMetadata(url)
.then((metadata) => {
logger.info('[useFetchWebMetadata] Received metadata', { url, hasTitle: !!metadata.title, hasDescription: !!metadata.description, hasImage: !!metadata.image })
logger.debug('[useFetchWebMetadata] Received metadata', { url, hasTitle: !!metadata.title, hasDescription: !!metadata.description, hasImage: !!metadata.image })
setMetadata(metadata)
})
.catch((error) => {
logger.error('[useFetchWebMetadata] Failed to fetch metadata', { url, error })
logger.debug('[useFetchWebMetadata] Failed to fetch metadata', { url, error })
})
}, [url])

30
src/i18n/locales/en.ts

@ -76,6 +76,36 @@ export default { @@ -76,6 +76,36 @@ export default {
'Click to open payment options': 'Click to open payment options',
'Click to copy address': 'Click to copy address',
'Open on website': 'Open on website',
'Raw profile event': 'Raw profile event',
'Full profile event': 'Full profile event',
'Event (JSON)': 'Event (JSON)',
'Save full profile': 'Save full profile',
'Add tag': 'Add tag',
'Remove tag': 'Remove tag',
'Tag name': 'Tag name',
Value: 'Value',
'Add value to tag': 'Add value to tag',
'Remove value': 'Remove value',
'No tags. Click "Add tag" to add one.': 'No tags. Click "Add tag" to add one.',
'Profile updated': 'Profile updated',
'Failed to publish profile': 'Failed to publish profile',
'Invalid profile JSON': 'Invalid profile JSON',
'Refresh cache': 'Refresh cache',
'Force-refresh profile and payment info from relays': 'Force-refresh profile and payment info from relays',
'Profile and payment cache refreshed': 'Profile and payment cache refreshed',
'Failed to refresh cache': 'Failed to refresh cache',
'Raw payment info event': 'Raw payment info event',
'Payment info': 'Payment info',
'Edit payment info': 'Edit payment info',
'Add payment info': 'Add payment info',
'No payment info event yet. Click "Add payment info" to create one.': 'No payment info event yet. Click "Add payment info" to create one.',
'Content (JSON)': 'Content (JSON)',
Tags: 'Tags',
'Tags (JSON array of arrays, e.g. [["payto","lightning","user@domain.com"]])': 'Tags (JSON array of arrays, e.g. [["payto","lightning","user@domain.com"]])',
'Payment info updated': 'Payment info updated',
'Failed to publish payment info': 'Failed to publish payment info',
'Invalid tags JSON': 'Invalid tags JSON',
'Saving…': 'Saving…',
'Share with Jumble': 'Share with Jumble',
'Share with Alexandria': 'Share with Alexandria',
Delete: 'Delete',

10
src/lib/draft-event.ts

@ -503,6 +503,16 @@ export function createProfileDraftEvent(content: string, tags: string[][] = []): @@ -503,6 +503,16 @@ export function createProfileDraftEvent(content: string, tags: string[][] = []):
}
}
/** NIP-A3 payment info (kind 10133). */
export function createPaymentInfoDraftEvent(content: string, tags: string[][] = []): TDraftEvent {
return {
kind: ExtendedKind.PAYMENT_INFO,
content,
tags,
created_at: dayjs().unix()
}
}
export function createFavoriteRelaysDraftEvent(
favoriteRelays: string[],
relaySetEventsOrATags: Event[] | string[][]

6
src/lib/error-suppression.ts

@ -124,7 +124,9 @@ export function suppressExpectedErrors() { @@ -124,7 +124,9 @@ export function suppressExpectedErrors() {
// Suppress invalid URI / media resource errors (e.g. empty img src resolving to origin)
if (message.includes('Ungültige URI') ||
message.includes('Invalid URI') ||
message.includes('Laden der Medienressource fehlgeschlagen') ||
message.includes('Medienressource') ||
(message.includes('fehlgeschlagen') && message.includes('URI')) ||
message.includes('Laden der Medienressource') ||
message.includes('Failed to load media resource') ||
message.includes('OpaqueResponseBlocking')) {
return
@ -148,6 +150,8 @@ export function suppressExpectedErrors() { @@ -148,6 +150,8 @@ export function suppressExpectedErrors() {
// Suppress invalid URI / failed media resource (e.g. empty img src)
if (message.includes('Ungültige URI') ||
message.includes('Invalid URI') ||
message.includes('Medienressource') ||
(message.includes('fehlgeschlagen') && message.includes('URI')) ||
message.includes('Laden der Medienressource') ||
message.includes('Failed to load media resource')) {
return

24
src/lib/url.ts

@ -268,6 +268,30 @@ export function isVideo(url: string) { @@ -268,6 +268,30 @@ export function isVideo(url: string) {
}
}
/**
* Return true if the URL looks like a fetchable web page (http(s) with a plausible host).
* Used to skip OG metadata fetch for invalid or non-http URLs (e.g. "https://1.4ghz/").
*/
export function isLikelyWebPageUrl(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
const host = parsed.hostname || ''
if (!host) return false
// Require a dot (e.g. example.com) or localhost so we skip bare hostnames like "1.4ghz"
return host.includes('.') || host === 'localhost'
} catch {
return false
}
}
/** Return true if the string looks like a safe absolute HTTP(S) URL for use as img/video src. */
export function isSafeMediaUrl(url: string): boolean {
if (!url || typeof url !== 'string') return false
const t = url.trim()
return t.startsWith('http://') || t.startsWith('https://')
}
/**
* Remove tracking parameters from URLs
* Removes common tracking parameters like utm_*, fbclid, gclid, etc.

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

@ -1,19 +1,34 @@ @@ -1,19 +1,34 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
import Uploader from '@/components/PostEditor/Uploader'
import ProfileBanner from '@/components/ProfileBanner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { createProfileDraftEvent } from '@/lib/draft-event'
import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey'
import { isEmail } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Loader, Upload } from 'lucide-react'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import client from '@/services/client.service'
import { ChevronDown, Loader, Pencil, RefreshCw, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
@ -32,6 +47,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -32,6 +47,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [saving, setSaving] = useState(false)
const [uploadingBanner, setUploadingBanner] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false)
const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('')
const [paymentInfoEditTagsJson, setPaymentInfoEditTagsJson] = useState('[]')
const [savingPaymentInfo, setSavingPaymentInfo] = useState(false)
/** Editable full profile event (whole event as JSON string); synced from profileEvent. */
const [profileEventJson, setProfileEventJson] = useState<string>('')
const [savingFullProfile, setSavingFullProfile] = useState(false)
const [refreshingCache, setRefreshingCache] = useState(false)
const defaultImage = useMemo(
() => (account ? generateImageByPubkey(account.pubkey) : undefined),
[account]
@ -57,6 +81,79 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -57,6 +81,79 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}, [profile])
// Sync editable full profile event (entire event as JSON) from profileEvent
useEffect(() => {
if (profileEvent) {
setProfileEventJson(JSON.stringify(profileEvent, null, 2))
} else {
setProfileEventJson('')
}
}, [profileEvent])
// Fetch payment info event (kind 10133) for current user
useEffect(() => {
if (!account?.pubkey) {
setPaymentInfoEvent(null)
return
}
let cancelled = false
client
.fetchPaymentInfoEvent(account.pubkey)
.then((evt) => {
if (!cancelled) setPaymentInfoEvent(evt ?? null)
})
.catch(() => {
if (!cancelled) setPaymentInfoEvent(null)
})
return () => {
cancelled = true
}
}, [account?.pubkey])
const openPaymentInfoEditor = useCallback(() => {
if (paymentInfoEvent) {
setPaymentInfoEditContent(
typeof paymentInfoEvent.content === 'string'
? paymentInfoEvent.content
: JSON.stringify(paymentInfoEvent.content ?? '', null, 2)
)
setPaymentInfoEditTagsJson(
JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)
)
} else {
setPaymentInfoEditContent('{}')
setPaymentInfoEditTagsJson('[]')
}
setPaymentInfoEditOpen(true)
}, [paymentInfoEvent])
const savePaymentInfo = useCallback(async () => {
let tags: string[][]
try {
tags = JSON.parse(paymentInfoEditTagsJson)
if (!Array.isArray(tags)) throw new Error('Tags must be an array')
tags.forEach((t, i) => {
if (!Array.isArray(t)) throw new Error(`Tag at index ${i} must be an array of strings`)
})
} catch (e) {
toast.error(t('Invalid tags JSON'))
return
}
setSavingPaymentInfo(true)
try {
const draft = createPaymentInfoDraftEvent(paymentInfoEditContent.trim(), tags)
const published = await publish(draft)
await client.updatePaymentInfoCache(published)
setPaymentInfoEvent(published)
setPaymentInfoEditOpen(false)
toast.success(t('Payment info updated'))
} catch (err) {
toast.error(t('Failed to publish payment info'))
} finally {
setSavingPaymentInfo(false)
}
}, [paymentInfoEditContent, paymentInfoEditTagsJson, publish, t])
if (!account || !profile) return null
const save = async () => {
@ -95,7 +192,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -95,7 +192,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setHasChanged(false)
const profileDraftEvent = createProfileDraftEvent(
JSON.stringify(newProfileContent),
profileEvent?.tags
profileEvent?.tags ?? []
)
const newProfileEvent = await publish(profileDraftEvent)
await updateProfileEvent(newProfileEvent)
@ -113,8 +210,72 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -113,8 +210,72 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setHasChanged(true)
}
const forceRefreshProfileAndPaymentCache = useCallback(async () => {
if (!account?.pubkey) return
setRefreshingCache(true)
try {
await client.forceRefreshProfileAndPaymentInfoCache(account.pubkey)
const [profileEvt, paymentEvt] = await Promise.all([
client.fetchProfileEvent(account.pubkey),
client.fetchPaymentInfoEvent(account.pubkey)
])
if (profileEvt) await updateProfileEvent(profileEvt)
setPaymentInfoEvent(paymentEvt ?? null)
toast.success(t('Profile and payment cache refreshed'))
} catch {
toast.error(t('Failed to refresh cache'))
} finally {
setRefreshingCache(false)
}
}, [account?.pubkey, updateProfileEvent, t])
const saveFullProfile = async () => {
let parsed: { kind?: number; content?: string; tags?: string[][] }
try {
const raw = JSON.parse(profileEventJson.trim())
if (raw === null || typeof raw !== 'object') throw new Error('Must be a JSON object')
parsed = raw
if (parsed.kind !== 0) throw new Error('kind must be 0')
if (typeof parsed.content !== 'string') throw new Error('content must be a string')
if (!Array.isArray(parsed.tags)) throw new Error('tags must be an array')
parsed.tags.forEach((t: unknown, i: number) => {
if (!Array.isArray(t)) throw new Error(`tag at index ${i} must be an array`)
})
} catch (e) {
toast.error(e instanceof Error ? e.message : t('Invalid profile JSON'))
return
}
setSavingFullProfile(true)
try {
const profileDraftEvent = createProfileDraftEvent(
parsed.content!,
parsed.tags ?? []
)
const newProfileEvent = await publish(profileDraftEvent)
await updateProfileEvent(newProfileEvent)
setProfileEventJson(JSON.stringify(newProfileEvent, null, 2))
setHasChanged(false)
toast.success(t('Profile updated'))
} catch (err) {
toast.error(t('Failed to publish profile'))
} finally {
setSavingFullProfile(false)
}
}
const controls = (
<div className="pr-3">
<div className="pr-3 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache}
className="gap-1.5"
title={t('Force-refresh profile and payment info from relays')}
>
{refreshingCache ? <Loader className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t('Refresh cache')}
</Button>
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}>
{saving ? <Loader className="animate-spin" /> : t('Save')}
</Button>
@ -219,7 +380,119 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -219,7 +380,119 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<div className="text-xs text-destructive pl-3">{lightningAddressError}</div>
)}
</Item>
{/* Full profile event (kind 0): editable entire event as JSON */}
{profileEvent && (
<Item>
<Collapsible defaultOpen={false}>
<CollapsibleTrigger className="flex items-center gap-2 font-medium">
<ChevronDown className="h-4 w-4 transition-transform [[data-state=open]_&]:rotate-180" />
{t('Full profile event')}
</CollapsibleTrigger>
<CollapsibleContent className="pt-4 space-y-4">
<div>
<Label htmlFor="profile-event-json" className="text-muted-foreground">
{t('Event (JSON)')}
</Label>
<Textarea
id="profile-event-json"
className="mt-1 font-mono text-xs min-h-64"
value={profileEventJson}
onChange={(e) => {
setProfileEventJson(e.target.value)
setHasChanged(true)
}}
placeholder='{"id":"...","pubkey":"...","created_at":0,"kind":0,"tags":[],"content":"{}","sig":"..."}'
/>
</div>
<Button
onClick={saveFullProfile}
disabled={savingFullProfile || !hasChanged}
className="gap-2"
>
{savingFullProfile && <Loader className="h-4 w-4 animate-spin" />}
{savingFullProfile ? t('Saving…') : t('Save full profile')}
</Button>
</CollapsibleContent>
</Collapsible>
</Item>
)}
{/* Payment info (kind 10133): stringified content + tags + Edit button */}
<Item>
<div className="flex items-center justify-between gap-2">
<Label className="text-muted-foreground">{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')}
</Button>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<ChevronDown className="h-4 w-4 transition-transform [[data-state=open]_&]:rotate-180" />
{t('Raw payment info event')}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
{paymentInfoEvent ? (
<>
<div>
<Label className="text-muted-foreground text-xs">{t('Content (JSON)')}</Label>
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48 break-all whitespace-pre-wrap">
{paymentInfoEvent.content || '{}'}
</pre>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('Tags')}</Label>
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48">
{JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)}
</pre>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{t('No payment info event yet. Click "Add payment info" to create one.')}</p>
)}
</CollapsibleContent>
</Collapsible>
</Item>
</div>
{/* Edit payment info dialog */}
<Dialog open={paymentInfoEditOpen} onOpenChange={setPaymentInfoEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('Edit payment info')} (kind 10133)</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-4">
<Item>
<Label htmlFor="payment-info-content">{t('Content (JSON)')}</Label>
<Textarea
id="payment-info-content"
className="font-mono text-sm min-h-32"
value={paymentInfoEditContent}
onChange={(e) => setPaymentInfoEditContent(e.target.value)}
/>
</Item>
<Item>
<Label htmlFor="payment-info-tags">{t('Tags (JSON array of arrays, e.g. [["payto","lightning","user@domain.com"]])')}</Label>
<Textarea
id="payment-info-tags"
className="font-mono text-sm min-h-24"
value={paymentInfoEditTagsJson}
onChange={(e) => setPaymentInfoEditTagsJson(e.target.value)}
/>
</Item>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}>
{t('Cancel')}
</Button>
<Button onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2">
{savingPaymentInfo && <Loader className="h-4 w-4 animate-spin" />}
{savingPaymentInfo ? t('Saving…') : t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SecondaryPageLayout>
)
})

26
src/services/client.service.ts

@ -2142,6 +2142,32 @@ class ClientService extends EventTarget { @@ -2142,6 +2142,32 @@ class ClientService extends EventTarget {
return await this.fetchReplaceableEvent(pubkey, 10001)
}
/** Fetch NIP-A3 payment info (kind 10133) for a user; uses replaceable cache and IndexedDB. */
async fetchPaymentInfoEvent(pubkey: string) {
return await this.fetchReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO)
}
/** Update local cache after publishing a payment info (kind 10133) event. */
async updatePaymentInfoCache(evt: NEvent) {
await this.updateReplaceableEventCache(evt)
}
/** Fetch profile (kind 0) event; uses replaceable cache and IndexedDB. */
async fetchProfileEvent(pubkey: string) {
return await this.fetchReplaceableEvent(pubkey, kinds.Metadata)
}
/**
* Force-refresh profile (kind 0) and payment info (kind 10133) cache for a pubkey:
* clears in-memory cache and IndexedDB so the next fetch loads from relays.
*/
async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise<void> {
this.replaceableEventDataLoader.clear({ pubkey, kind: kinds.Metadata })
this.replaceableEventDataLoader.clear({ pubkey, kind: ExtendedKind.PAYMENT_INFO })
await indexedDb.invalidateReplaceableEvent(pubkey, kinds.Metadata)
await indexedDb.invalidateReplaceableEvent(pubkey, ExtendedKind.PAYMENT_INFO)
}
clearRelayConnectionState(relayUrl: string) {
// Clear connection state for specified relay
this.pool.close([relayUrl])

41
src/services/indexed-db.service.ts

@ -38,11 +38,16 @@ export const StoreNames = { @@ -38,11 +38,16 @@ export const StoreNames = {
/** NIP-66: cached list of public lively relay URLs (from 30166 discovery). */
PUBLIC_LIVELY_RELAYS: 'publicLivelyRelays',
/** NIP-66: per-relay discovery cache (key = relay URL, value = { discovery, cachedAt }). */
NIP66_DISCOVERY: 'nip66Discovery'
NIP66_DISCOVERY: 'nip66Discovery',
/** NIP-A3 payment targets (kind 10133). */
PAYMENT_INFO_EVENTS: 'paymentInfoEvents'
}
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 19
const DB_VERSION = 21
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
/** Convert IDB request.onerror Event to a proper Error for logging and UI */
function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): Error {
@ -196,6 +201,9 @@ class IndexedDbService { @@ -196,6 +201,9 @@ class IndexedDbService {
store.createIndex('feedUrl', 'feedUrl', { unique: false })
store.createIndex('pubDate', 'pubDate', { unique: false })
}
if (!db.objectStoreNames.contains(StoreNames.PAYMENT_INFO_EVENTS)) {
db.createObjectStore(StoreNames.PAYMENT_INFO_EVENTS, { keyPath: 'key' })
}
}
}
);
@ -402,8 +410,19 @@ class IndexedDbService { @@ -402,8 +410,19 @@ class IndexedDbService {
const request = store.get(key)
request.onsuccess = () => {
const row = request.result as TValue<Event> | undefined
if (!row) {
transaction.commit()
return resolve(undefined)
}
// Invalidate profile and payment info cache when stale so they refetch regularly
const isProfileOrPayment = kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) {
transaction.commit()
return resolve(undefined)
}
transaction.commit()
resolve((request.result as TValue<Event>)?.value)
resolve(row.value)
}
request.onerror = (event) => {
@ -710,9 +729,9 @@ class IndexedDbService { @@ -710,9 +729,9 @@ class IndexedDbService {
private getReplaceableEventKeyFromEvent(event: Event): string {
// Events that are replaceable by pubkey only (no d-tag)
// RSS_FEED_LIST (10895) is in the 10000-20000 range, so it's automatically handled
// PAYMENT_INFO (10133), RSS_FEED_LIST (10895), etc. are in the 10000-20000 range
if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) ||
[kinds.Metadata, kinds.Contacts, ExtendedKind.PAYMENT_INFO].includes(event.kind) ||
(event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.WIKI_ARTICLE_MARKDOWN && event.kind !== kinds.LongFormArticle)
) {
return this.getReplaceableEventKey(event.pubkey)
@ -759,6 +778,8 @@ class IndexedDbService { @@ -759,6 +778,8 @@ class IndexedDbService {
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:
return StoreNames.EMOJI_SET_EVENTS
case ExtendedKind.PAYMENT_INFO:
return StoreNames.PAYMENT_INFO_EVENTS
case ExtendedKind.PUBLICATION:
case ExtendedKind.PUBLICATION_CONTENT:
case ExtendedKind.WIKI_ARTICLE:
@ -1147,6 +1168,14 @@ class IndexedDbService { @@ -1147,6 +1168,14 @@ class IndexedDbService {
})
}
/** Remove a replaceable event from cache so the next fetch will load from relays. */
async invalidateReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<void> {
const storeName = this.getStoreNameByKind(kind)
if (!storeName) return
const key = this.getReplaceableEventKey(pubkey, d)
await this.deleteStoreItem(storeName, key)
}
async deleteStoreItem(storeName: string, key: string): Promise<void> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
@ -1324,6 +1353,7 @@ class IndexedDbService { @@ -1324,6 +1353,7 @@ class IndexedDbService {
if (storeName === StoreNames.RSS_FEED_LIST_EVENTS) return ExtendedKind.RSS_FEED_LIST
if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList
if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets
if (storeName === StoreNames.PAYMENT_INFO_EVENTS) return ExtendedKind.PAYMENT_INFO
// PUBLICATION_EVENTS is not replaceable, so we don't handle it here
return undefined
}
@ -1405,6 +1435,7 @@ class IndexedDbService { @@ -1405,6 +1435,7 @@ class IndexedDbService {
const stores = [
{ name: StoreNames.PROFILE_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{ name: StoreNames.PAYMENT_INFO_EVENTS, expirationTimestamp: Date.now() - PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS }, // 5 min
{ name: StoreNames.RELAY_LIST_EVENTS, expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 }, // 1 day
{
name: StoreNames.FOLLOW_LIST_EVENTS,

Loading…
Cancel
Save