From 133f8fa8d994dab770bc447c7d4ac3afddd7ad49 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 16 Mar 2026 17:21:54 +0100 Subject: [PATCH] bug-fixes expand profile and paymentInfo editors --- nip66-cron/Dockerfile | 4 +- .../FavoriteRelaysSetting/RelaySet.tsx | 5 +- src/components/Profile/index.tsx | 15 +- src/components/RelayInfo/index.tsx | 2 +- src/components/WebPreview/index.tsx | 16 +- src/constants.ts | 4 +- src/hooks/useFetchWebMetadata.tsx | 14 +- src/i18n/locales/en.ts | 30 ++ src/lib/draft-event.ts | 10 + src/lib/error-suppression.ts | 6 +- src/lib/url.ts | 24 ++ .../secondary/ProfileEditorPage/index.tsx | 283 +++++++++++++++++- src/services/client.service.ts | 26 ++ src/services/indexed-db.service.ts | 41 ++- 14 files changed, 437 insertions(+), 43 deletions(-) diff --git a/nip66-cron/Dockerfile b/nip66-cron/Dockerfile index 9f47c976..367e2aa7 100644 --- a/nip66-cron/Dockerfile +++ b/nip66-cron/Dockerfile @@ -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 ./ diff --git a/src/components/FavoriteRelaysSetting/RelaySet.tsx b/src/components/FavoriteRelaysSetting/RelaySet.tsx index 4f008950..a87973eb 100644 --- a/src/components/FavoriteRelaysSetting/RelaySet.tsx +++ b/src/components/FavoriteRelaysSetting/RelaySet.tsx @@ -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) { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 5855cbc9..5249eaf9 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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 }) { }) }, [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 }) { setPaymentInfo(null) } } - + fetchPaymentInfo() }, [profile?.pubkey]) const [activeTab, setActiveTab] = useState('posts') diff --git a/src/components/RelayInfo/index.tsx b/src/components/RelayInfo/index.tsx index 2d43dc53..6311b77a 100644 --- a/src/components/RelayInfo/index.tsx +++ b/src/components/RelayInfo/index.tsx @@ -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) diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index 61035a6f..874e453b 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -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? const [ogImageAspectRatio, setOgImageAspectRatio] = useState(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? // 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?
- {displayImage && ( + {displayImage && isSafeMediaUrl(displayImage) && (
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? // 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 (
@@ -748,7 +748,7 @@ export default function WebPreview({ url, className }: { url: string; className? // Render all OG images on left side, crop wider ones return (
- {image && ( + {image && isSafeMediaUrl(image) && (
1 ? "w-32 sm:w-52 md:w-[416px]" : "w-20 sm:w-40 md:w-52" diff --git a/src/constants.ts b/src/constants.ts index bc79a6d0..706609dc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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', diff --git a/src/hooks/useFetchWebMetadata.tsx b/src/hooks/useFetchWebMetadata.tsx index e15a7c58..0605f0e6 100644 --- a/src/hooks/useFetchWebMetadata.tsx +++ b/src/hooks/useFetchWebMetadata.tsx @@ -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({}) 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]) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e4ba6656..bfe4f2e2 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index f3888185..92764dcb 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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[][] diff --git a/src/lib/error-suppression.ts b/src/lib/error-suppression.ts index 246f1f96..ea993897 100644 --- a/src/lib/error-suppression.ts +++ b/src/lib/error-suppression.ts @@ -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() { // 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 diff --git a/src/lib/url.ts b/src/lib/url.ts index 7479a7d2..5895da60 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -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. diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index d5047bb7..63f6c01a 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -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) => { const [saving, setSaving] = useState(false) const [uploadingBanner, setUploadingBanner] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) + const [paymentInfoEvent, setPaymentInfoEvent] = useState(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('') + 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) => { } }, [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) => { 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) => { 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 = ( -
+
+ @@ -219,7 +380,119 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{lightningAddressError}
)} + + {/* Full profile event (kind 0): editable entire event as JSON */} + {profileEvent && ( + + + + + {t('Full profile event')} + + +
+ +