From 8e8aed69c0fcdf0a21306a6e83858b4706e9e9f5 Mon Sep 17 00:00:00 2001 From: Lemon Date: Sun, 15 Mar 2026 23:04:00 -0700 Subject: [PATCH 01/50] Add Web Push notifications via nostr-push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the browser Notification constructor (tab-must-be-open) with nostr-push Web Push notifications that work even when the browser is closed. - Add service worker (public/sw.js) for receiving and displaying push events - Add nostr-push RPC client (src/lib/nostrPush.ts) using an ephemeral device keypair for signing — the user's Nostr signer is never prompted - Add usePushNotifications hook managing the full lifecycle: SW registration, VAPID key fetch, pushManager.subscribe, and nostr-push registration - Replace the web path in useNativeNotifications with push registration that auto-enables/disables based on the notificationsEnabled encrypted setting - Android native Capacitor path is unchanged - Requires VITE_NOSTR_PUSH_PUBKEY env var (hex pubkey of the nostr-push server) --- .env.example | 4 +- public/sw.js | 63 ++++++++ src/hooks/useNativeNotifications.ts | 115 +++----------- src/hooks/usePushNotifications.ts | 207 +++++++++++++++++++++++++ src/lib/nostrPush.ts | 224 ++++++++++++++++++++++++++++ src/lib/notificationTemplates.ts | 71 +++++++++ src/pages/NotificationSettings.tsx | 31 +++- src/vite-env.d.ts | 5 + 8 files changed, 619 insertions(+), 101 deletions(-) create mode 100644 public/sw.js create mode 100644 src/hooks/usePushNotifications.ts create mode 100644 src/lib/nostrPush.ts create mode 100644 src/lib/notificationTemplates.ts diff --git a/.env.example b/.env.example index 3d3a5f29..d28fed4b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ VITE_SENTRY_DSN="https://********************************@*****************.example.com/****************" VITE_PLAUSIBLE_DOMAIN="example.tld" -VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event" \ No newline at end of file +VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event" +# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey") +VITE_NOSTR_PUSH_PUBKEY="" \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..30a233ba --- /dev/null +++ b/public/sw.js @@ -0,0 +1,63 @@ +/** + * Ditto Service Worker + * + * Handles incoming Web Push notifications from the nostr-push server and + * opens/focuses the app when the user taps a notification. + */ + +// --- Push received --- + +self.addEventListener('push', (event) => { + if (!event.data) return; + + let payload; + try { + payload = event.data.json(); + } catch { + payload = { title: 'Ditto', body: event.data.text() }; + } + + const title = payload.title ?? 'Ditto'; + const options = { + body: payload.body ?? '', + icon: payload.icon ?? '/icon-192.png', + badge: payload.badge ?? '/icon-192.png', + data: payload.data ?? {}, + requireInteraction: false, + tag: payload.data?.subscription_id ?? 'ditto-notification', + renotify: true, + }; + + event.waitUntil( + self.registration.showNotification(title, options), + ); +}); + +// --- Notification click --- + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Focus an existing Ditto tab if one is open + for (const client of clientList) { + if (new URL(client.url).origin === self.location.origin) { + client.navigate('/notifications'); + return client.focus(); + } + } + // Otherwise open a new tab + return self.clients.openWindow('/notifications'); + }), + ); +}); + +// --- Activate immediately --- + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); diff --git a/src/hooks/useNativeNotifications.ts b/src/hooks/useNativeNotifications.ts index 6e06c124..8f7c1ec8 100644 --- a/src/hooks/useNativeNotifications.ts +++ b/src/hooks/useNativeNotifications.ts @@ -1,12 +1,11 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect } from 'react'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { LocalNotifications } from '@capacitor/local-notifications'; -import { useNostr } from '@nostrify/react'; -import type { NostrEvent, NPool } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import { useEncryptedSettings } from './useEncryptedSettings'; +import { usePushNotifications } from './usePushNotifications'; import { getEffectiveRelays } from '@/lib/appRelays'; /** Interface for the native DittoNotification Capacitor plugin. */ @@ -16,54 +15,26 @@ interface DittoNotificationPlugin { const DittoNotification = registerPlugin('DittoNotification'); -/** Human-readable label for a notification event kind. */ -function notificationTitle(event: NostrEvent): string { - switch (event.kind) { - case 7: return 'New reaction'; - case 6: - case 16: return 'New repost'; - case 9735: return 'New zap'; - case 1111: return 'New comment'; - case 1222: - case 1244: return 'New voice message'; - default: return 'New mention'; - } -} - /** * Hook that manages device/browser notifications for the Nostr app. * * Capacitor (native): passes user pubkey + relay URLs to the native Android - * notification service. Respects the user's notificationsEnabled setting. + * notification service. Defaults to on. Respects the user's notificationsEnabled setting. * - * Web/PWA: subscribes to Nostr events via a live relay subscription and - * fires browser Notification API notifications when the user has both - * granted browser permission and enabled notifications in their settings. + * Web/PWA: handles only the disable path — unregisters from nostr-push when + * the user turns off notifications or logs out. The enable path is triggered + * exclusively from NotificationSettings.tsx (a user gesture / click handler) + * because iOS requires Notification.requestPermission() to be called from + * a direct user interaction. */ export function useNativeNotifications(): void { - const { nostr } = useNostr(); const { user } = useCurrentUser(); const { config } = useAppContext(); const { settings } = useEncryptedSettings(); + const { supported: pushSupported, enabled: pushEnabled, disable: disablePush } = usePushNotifications(); - const notificationsEnabled = settings?.notificationsEnabled ?? true; - const notifPrefs = useMemo(() => settings?.notificationPreferences ?? {}, [settings?.notificationPreferences]); - - // Track the subscription start time so we only surface events that arrive - // after the subscription is opened (avoids replaying historical events). - const subStartRef = useRef(Math.floor(Date.now() / 1000)); - - // Keep a stable ref to the nostr object to avoid re-subscribing on every render. - const nostrRef = useRef(nostr); - useEffect(() => { nostrRef.current = nostr; }, [nostr]); - - // Keep a stable ref to per-type prefs so the async loop reads the latest - // values without triggering a reconnect on every preference change. - const notifPrefsRef = useRef(notifPrefs); - useEffect(() => { notifPrefsRef.current = notifPrefs; }, [notifPrefs]); - - // Deduplicate: track event IDs that have already triggered a notification. - const seenIdsRef = useRef>(new Set()); + // Web defaults to false (opt-in); native defaults to true (foreground service). + const notificationsEnabled = settings?.notificationsEnabled ?? Capacitor.isNativePlatform(); // ── Capacitor path ──────────────────────────────────────────────────────── @@ -106,64 +77,16 @@ export function useNativeNotifications(): void { }); }, [user, config.relayMetadata, config.useAppRelays, notificationsEnabled]); - // ── Web / PWA path ──────────────────────────────────────────────────────── + // ── Web Push path (nostr-push) — disable only ───────────────────────────── + // Enable is handled by NotificationSettings.tsx click handler. useEffect(() => { - // Only run on web (not native Capacitor). if (Capacitor.isNativePlatform()) return; - // Need a logged-in user, notifications enabled in settings, and browser permission. - if (!user || !notificationsEnabled) return; - if (!('Notification' in window) || Notification.permission !== 'granted') return; + if (!pushSupported) return; - // Record when we opened the subscription so old events are ignored. - subStartRef.current = Math.floor(Date.now() / 1000); - - const controller = new AbortController(); - - (async () => { - try { - const stream = nostrRef.current.req( - [{ - kinds: [1, 6, 7, 16, 9735, 1111, 1222, 1244], - '#p': [user.pubkey], - since: subStartRef.current, - }], - { signal: controller.signal }, - ); - - for await (const msg of stream) { - if (msg[0] !== 'EVENT') continue; - const event: NostrEvent = msg[2]; - - // Skip own events - if (event.pubkey === user.pubkey) continue; - // Skip events older than when the sub opened (relay may send a burst) - if (event.created_at < subStartRef.current) continue; - // Deduplicate: skip if we've already shown a notification for this event - if (seenIdsRef.current.has(event.id)) continue; - seenIdsRef.current.add(event.id); - - // Respect per-type preferences (default = enabled when not explicitly false) - const prefs = notifPrefsRef.current; - if (event.kind === 7 && prefs.reactions === false) continue; - if ((event.kind === 6 || event.kind === 16) && prefs.reposts === false) continue; - if (event.kind === 9735 && prefs.zaps === false) continue; - if (event.kind === 1 && prefs.mentions === false) continue; - if (event.kind === 1111 && prefs.comments === false) continue; - - new Notification(notificationTitle(event), { - body: event.content.slice(0, 120) || undefined, - tag: event.id, - icon: '/favicon.ico', - }); - } - } catch { - // Subscription closed or errored — ignore - } - })(); - - return () => { - controller.abort(); - }; - }, [user, notificationsEnabled]); + // User logged out or disabled notifications — unregister from nostr-push. + if ((!user || !notificationsEnabled) && pushEnabled) { + disablePush().catch((err) => console.error('[push] Failed to disable:', err)); + } + }, [user, notificationsEnabled, pushSupported, pushEnabled]); } diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts new file mode 100644 index 00000000..38fe94ff --- /dev/null +++ b/src/hooks/usePushNotifications.ts @@ -0,0 +1,207 @@ +/** + * usePushNotifications + * + * Manages the Web Push notification lifecycle for Ditto via nostr-push. + * + * - Registers the service worker on mount. + * - On enable(): requests browser permission, fetches the VAPID key from the + * nostr-push server, subscribes to Web Push, and registers the subscription + * with the server for each notification kind the user cares about. + * - On disable(): deletes server subscriptions and unsubscribes the browser. + * + * Uses an ephemeral device keypair (not the user's Nostr key) for signing + * the RPC events, so the user's signer is never prompted. + * + * Environment variables: + * VITE_NOSTR_PUSH_PUBKEY — hex pubkey of the nostr-push server + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; + +import { NostrPushClient, serializePushSubscription, urlBase64ToUint8Array } from '@/lib/nostrPush'; +import { NOTIFICATION_TEMPLATES } from '@/lib/notificationTemplates'; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +const SERVER_PUBKEY: string = import.meta.env.VITE_NOSTR_PUSH_PUBKEY ?? ''; +const DOMAIN = typeof window !== 'undefined' ? window.location.hostname : ''; + +/** Relays used for the RPC channel to nostr-push. */ +const RPC_RELAYS = [ + 'wss://relay.ditto.pub/', + 'wss://relay.primal.net/', + 'wss://relay.damus.io/', +]; + +// localStorage keys +const VAPID_KEY_CACHE = 'ditto-push-vapid-key'; +const REGISTERED_KEY = 'ditto-push-registered'; +const SUBSCRIPTION_ID_KEY = 'ditto-push-subscription-id'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getOrCreateSubscriptionId(): string { + const existing = localStorage.getItem(SUBSCRIPTION_ID_KEY); + if (existing) return existing; + const id = crypto.randomUUID(); + localStorage.setItem(SUBSCRIPTION_ID_KEY, id); + return id; +} + +// ─── Hook ───────────────────────────────────────────────────────────────────── + +export interface UsePushNotificationsReturn { + /** Current browser permission state. */ + permission: NotificationPermission; + /** Whether Web Push is currently active and registered. */ + enabled: boolean; + /** Whether the browser and environment support Web Push. */ + supported: boolean; + /** Request permission, subscribe, and register with nostr-push. Must be called from a user gesture. */ + enable: (userPubkey: string) => Promise; + /** Unsubscribe from Web Push and delete server registrations. */ + disable: () => Promise; +} + +export function usePushNotifications(): UsePushNotificationsReturn { + const supported = + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + !!SERVER_PUBKEY; + + const [permission, setPermission] = useState( + typeof Notification !== 'undefined' ? Notification.permission : 'default', + ); + const [enabled, setEnabled] = useState(false); + + const pushSubRef = useRef(null); + const clientRef = useRef(null); + const swRegistrationRef = useRef(null); + + // ─── Register SW + restore state on mount ───────────────────────────────── + + useEffect(() => { + if (!supported) return; + + clientRef.current = new NostrPushClient(SERVER_PUBKEY, RPC_RELAYS); + + navigator.serviceWorker + .register('/sw.js', { scope: '/' }) + .then((reg) => { + swRegistrationRef.current = reg; + return navigator.serviceWorker.ready; + }) + .then(async (reg) => { + // If permission was already granted and a push subscription exists, + // restore the enabled state silently (no RPC needed — server already has it). + if (Notification.permission === 'granted') { + const existing = await reg.pushManager.getSubscription(); + if (existing && localStorage.getItem(REGISTERED_KEY) === 'true') { + pushSubRef.current = existing; + setPermission('granted'); + setEnabled(true); + } + } + }) + .catch((err) => console.error('[push] SW registration failed:', err)); + + return () => { + clientRef.current?.destroy(); + clientRef.current = null; + }; + }, []); + + // ─── enable() ───────────────────────────────────────────────────────────── + + const enable = useCallback(async (userPubkey: string) => { + if (!supported) return; + + const result = await Notification.requestPermission(); + setPermission(result); + if (result !== 'granted') return; + + const client = clientRef.current; + if (!client) { + console.warn('[push] NostrPushClient not initialized — service worker may still be loading'); + return; + } + + // Get or fetch VAPID key + let vapidPublicKey = localStorage.getItem(VAPID_KEY_CACHE); + if (!vapidPublicKey) { + vapidPublicKey = await client.getVapidKey(DOMAIN); + localStorage.setItem(VAPID_KEY_CACHE, vapidPublicKey); + } + + // Get or create push subscription + const reg = swRegistrationRef.current ?? await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + pushSubRef.current = sub; + + const baseId = getOrCreateSubscriptionId(); + const serialized = serializePushSubscription(sub); + + // Register one subscription per notification type so each gets a + // tailored template (matching the Android native notification text). + await Promise.all(NOTIFICATION_TEMPLATES.map((tmpl) => + client.registerSubscription({ + subscription_id: `${baseId}-${tmpl.id}`, + domain: DOMAIN, + filter: { + kinds: tmpl.kinds, + '#p': [userPubkey], + }, + notification: { + title: tmpl.title, + body: tmpl.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + }, + push_subscription: serialized, + }), + )); + + localStorage.setItem(REGISTERED_KEY, 'true'); + setEnabled(true); + }, [supported]); + + // ─── disable() ──────────────────────────────────────────────────────────── + + const disable = useCallback(async () => { + const client = clientRef.current; + const baseId = localStorage.getItem(SUBSCRIPTION_ID_KEY); + + // Delete all per-type subscriptions from nostr-push server + if (client && baseId) { + await Promise.allSettled( + NOTIFICATION_TEMPLATES.map((tmpl) => + client.deleteSubscription({ + subscription_id: `${baseId}-${tmpl.id}`, + domain: DOMAIN, + }).catch((err) => console.error(`[push] Failed to delete ${tmpl.id}:`, err)), + ), + ); + } + + // Unsubscribe browser push + const pushSub = pushSubRef.current; + if (pushSub) { + try { + await pushSub.unsubscribe(); + } catch { /* ignore */ } + pushSubRef.current = null; + } + + localStorage.removeItem(REGISTERED_KEY); + setEnabled(false); + }, []); + + return { permission, enabled, supported, enable, disable }; +} diff --git a/src/lib/nostrPush.ts b/src/lib/nostrPush.ts new file mode 100644 index 00000000..f118e1c2 --- /dev/null +++ b/src/lib/nostrPush.ts @@ -0,0 +1,224 @@ +/** + * nostr-push RPC client + * + * Sends RPC calls to the nostr-push server via encrypted Nostr events + * (kind 25742, NIP-44) and awaits a confirmation response. + * + * Uses an ephemeral keypair (generated once, persisted in localStorage) + * to avoid prompting the user's signer for every RPC call. + * + * Protocol: + * Request: kind 25742, tags [["p", serverPubkey]] + * content: nip44Encrypt(serverPubkey, JSON.stringify({ method, params, request_id })) + * + * Response: kind 25742, tags [["p", clientPubkey]], authored by serverPubkey + * content: nip44Encrypt(clientPubkey, JSON.stringify({ request_id, success, error? })) + */ + +import { nip44, generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'; +import { SimplePool } from 'nostr-tools'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; + +// ─── Ephemeral device key ───────────────────────────────────────────────────── + +const DEVICE_KEY_STORAGE = 'ditto-push-device-key'; + +/** + * Get or generate a persistent ephemeral key for this device. + * Used to sign nostr-push RPC events without prompting the user's signer. + */ +function getDeviceSecretKey(): Uint8Array { + const stored = localStorage.getItem(DEVICE_KEY_STORAGE); + if (stored) { + return hexToBytes(stored); + } + const sk = generateSecretKey(); + localStorage.setItem(DEVICE_KEY_STORAGE, bytesToHex(sk)); + return sk; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface WebPushSubscription { + type: 'web'; + endpoint: string; + p256dh_key: string; + auth_key: string; +} + +export interface RegisterSubscriptionParams { + subscription_id: string; + domain: string; + filter: { + kinds?: number[]; + authors?: string[]; + '#p'?: string[]; + '#t'?: string[]; + since?: number; + until?: number; + }; + notification: { + title: string; + body: string; + icon?: string; + badge?: string; + }; + push_subscription: WebPushSubscription; +} + +export interface DeleteSubscriptionParams { + subscription_id: string; + domain: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Convert a browser PushSubscription to the flat structure nostr-push expects. */ +export function serializePushSubscription(sub: PushSubscription): WebPushSubscription { + const keys = sub.toJSON().keys; + if (!keys?.p256dh || !keys?.auth) { + throw new Error('PushSubscription is missing p256dh or auth keys'); + } + return { + type: 'web', + endpoint: sub.endpoint, + p256dh_key: keys.p256dh, + auth_key: keys.auth, + }; +} + +/** Convert a base64url string to a Uint8Array (for applicationServerKey). */ +export function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +/** How long to wait for a server response before giving up. */ +const RESPONSE_TIMEOUT_MS = 15_000; + +export class NostrPushClient { + private pool: SimplePool; + private secretKey: Uint8Array; + private publicKey: string; + + constructor( + /** The nostr-push server's pubkey (hex). */ + private readonly serverPubkey: string, + /** Relays to publish RPC calls to. */ + private readonly relays: string[], + ) { + this.pool = new SimplePool(); + this.secretKey = getDeviceSecretKey(); + this.publicKey = getPublicKey(this.secretKey); + } + + /** + * Get the VAPID public key for a domain. + * Must be called before pushManager.subscribe() so the browser gets + * the correct applicationServerKey for this domain. + */ + async getVapidKey(domain: string): Promise { + const result = await this.send('get_vapid_key', { domain }); + const vapidKey = (result as { vapid_public_key?: string })?.vapid_public_key; + if (!vapidKey) throw new Error('nostr-push: server did not return a VAPID key'); + return vapidKey; + } + + /** Register (or replace) a push subscription. */ + async registerSubscription(params: RegisterSubscriptionParams): Promise { + await this.send('register_subscription', params); + } + + /** Delete a push subscription by its ID. */ + async deleteSubscription(params: DeleteSubscriptionParams): Promise { + await this.send('delete_subscription', params); + } + + /** Close the relay pool. */ + destroy(): void { + this.pool.close(this.relays); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private async send( + method: string, + params: object, + ): Promise { + const request_id = crypto.randomUUID(); + + // NIP-44 encrypt the payload to the server + const conversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey); + const encryptedContent = nip44.v2.encrypt( + JSON.stringify({ method, params, request_id }), + conversationKey, + ); + + const event = finalizeEvent( + { + kind: 25742, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', this.serverPubkey]], + content: encryptedContent, + }, + this.secretKey, + ); + + // Subscribe for the response BEFORE publishing so we don't miss a fast reply. + const responsePromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + sub.close(); + reject(new Error(`nostr-push: RPC timeout (${method})`)); + }, RESPONSE_TIMEOUT_MS); + + const responseConversationKey = nip44.v2.utils.getConversationKey(this.secretKey, this.serverPubkey); + + const sub = this.pool.subscribeMany( + this.relays, + [{ + kinds: [25742], + authors: [this.serverPubkey], + '#p': [this.publicKey], + since: Math.floor(Date.now() / 1000) - 5, + }], + { + onevent: (responseEvent) => { + try { + const decrypted = nip44.v2.decrypt(responseEvent.content, responseConversationKey); + const response = JSON.parse(decrypted) as { + request_id: string; + success: boolean; + error?: string; + result?: unknown; + }; + + if (response.request_id !== request_id) return; + + clearTimeout(timeout); + sub.close(); + + if (response.success) { + resolve(response.result); + } else { + reject(new Error(response.error ?? 'RPC call failed')); + } + } catch { + // Ignore events we can't decrypt + } + }, + }, + ); + }); + + // Publish to relays — at least one must accept + await Promise.any(this.relays.map((url) => this.pool.publish([url], event))).catch(() => { + throw new Error('nostr-push: failed to publish RPC event to any relay'); + }); + + return responsePromise; + } +} diff --git a/src/lib/notificationTemplates.ts b/src/lib/notificationTemplates.ts new file mode 100644 index 00000000..fcbdf718 --- /dev/null +++ b/src/lib/notificationTemplates.ts @@ -0,0 +1,71 @@ +/** + * Notification templates shared between Web Push (nostr-push) and + * Android native (NostrPoller.java) notification paths. + * + * Each entry defines a nostr-push subscription: the kinds to watch, + * and the title/body templates using nostr-push's server-side variable + * substitution ({{author_name}}, {{content}}, {{amount}}). + * + * Android equivalent in NostrPoller.java `kindToAction()`: + * kind 7 → "Someone reacted to your post" + * kind 6 → "Someone reposted your note" + * kind 16 → "Someone mentioned you" (default) + * kind 9735 → "Someone zapped you {amount} sats" + * kind 1 → "Someone replied to you" / "Someone mentioned you" + * kind 1111 → "Someone commented on your post" / "Someone replied to your comment" + * + * Web Push uses {{author_name}} instead of "Someone" since nostr-push + * resolves the author's kind 0 display name server-side. + * + * Conditional logic (reply vs mention for kind 1, comment vs reply for + * kind 1111) is not possible at the template level — nostr-push uses + * static templates per subscription. We use the most common case. + */ + +export interface NotificationTemplate { + /** Suffix appended to the base subscription ID to make it unique. */ + id: string; + /** Nostr event kinds this subscription watches. */ + kinds: number[]; + /** Notification title template. */ + title: string; + /** Notification body template. */ + body: string; +} + +/** + * Notification subscriptions to register with nostr-push. + * Each entry becomes a separate subscription with its own filter and template. + */ +export const NOTIFICATION_TEMPLATES: NotificationTemplate[] = [ + { + id: 'reactions', + kinds: [7], + title: '{{author_name}} Reacted!', + body: '{{content}}', + }, + { + id: 'reposts', + kinds: [6, 16], + title: '{{author_name}} Reposted!', + body: '{{content}}', + }, + { + id: 'zaps', + kinds: [9735], + title: '{{amount}} sats!', + body: '{{author_name}} zapped you', + }, + { + id: 'mentions', + kinds: [1], + title: '{{author_name}} Mentioned You!', + body: '{{content}}', + }, + { + id: 'comments', + kinds: [1111], + title: '{{author_name}} Commented!', + body: '{{content}}', + }, +]; diff --git a/src/pages/NotificationSettings.tsx b/src/pages/NotificationSettings.tsx index 91661502..85ea502f 100644 --- a/src/pages/NotificationSettings.tsx +++ b/src/pages/NotificationSettings.tsx @@ -7,6 +7,8 @@ import { Switch } from '@/components/ui/switch'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; +import { usePushNotifications } from '@/hooks/usePushNotifications'; +import { toast } from '@/hooks/useToast'; type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments'; @@ -120,11 +122,14 @@ export function NotificationSettings() { const { user } = useCurrentUser(); const { config } = useAppContext(); const { settings, updateSettings } = useEncryptedSettings(); + const { enable: enablePush, disable: disablePush } = usePushNotifications(); const [permission, setPermission] = useState('default'); + const isNative = Capacitor.isNativePlatform(); + // Local UI state — initialized from settings once loaded, then updated // synchronously on every toggle (fire-and-forget persist in background). - const [pushEnabled, setPushEnabled] = useState(true); + const [pushEnabled, setPushEnabled] = useState(() => isNative); const [prefs, setPrefs] = useState['notificationPreferences']>>({}); const initializedRef = useRef(false); @@ -132,7 +137,7 @@ export function NotificationSettings() { useEffect(() => { if (initializedRef.current || settings === null || settings === undefined) return; initializedRef.current = true; - setPushEnabled(settings.notificationsEnabled ?? true); + setPushEnabled(settings.notificationsEnabled ?? isNative); setPrefs(settings.notificationPreferences ?? {}); }, [settings]); @@ -148,12 +153,31 @@ export function NotificationSettings() { }, []); const handleTogglePush = async (enabled: boolean) => { - if (enabled && !Capacitor.isNativePlatform()) { + if (enabled && !isNative) { if (!('Notification' in window)) return; + const result = await Notification.requestPermission(); setPermission(result); if (result !== 'granted') return; + + // Register with nostr-push from this click handler (user gesture). + // Must happen here — iOS requires requestPermission + pushManager.subscribe + // to originate from a direct user interaction, not a useEffect. + if (user) { + try { + await enablePush(user.pubkey); + } catch (err) { + console.error('[push] Registration failed:', err); + toast({ title: 'Failed to enable notifications', description: 'Please try again.' }); + return; // Don't persist enabled=true if registration failed + } + } } + + if (!enabled && !isNative) { + await disablePush().catch((err) => console.error('[push] Failed to disable:', err)); + } + setPushEnabled(enabled); updateSettings.mutateAsync({ notificationsEnabled: enabled }).catch(() => { setPushEnabled(!enabled); // roll back on failure @@ -180,7 +204,6 @@ export function NotificationSettings() { return ; } - const isNative = Capacitor.isNativePlatform(); const isSupported = isNative || 'Notification' in window; const isDenied = !isNative && permission === 'denied'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 5a942620..7a8a143d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -4,6 +4,11 @@ declare module '@fontsource-variable/*'; declare module '@fontsource/comic-relief/*'; +interface ImportMetaEnv { + /** Hex pubkey of the nostr-push server for Web Push notifications. */ + readonly VITE_NOSTR_PUSH_PUBKEY?: string; +} + /** * Build-time configuration injected by Vite from ditto.json. * `null` when no config file was provided at build time. From e5f34659b2ebba862ac841ecc045e158f5776e81 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:20:36 -0500 Subject: [PATCH 02/50] feat: add field preset templates and live sidebar preview to profile settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic 3-item Add Field dropdown with 7 discoverable presets (Music, Photo, Video, Email, Link, Wallet, Custom) that pre-configure field type, default emoji labels, and scoped file-accept filters. - Add tooltips on upload buttons showing accepted file formats per type - Add HelpTip to Profile Fields section header with new FAQ entries - Add live sidebar preview panel (right column on xl+, collapsible on mobile) - Scope file picker accept attribute dynamically per field preset - No changes to the underlying data model — presets are purely client-side UX --- src/lib/helpContent.ts | 31 ++ src/pages/ProfileSettings.tsx | 531 ++++++++++++++++++++++++---------- 2 files changed, 410 insertions(+), 152 deletions(-) diff --git a/src/lib/helpContent.ts b/src/lib/helpContent.ts index a3fadd96..c5a01134 100644 --- a/src/lib/helpContent.ts +++ b/src/lib/helpContent.ts @@ -224,6 +224,37 @@ export const FAQ_CATEGORIES: FAQCategory[] = [ ], }, + // ── Profile & Identity ─────────────────────────────────────────────────── + { + id: 'profile-identity', + label: 'Profile & Identity', + items: [ + { + id: 'profile-fields', + question: 'What are profile fields?', + answer: [ + 'Profile fields let you add extra info to your profile sidebar — like links, wallet addresses, music, photos, videos, and more. They\'re a way to express yourself and share what matters to you.', + 'You can add fields from the profile settings page. Each field has a **label** (what it\'s called) and a **value** (the content). For media fields, you can upload files directly and they\'ll render as players or embeds on your profile.', + ], + }, + { + id: 'profile-fields-music', + question: 'What audio formats can I upload for music fields?', + answer: [ + 'You can upload audio files in these formats: **MP3**, **OGG**, **WAV**, **FLAC**, **AAC**, **M4A**, and **Opus**. They\'ll appear as a mini audio player on your profile sidebar.', + ], + }, + { + id: 'profile-fields-media', + question: 'What image and video formats are supported?', + answer: [ + 'For images: **JPG**, **PNG**, **GIF**, **WebP**, **SVG**, and **AVIF**. For video: **MP4**, **WebM**, and **MOV**.', + 'Images will display as linked thumbnails, and videos will be embedded inline on your profile.', + ], + }, + ], + }, + // ── Why is this different from Big Tech? ──────────────────────────────── { id: 'big-tech', diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index baab1f4e..a2538c0a 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -1,6 +1,9 @@ import { useSeoMeta } from '@unhead/react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, Type, Wallet, Image, Upload } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, + Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, +} from 'lucide-react'; import { Link, Navigate } from 'react-router-dom'; import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -18,6 +21,7 @@ import { import { CSS } from '@dnd-kit/utilities'; import { ProfileCard } from '@/components/ProfileCard'; +import { ProfileRightSidebar } from '@/components/ProfileRightSidebar'; import { IntroImage } from '@/components/IntroImage'; import { HelpTip } from '@/components/HelpTip'; import { ImageCropDialog } from '@/components/ImageCropDialog'; @@ -33,6 +37,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { @@ -56,8 +61,11 @@ import { CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { isValidAvatarShape } from '@/lib/avatarShape'; +// ── Constants ───────────────────────────────────────────────────────────────── + const WALLET_TICKERS = [ '$BTC', '$ETH', '$SOL', '$XMR', '$LTC', '$DOGE', '$ADA', '$DOT', '$XRP', '$MATIC', ] as const; @@ -65,6 +73,108 @@ const WALLET_TICKERS = [ /** Bare tickers used only for detection (strips leading $). */ const BARE_TICKERS = WALLET_TICKERS.map((t) => t.slice(1)); +// ── Field preset templates ──────────────────────────────────────────────────── + +interface FieldPreset { + id: string; + label: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + /** Default label to pre-fill when adding this field type. */ + defaultLabel: string; + /** The form field type. */ + type: 'text' | 'wallet' | 'media'; + /** File accept attribute for the file picker (media types only). */ + accept?: string; + /** Human-readable format list shown in tooltips. */ + formatHint?: string; + /** Placeholder for the value input. */ + valuePlaceholder?: string; +} + +const FIELD_PRESETS: FieldPreset[] = [ + { + id: 'music', + label: 'Music', + description: 'Add a song or audio clip', + icon: Music, + defaultLabel: '\u{1F3B6}', + type: 'media', + accept: 'audio/*', + formatHint: 'MP3, OGG, WAV, FLAC, AAC, M4A, Opus', + valuePlaceholder: 'Audio URL', + }, + { + id: 'photo', + label: 'Photo', + description: 'Upload an image', + icon: ImageIcon, + defaultLabel: '\u{1F4F8}', + type: 'media', + accept: 'image/*', + formatHint: 'JPG, PNG, GIF, WebP, SVG, AVIF', + valuePlaceholder: 'Image URL', + }, + { + id: 'video', + label: 'Video', + description: 'Upload a video clip', + icon: Film, + defaultLabel: '\u{1F3AC}', + type: 'media', + accept: 'video/*', + formatHint: 'MP4, WebM, MOV', + valuePlaceholder: 'Video URL', + }, + { + id: 'email', + label: 'Email', + description: 'Contact email address', + icon: Mail, + defaultLabel: 'Email', + type: 'text', + valuePlaceholder: 'you@example.com', + }, + { + id: 'link', + label: 'Link', + description: 'Link to any website or profile', + icon: Link2, + defaultLabel: '', + type: 'text', + valuePlaceholder: 'https://...', + }, + { + id: 'wallet', + label: 'Wallet', + description: 'Cryptocurrency wallet address', + icon: Wallet, + defaultLabel: '$BTC', + type: 'wallet', + valuePlaceholder: 'Address', + }, +]; + +/** The "Custom" preset — always shown last, separated by a divider. */ +const CUSTOM_PRESET: FieldPreset = { + id: 'custom', + label: 'Custom', + description: 'Create any custom field', + icon: Pencil, + defaultLabel: '', + type: 'text', + valuePlaceholder: 'Value or URL', +}; + +/** Find a preset's format hint from its accept filter. */ +function getFormatHintForAccept(accept: string | undefined): string | undefined { + if (!accept) return undefined; + const preset = FIELD_PRESETS.find((p) => p.accept === accept); + return preset?.formatHint; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + /** Infer the field type from stored label/value when loading from existing data. */ function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'media' { const bare = label.replace(/^\$/, '').toUpperCase(); @@ -76,11 +186,23 @@ function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'medi return 'text'; } +/** Infer a file-accept filter from an existing field's value URL. */ +function inferAcceptFromValue(value: string): string | undefined { + if (/\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i.test(value)) return 'audio/*'; + if (/\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(value)) return 'image/*'; + if (/\.(mp4|webm|mov|qt)(\?.*)?$/i.test(value)) return 'video/*'; + return undefined; +} + +// ── Schema ──────────────────────────────────────────────────────────────────── + const formSchema = n.metadata().extend({ fields: z.array(z.object({ label: z.string(), value: z.string(), type: z.enum(['text', 'wallet', 'media']), + /** Client-side only — file accept filter for the file picker (not persisted). */ + accept: z.string().optional(), })).optional(), shape: z.string().optional(), }); @@ -100,6 +222,7 @@ interface SortableFieldRowProps { id: string; index: number; type: 'text' | 'wallet' | 'media'; + accept?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any control: any; onRemove: () => void; @@ -107,10 +230,12 @@ interface SortableFieldRowProps { onTickerChange: (ticker: string) => void; } -function SortableFieldRow({ id, index, type, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { +function SortableFieldRow({ id, index, type, accept, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; + const formatHint = type === 'media' ? getFormatHintForAccept(accept) : undefined; + return (
)} - {/* Value column — media gets upload button, others get text input */} + {/* Value column — media gets upload button with tooltip, others get text input */} {type === 'media' ? ( - + + + + + + {formatHint ? ( + Upload file
{formatHint}
+ ) : ( + Upload a media file + )} +
+
@@ -230,6 +366,7 @@ export function ProfileSettings() { const { toast } = useToast(); const [cropState, setCropState] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); + const [showMobilePreview, setShowMobilePreview] = useState(false); useSeoMeta({ title: `Profile | Settings | ${config.appName}`, @@ -237,7 +374,7 @@ export function ProfileSettings() { }); // Parse existing custom fields from raw event - const parseFields = (): Array<{ label: string; value: string; type: 'text' | 'wallet' | 'media' }> => { + const parseFields = (): Array<{ label: string; value: string; type: 'text' | 'wallet' | 'media'; accept?: string }> => { if (!event) return []; try { const parsed = JSON.parse(event.content); @@ -250,7 +387,8 @@ export function ProfileSettings() { const label = type === 'wallet' && !f[0].startsWith('$') ? `$${f[0].toUpperCase()}` : f[0]; - return { label, value: f[1], type }; + const accept = type === 'media' ? inferAcceptFromValue(f[1]) : undefined; + return { label, value: f[1], type, accept }; }); } } catch { /* ignore */ } @@ -292,11 +430,16 @@ export function ProfileSettings() { move(oldIndex, newIndex); }, [fields, move]); - // Media field upload + // Media field upload — dynamic accept attribute per field const mediaInputRef = useRef(null); const pendingMediaIndex = useRef(-1); const handleMediaPick = (index: number) => { pendingMediaIndex.current = index; + // Dynamically set the accept attribute based on the field's preset + const fieldAccept = form.getValues(`fields.${index}.accept`); + if (mediaInputRef.current) { + mediaInputRef.current.accept = fieldAccept || 'image/*,video/*,audio/*'; + } mediaInputRef.current?.click(); }; const handleMediaFileChosen = async (e: React.ChangeEvent) => { @@ -346,6 +489,24 @@ export function ProfileSettings() { shape: watched.shape, }; + // Live sidebar preview fields — computed from watched form values + const previewFields = useMemo(() => { + const result: Array<{ label: string; value: string }> = []; + // Add website if present + if (watched.website?.trim()) { + result.push({ label: 'Website', value: watched.website.trim() }); + } + // Add custom fields that have both label and value + if (watched.fields) { + for (const f of watched.fields) { + if (f.label.trim() && f.value.trim()) { + result.push({ label: f.label, value: f.value }); + } + } + } + return result; + }, [watched.website, watched.fields]); + // Card onChange: patch individual fields const handleCardChange = (patch: Partial) => { for (const [k, v] of Object.entries(patch)) { @@ -395,6 +556,16 @@ export function ProfileSettings() { setCropState(null); }; + // Handle adding a field from a preset + const handleAddPreset = (preset: FieldPreset) => { + append({ + label: preset.defaultLabel, + value: '', + type: preset.type, + accept: preset.accept, + }); + }; + const onSubmit = async (values: FormValues) => { if (!user) return; try { @@ -428,6 +599,21 @@ export function ProfileSettings() { const busy = isPending || isUploading; + // ── Sidebar preview element (shared between desktop and mobile) ─────── + const sidebarPreview = ( +
+
+
+ + Sidebar Preview +
+
+
+ +
+
+ ); + return (
{/* Hidden file input for avatar/banner */} @@ -438,7 +624,7 @@ export function ProfileSettings() { className="hidden" onChange={handleFileChosen} /> - {/* Hidden file input for media fields */} + {/* Hidden file input for media fields — accept is set dynamically */} -
- + {/* Two-column layout: form + sidebar preview on xl+ */} +
+ {/* Left column — form */} + + - {/* Intro */} -
- -
-

Your Identity

-

- Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image. -

+ {/* Intro */} +
+ +
+

Your Identity

+

+ Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image. +

+
-
- {/* Interactive profile card */} - form.setValue('shape', shape, { shouldDirty: true })} - onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })} - /> + {/* Interactive profile card */} + form.setValue('shape', shape, { shouldDirty: true })} + onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })} + /> - {isUploading && ( -
- - Uploading image… + {isUploading && ( +
+ + Uploading… +
+ )} + + {/* Profile fields */} +
+

+ Profile Fields + +

+
+ {/* Website — always first */} + ( +
+
+
+ Website +
+ +
+
+ )} + /> + + {/* Lightning address */} + ( +
+
+
+ Lightning + +
+ +
+
+ )} + /> + + + f.id)} strategy={verticalListSortingStrategy}> + {fields.map((field, index) => ( + remove(index)} + onMediaPick={() => handleMediaPick(index)} + onTickerChange={(ticker) => form.setValue(`fields.${index}.label`, ticker, { shouldDirty: true })} + /> + ))} + + + + {/* Add field dropdown with presets */} + + + + + + {FIELD_PRESETS.map((preset) => { + const Icon = preset.icon; + return ( + handleAddPreset(preset)} className="flex items-start gap-2.5 py-2"> + +
+
{preset.label}
+
{preset.description}
+
+
+ ); + })} + + handleAddPreset(CUSTOM_PRESET)} className="flex items-start gap-2.5 py-2"> + +
+
{CUSTOM_PRESET.label}
+
{CUSTOM_PRESET.description}
+
+
+
+
+
- )} - {/* Profile fields */} -
-

Profile Fields

-
- {/* Website — always first */} - ( -
-
-
- Website -
- -
-
- )} - /> - - {/* Lightning address */} - ( -
-
-
- Lightning - -
- -
-
- )} - /> - - - f.id)} strategy={verticalListSortingStrategy}> - {fields.map((field, index) => ( - remove(index)} - onMediaPick={() => handleMediaPick(index)} - onTickerChange={(ticker) => form.setValue(`fields.${index}.label`, ticker, { shouldDirty: true })} - /> - ))} - - - - {/* Add field dropdown */} - - - - - - append({ label: '', value: '', type: 'text' })}> - - Text - - append({ label: '$BTC', value: '', type: 'wallet' })}> - - Wallet Address - - append({ label: '', value: '', type: 'media' })}> - - Media - - - + + + {sidebarPreview} + +
+ + {/* Advanced */} + + + + + + ( + +
+ Bot Account + Mark this account as automated +
+ + + +
+ )} + /> +
+
+ + + + + {/* Right column — live sidebar preview (xl+ only) */} +
+
+ {sidebarPreview}
- - {/* Advanced */} - - - - - - ( - -
- Bot Account - Mark this account as automated -
- - - -
- )} - /> -
-
- - - +
+
); } From f55c9ec8b4cc0b47afc5cc7961b06c8ada4a64ee Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:27:20 -0500 Subject: [PATCH 03/50] fix: use app sidebar slot for profile fields preview instead of inline two-column layout Use useLayoutOptions({ rightSidebar }) to render the live ProfileRightSidebar preview in the actual app sidebar, replacing the previous approach that put a fake two-column layout inside the center content area. The form returns to its original max-w-xl centered layout. Mobile collapsible preview is retained for screens below xl where the sidebar is hidden. --- src/pages/ProfileSettings.tsx | 357 +++++++++++++++++----------------- 1 file changed, 176 insertions(+), 181 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index a2538c0a..f8498b7e 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, } from 'lucide-react'; +import { useLayoutOptions } from '@/contexts/LayoutContext'; import { Link, Navigate } from 'react-router-dom'; import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -595,25 +596,15 @@ export function ProfileSettings() { } }; + // Inject live sidebar preview into the app's right sidebar slot + useLayoutOptions({ + rightSidebar: , + }); + if (!user) return ; const busy = isPending || isUploading; - // ── Sidebar preview element (shared between desktop and mobile) ─────── - const sidebarPreview = ( -
-
-
- - Sidebar Preview -
-
-
- -
-
- ); - return (
{/* Hidden file input for avatar/banner */} @@ -661,191 +652,195 @@ export function ProfileSettings() { - {/* Two-column layout: form + sidebar preview on xl+ */} -
- {/* Left column — form */} -
- + + - {/* Intro */} -
- -
-

Your Identity

-

- Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image. -

-
+ {/* Intro */} +
+ +
+

Your Identity

+

+ Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image. +

+
- {/* Interactive profile card */} - form.setValue('shape', shape, { shouldDirty: true })} - onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })} - /> + {/* Interactive profile card */} + form.setValue('shape', shape, { shouldDirty: true })} + onRemoveAvatar={() => form.setValue('picture', '', { shouldDirty: true })} + /> - {isUploading && ( -
- - Uploading… -
- )} - - {/* Profile fields */} -
-

- Profile Fields - -

-
- {/* Website — always first */} - ( -
-
-
- Website -
- -
-
- )} - /> - - {/* Lightning address */} - ( -
-
-
- Lightning - -
- -
-
- )} - /> - - - f.id)} strategy={verticalListSortingStrategy}> - {fields.map((field, index) => ( - remove(index)} - onMediaPick={() => handleMediaPick(index)} - onTickerChange={(ticker) => form.setValue(`fields.${index}.label`, ticker, { shouldDirty: true })} - /> - ))} - - - - {/* Add field dropdown with presets */} - - - - - - {FIELD_PRESETS.map((preset) => { - const Icon = preset.icon; - return ( - handleAddPreset(preset)} className="flex items-start gap-2.5 py-2"> - -
-
{preset.label}
-
{preset.description}
-
-
- ); - })} - - handleAddPreset(CUSTOM_PRESET)} className="flex items-start gap-2.5 py-2"> - -
-
{CUSTOM_PRESET.label}
-
{CUSTOM_PRESET.description}
-
-
-
-
-
+ {isUploading && ( +
+ + Uploading…
+ )} - {/* Mobile sidebar preview */} -
- - - - - - {sidebarPreview} - - + + + {FIELD_PRESETS.map((preset) => { + const Icon = preset.icon; + return ( + handleAddPreset(preset)} className="flex items-start gap-2.5 py-2"> + +
+
{preset.label}
+
{preset.description}
+
+
+ ); + })} + + handleAddPreset(CUSTOM_PRESET)} className="flex items-start gap-2.5 py-2"> + +
+
{CUSTOM_PRESET.label}
+
{CUSTOM_PRESET.description}
+
+
+
+
+
- {/* Advanced */} - + {/* Mobile sidebar preview — visible only below xl where the real sidebar is hidden */} +
+ - ( - -
- Bot Account - Mark this account as automated -
- - - -
+
+ {previewFields.length > 0 ? ( +
+ {previewFields.map((field, i) => ( +
+
{field.label}
+

{field.value}

+
+ ))} +
+ ) : ( +

+ Add fields above to see a preview here. +

)} - /> +
- - - - - {/* Right column — live sidebar preview (xl+ only) */} -
-
- {sidebarPreview}
-
-
+ + {/* Advanced */} + + + + + + ( + +
+ Bot Account + Mark this account as automated +
+ + + +
+ )} + /> +
+
+ + +
); } From 4e18d91feb6bc98f266260ce06cb0268ec7c411d Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:35:39 -0500 Subject: [PATCH 04/50] fix: improve media field placeholder language to emphasize uploading Replace misleading 'URL' placeholder on media fields with action-oriented text like 'Upload audio or paste direct file link' per preset type. Update upload button tooltips to say 'Choose file to upload'. Update Music preset description to say 'Upload' consistently. Each preset now carries its own valuePlaceholder through the form schema to the row component. --- src/pages/ProfileSettings.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index f8498b7e..2e9558bc 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -97,13 +97,13 @@ const FIELD_PRESETS: FieldPreset[] = [ { id: 'music', label: 'Music', - description: 'Add a song or audio clip', + description: 'Upload a song or audio clip', icon: Music, defaultLabel: '\u{1F3B6}', type: 'media', accept: 'audio/*', formatHint: 'MP3, OGG, WAV, FLAC, AAC, M4A, Opus', - valuePlaceholder: 'Audio URL', + valuePlaceholder: 'Upload audio or paste direct file link', }, { id: 'photo', @@ -114,7 +114,7 @@ const FIELD_PRESETS: FieldPreset[] = [ type: 'media', accept: 'image/*', formatHint: 'JPG, PNG, GIF, WebP, SVG, AVIF', - valuePlaceholder: 'Image URL', + valuePlaceholder: 'Upload image or paste direct file link', }, { id: 'video', @@ -125,7 +125,7 @@ const FIELD_PRESETS: FieldPreset[] = [ type: 'media', accept: 'video/*', formatHint: 'MP4, WebM, MOV', - valuePlaceholder: 'Video URL', + valuePlaceholder: 'Upload video or paste direct file link', }, { id: 'email', @@ -204,6 +204,8 @@ const formSchema = n.metadata().extend({ type: z.enum(['text', 'wallet', 'media']), /** Client-side only — file accept filter for the file picker (not persisted). */ accept: z.string().optional(), + /** Client-side only — placeholder text for the value input (not persisted). */ + placeholder: z.string().optional(), })).optional(), shape: z.string().optional(), }); @@ -224,6 +226,7 @@ interface SortableFieldRowProps { index: number; type: 'text' | 'wallet' | 'media'; accept?: string; + valuePlaceholder?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any control: any; onRemove: () => void; @@ -231,7 +234,7 @@ interface SortableFieldRowProps { onTickerChange: (ticker: string) => void; } -function SortableFieldRow({ id, index, type, accept, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { +function SortableFieldRow({ id, index, type, accept, valuePlaceholder, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; @@ -300,7 +303,7 @@ function SortableFieldRow({ id, index, type, accept, control, onRemove, onMediaP
- + @@ -316,9 +319,9 @@ function SortableFieldRow({ id, index, type, accept, control, onRemove, onMediaP {formatHint ? ( - Upload file
{formatHint}
+ Choose file to upload
{formatHint}
) : ( - Upload a media file + Choose a media file to upload )}
@@ -564,6 +567,7 @@ export function ProfileSettings() { value: '', type: preset.type, accept: preset.accept, + placeholder: preset.valuePlaceholder, }); }; @@ -732,6 +736,7 @@ export function ProfileSettings() { index={index} type={form.watch(`fields.${index}.type`) ?? 'text'} accept={form.watch(`fields.${index}.accept`)} + valuePlaceholder={form.watch(`fields.${index}.placeholder`)} control={form.control} onRemove={() => remove(index)} onMediaPick={() => handleMediaPick(index)} From 5384da54e2abadf0478ad2604c5669589189ccbb Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:38:01 -0500 Subject: [PATCH 05/50] feat: warn when pasted URL doesn't match expected media file type Show an inline amber warning below media field inputs when the pasted URL doesn't have the right file extension for the field type. For example, pasting a YouTube link in a Music field warns that it won't work as an audio player and suggests uploading a file instead. Handles three cases: wrong type (e.g. image URL in audio field), no recognizable extension (e.g. youtube.com), and Blossom URLs (always allowed, type can't be determined from URL). --- src/pages/ProfileSettings.tsx | 126 +++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index 2e9558bc..c8e6cb74 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -2,7 +2,7 @@ import { useSeoMeta } from '@unhead/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, - Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, + Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle, } from 'lucide-react'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { Link, Navigate } from 'react-router-dom'; @@ -187,6 +187,61 @@ function inferFieldType(label: string, value: string): 'text' | 'wallet' | 'medi return 'text'; } +/** Extension patterns for each media accept category. */ +const AUDIO_EXT = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i; +const IMAGE_EXT = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i; +const VIDEO_EXT = /\.(mp4|webm|mov|qt)(\?.*)?$/i; + +/** + * Check whether a pasted URL matches the expected file type for a media field. + * Returns a warning message if the URL looks wrong, or undefined if it's fine. + * Only warns when the value looks like a URL — empty/non-URL values return undefined. + */ +function getMediaMismatchWarning(value: string, accept: string | undefined): string | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + // Only check if it looks like a URL + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return undefined; + + // Blossom-style URLs (hex hash path) are always fine — type can't be determined from URL + if (/^https?:\/\/.+\/[0-9a-f]{64}(\.\w+)?$/i.test(trimmed)) return undefined; + + // Check if URL has a recognizable file extension at all + const hasAudioExt = AUDIO_EXT.test(trimmed); + const hasImageExt = IMAGE_EXT.test(trimmed); + const hasVideoExt = VIDEO_EXT.test(trimmed); + const hasKnownExt = hasAudioExt || hasImageExt || hasVideoExt; + + if (accept === 'audio/*') { + if (hasKnownExt && !hasAudioExt) { + return 'This URL doesn\u2019t point to an audio file. Upload an audio file or use a direct link ending in .mp3, .ogg, .wav, etc.'; + } + if (!hasKnownExt) { + return 'This URL may not work as an audio player. For best results, upload a file using the button or paste a direct link to an audio file.'; + } + } + + if (accept === 'image/*') { + if (hasKnownExt && !hasImageExt) { + return 'This URL doesn\u2019t point to an image. Upload an image or use a direct link ending in .jpg, .png, .webp, etc.'; + } + if (!hasKnownExt) { + return 'This URL may not display as an image. For best results, upload a file using the button or paste a direct link to an image file.'; + } + } + + if (accept === 'video/*') { + if (hasKnownExt && !hasVideoExt) { + return 'This URL doesn\u2019t point to a video. Upload a video or use a direct link ending in .mp4, .webm, .mov, etc.'; + } + if (!hasKnownExt) { + return 'This URL may not display as a video. For best results, upload a file using the button or paste a direct link to a video file.'; + } + } + + return undefined; +} + /** Infer a file-accept filter from an existing field's value URL. */ function inferAcceptFromValue(value: string): string | undefined { if (/\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i.test(value)) return 'audio/*'; @@ -299,36 +354,45 @@ function SortableFieldRow({ id, index, type, accept, valuePlaceholder, control, ( - -
- - - - - - - - - {formatHint ? ( - Choose file to upload
{formatHint}
- ) : ( - Choose a media file to upload - )} -
-
-
- -
- )} + render={({ field }) => { + const mismatchWarning = getMediaMismatchWarning(field.value, accept); + return ( + +
+ + + + + + + + + {formatHint ? ( + Choose file to upload
{formatHint}
+ ) : ( + Choose a media file to upload + )} +
+
+
+ {mismatchWarning && ( +

+ + {mismatchWarning} +

+ )} + +
+ ); + }} /> ) : ( Date: Mon, 16 Mar 2026 14:44:37 -0500 Subject: [PATCH 06/50] feat: render images and videos inline in profile sidebar fields Add isImageUrl and isVideoUrl helpers alongside the existing isAudioUrl. Profile field values that point to image files now render as inline images (clickable to open full size), and video files render using the VideoPlayer component. Both the desktop sidebar (ProfileFieldRow) and mobile inline view (ProfileFieldInline) support the new rendering. --- src/components/MiniAudioPlayer.tsx | 18 +++++++++++++++ src/components/ProfileRightSidebar.tsx | 32 ++++++++++++++++++++++++-- src/pages/ProfilePage.tsx | 30 +++++++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/components/MiniAudioPlayer.tsx b/src/components/MiniAudioPlayer.tsx index 20cda618..140897df 100644 --- a/src/components/MiniAudioPlayer.tsx +++ b/src/components/MiniAudioPlayer.tsx @@ -6,12 +6,30 @@ import { formatTime } from '@/lib/formatTime'; /** Audio file extensions used to detect audio URLs. */ const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i; +/** Image file extensions used to detect image URLs. */ +const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i; + +/** Video file extensions used to detect video URLs. */ +const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i; + /** Check whether a URL points to an audio file by extension. */ export function isAudioUrl(url: string): boolean { if (!url.startsWith('http://') && !url.startsWith('https://')) return false; return AUDIO_EXTENSIONS.test(url); } +/** Check whether a URL points to an image file by extension. */ +export function isImageUrl(url: string): boolean { + if (!url.startsWith('http://') && !url.startsWith('https://')) return false; + return IMAGE_EXTENSIONS.test(url); +} + +/** Check whether a URL points to a video file by extension. */ +export function isVideoUrl(url: string): boolean { + if (!url.startsWith('http://') && !url.startsWith('https://')) return false; + return VIDEO_EXTENSIONS.test(url); +} + interface MiniAudioPlayerProps { src: string; label?: string; diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index cba841cc..541e3954 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -15,7 +15,8 @@ import type { AddrCoords } from '@/hooks/useEvent'; import QRCode from 'qrcode'; import { useAppContext } from '@/hooks/useAppContext'; import { getContentWarning } from '@/lib/contentWarning'; -import { MiniAudioPlayer, isAudioUrl } from '@/components/MiniAudioPlayer'; +import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer'; +import { VideoPlayer } from '@/components/VideoPlayer'; import { parseDimToAspectRatio } from '@/components/MediaCollage'; /** Simple email regex for display purposes. */ @@ -368,7 +369,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) { ); } - // Audio file: render mini player + // Media fields: render inline players/previews based on file extension const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://'); if (isUrl && isAudioUrl(field.value)) { @@ -380,6 +381,33 @@ function ProfileFieldRow({ field }: { field: ProfileField }) { ); } + if (isUrl && isImageUrl(field.value)) { + return ( +
+ {field.label &&
{field.label}
} + + {field.label + +
+ ); + } + + if (isUrl && isVideoUrl(field.value)) { + return ( +
+ {field.label &&
{field.label}
} +
+ +
+
+ ); + } + // Regular field: label + linked value with favicon return (
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index d95e7949..b80d7a7b 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -52,7 +52,8 @@ import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { PullToRefresh } from '@/components/PullToRefresh'; import { ReportDialog } from '@/components/ReportDialog'; import { AddToListDialog } from '@/components/AddToListDialog'; -import { MiniAudioPlayer, isAudioUrl } from '@/components/MiniAudioPlayer'; +import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer'; +import { VideoPlayer } from '@/components/VideoPlayer'; import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme'; import { usePublishTheme } from '@/hooks/usePublishTheme'; @@ -635,6 +636,33 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string } return ; } + if (isUrl && isImageUrl(field.value)) { + return ( +
+ {field.label &&
{field.label}
} + + {field.label + +
+ ); + } + + if (isUrl && isVideoUrl(field.value)) { + return ( +
+ {field.label &&
{field.label}
} +
+ +
+
+ ); + } + if (isUrl) { return (
From a8a09a2c669d2686a67056b043bc8ffe75d73911 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:49:50 -0500 Subject: [PATCH 07/50] feat: show per-field upload spinner on media field rows Track which field index is currently uploading and replace the upload button with a spinning Loader2 icon on that specific field row during upload, giving immediate local feedback instead of relying on the distant save button spinner. --- src/pages/ProfileSettings.tsx | 54 +++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index c8e6cb74..35f81b0d 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -282,6 +282,7 @@ interface SortableFieldRowProps { type: 'text' | 'wallet' | 'media'; accept?: string; valuePlaceholder?: string; + isUploading?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any control: any; onRemove: () => void; @@ -289,7 +290,7 @@ interface SortableFieldRowProps { onTickerChange: (ticker: string) => void; } -function SortableFieldRow({ id, index, type, accept, valuePlaceholder, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { +function SortableFieldRow({ id, index, type, accept, valuePlaceholder, isUploading: fieldUploading, control, onRemove, onMediaPick, onTickerChange }: SortableFieldRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; @@ -362,26 +363,32 @@ function SortableFieldRow({ id, index, type, accept, valuePlaceholder, control, - - - - - - {formatHint ? ( - Choose file to upload
{formatHint}
- ) : ( - Choose a media file to upload - )} -
-
+ {fieldUploading ? ( +
+ +
+ ) : ( + + + + + + {formatHint ? ( + Choose file to upload
{formatHint}
+ ) : ( + Choose a media file to upload + )} +
+
+ )}
{mismatchWarning && (

@@ -435,6 +442,7 @@ export function ProfileSettings() { const [cropState, setCropState] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [showMobilePreview, setShowMobilePreview] = useState(false); + const [uploadingFieldIndex, setUploadingFieldIndex] = useState(-1); useSeoMeta({ title: `Profile | Settings | ${config.appName}`, @@ -516,12 +524,15 @@ export function ProfileSettings() { e.target.value = ''; const index = pendingMediaIndex.current; if (index < 0) return; + setUploadingFieldIndex(index); try { const [[, url]] = await uploadFile(file); form.setValue(`fields.${index}.value`, url, { shouldDirty: true }); toast({ title: 'Uploaded', description: 'Media file uploaded' }); } catch { toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' }); + } finally { + setUploadingFieldIndex(-1); } }; @@ -801,6 +812,7 @@ export function ProfileSettings() { type={form.watch(`fields.${index}.type`) ?? 'text'} accept={form.watch(`fields.${index}.accept`)} valuePlaceholder={form.watch(`fields.${index}.placeholder`)} + isUploading={uploadingFieldIndex === index} control={form.control} onRemove={() => remove(index)} onMediaPick={() => handleMediaPick(index)} From 4fd3859fb3df17cb365df60b0d29997d09d9094b Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:56:29 -0500 Subject: [PATCH 08/50] feat: replace Add Field dropdown with visible pill buttons Replace the hidden dropdown menu with a wrapping row of small pill-shaped buttons, each showing the preset icon and label (Music, Photo, Video, Email, Link, Wallet, Custom). Users can now see all available field types at a glance and add one with a single tap. Descriptions are preserved as hover tooltips on each pill. --- src/pages/ProfileSettings.tsx | 70 ++++++++++++++--------------------- 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index 35f81b0d..0df2b76f 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -1,7 +1,7 @@ import { useSeoMeta } from '@unhead/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, + ArrowLeft, Loader2, Trash2, ChevronDown, GripVertical, Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle, } from 'lucide-react'; import { useLayoutOptions } from '@/contexts/LayoutContext'; @@ -34,13 +34,7 @@ import { useToast } from '@/hooks/useToast'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; + import { Select, SelectContent, @@ -822,41 +816,31 @@ export function ProfileSettings() { - {/* Add field dropdown with presets */} - - - - - - {FIELD_PRESETS.map((preset) => { - const Icon = preset.icon; - return ( - handleAddPreset(preset)} className="flex items-start gap-2.5 py-2"> - -

-
{preset.label}
-
{preset.description}
-
- - ); - })} - - handleAddPreset(CUSTOM_PRESET)} className="flex items-start gap-2.5 py-2"> - -
-
{CUSTOM_PRESET.label}
-
{CUSTOM_PRESET.description}
-
-
- - + {/* Add field — visible pill buttons */} +
+ {[...FIELD_PRESETS, CUSTOM_PRESET].map((preset) => { + const Icon = preset.icon; + return ( + + + + + + {preset.description} + + + ); + })} +
From 300d0f94b99d6e55d078743f99c80ad238f0f9d5 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 14:58:21 -0500 Subject: [PATCH 09/50] fix: move field preset pills between header and field rows Place the pill buttons directly after the 'Profile Fields' heading so users see the available content types immediately, before the existing Website and Lightning fields. --- src/pages/ProfileSettings.tsx | 54 ++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index 0df2b76f..cfa60524 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -762,7 +762,34 @@ export function ProfileSettings() { Profile Fields -
+ + {/* Add field — visible pill buttons */} +
+ {[...FIELD_PRESETS, CUSTOM_PRESET].map((preset) => { + const Icon = preset.icon; + return ( + + + + + + {preset.description} + + + ); + })} +
+ +
{/* Website — always first */} - {/* Add field — visible pill buttons */} -
- {[...FIELD_PRESETS, CUSTOM_PRESET].map((preset) => { - const Icon = preset.icon; - return ( - - - - - - {preset.description} - - - ); - })} -
From 42c6faa748afba2d719a4d96dd6459e926841cfb Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 15:00:34 -0500 Subject: [PATCH 10/50] feat: load user's media events in profile settings sidebar preview Use the same useProfileMedia query as the profile page to fetch the current user's media events and pass them to ProfileRightSidebar in the settings page. The sidebar preview now shows the media collage section just like it appears on the actual profile. --- src/pages/ProfileSettings.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index cfa60524..eaa9e233 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -30,6 +30,7 @@ import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { useProfileMedia } from '@/hooks/useProfileMedia'; import { useToast } from '@/hooks/useToast'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -433,6 +434,27 @@ export function ProfileSettings() { const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); + + // Fetch media events for the sidebar preview (same query as profile page) + const { + data: mediaData, + isPending: mediaPending, + } = useProfileMedia(user?.pubkey); + const mediaEvents = useMemo(() => { + if (!mediaData?.pages) return []; + const seen = new Set(); + const events: import('@nostrify/nostrify').NostrEvent[] = []; + for (const page of mediaData.pages) { + for (const event of page.events) { + if (!seen.has(event.id)) { + seen.add(event.id); + events.push(event); + } + } + } + return events; + }, [mediaData?.pages]); + const [cropState, setCropState] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [showMobilePreview, setShowMobilePreview] = useState(false); @@ -671,7 +693,7 @@ export function ProfileSettings() { // Inject live sidebar preview into the app's right sidebar slot useLayoutOptions({ - rightSidebar: , + rightSidebar: , }); if (!user) return ; From e07dd212d5a7f7b2669aaea6aeaf5b5a62e13727 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 15:02:09 -0500 Subject: [PATCH 11/50] fix: add plus icon to field preset pill buttons Each pill now shows a small + icon before the preset icon and label, making it visually clear they are add-actions rather than just labels. --- src/pages/ProfileSettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/ProfileSettings.tsx b/src/pages/ProfileSettings.tsx index eaa9e233..3c0fe221 100644 --- a/src/pages/ProfileSettings.tsx +++ b/src/pages/ProfileSettings.tsx @@ -1,7 +1,7 @@ import { useSeoMeta } from '@unhead/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ArrowLeft, Loader2, Trash2, ChevronDown, GripVertical, + ArrowLeft, Loader2, Plus, Trash2, ChevronDown, GripVertical, Wallet, Upload, Music, ImageIcon, Film, Mail, Link2, Pencil, Eye, AlertTriangle, } from 'lucide-react'; import { useLayoutOptions } from '@/contexts/LayoutContext'; @@ -799,6 +799,7 @@ export function ProfileSettings() { className="h-7 rounded-full px-3 text-xs gap-1.5" onClick={() => handleAddPreset(preset)} > + {preset.label} From 90d62557fe270974210b7460bb804e6885942b08 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Mon, 16 Mar 2026 15:08:23 -0500 Subject: [PATCH 12/50] fix: swap Wallet/Link order, use real sidebar component for mobile preview Swap Wallet and Link pill button order. Replace the barebones mobile preview with the real ProfileRightSidebar component (with media collage, audio players, etc.) by adding a className prop to override the default hidden xl:flex classes. Rename 'Sidebar Preview' to 'Profile Fields Preview' since on mobile it's not in a sidebar. --- src/components/ProfileRightSidebar.tsx | 7 +++-- src/pages/ProfileSettings.tsx | 42 +++++++++++--------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index 541e3954..d42893af 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { Check, Copy, QrCode, ExternalLink, Bitcoin, ShieldAlert, Mail } from 'lucide-react'; import { Blurhash } from 'react-blurhash'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; @@ -67,6 +68,8 @@ interface ProfileRightSidebarProps { mediaLoading?: boolean; /** Called when a media tile is clicked. If provided, tiles don't navigate. */ onMediaClick?: (url: string) => void; + /** Override the root element's className (e.g. to show on mobile). */ + className?: string; } interface MediaItem { @@ -458,7 +461,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh return rows; } -export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick }: ProfileRightSidebarProps) { +export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLoadingProp, onMediaClick, className }: ProfileRightSidebarProps) { const { config } = useAppContext(); const media = useMemo( () => extractMedia(mediaEvents ?? [], config.contentWarningPolicy), @@ -469,7 +472,7 @@ export function ProfileRightSidebar({ fields, mediaEvents, mediaLoading: mediaLo const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]); return ( -