From f82adab05d46408758fd2d746b6c966044acae40 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 14:15:50 -0500 Subject: [PATCH 01/16] Fix: add letter (kind 8211) notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letters were completely absent from the notification pipeline — users had to visit the Letters page to discover incoming letters. This integrates kind 8211 into every layer of the notification system: - useNotifications: query, grouping, and referenced-event exclusion - useHasUnreadNotifications: unread dot indicator - NotificationsPage: LetterNotification component with link to /letters - NotificationSettings: toggleable Letters row - notificationTemplates: web push template - Android NotificationRelayService + NostrPoller: native push support - EncryptedSettings + schema: letters preference field Closes #188 --- .../main/java/pub/ditto/app/NostrPoller.java | 1 + .../ditto/app/NotificationRelayService.java | 2 +- src/hooks/useEncryptedSettings.ts | 1 + src/hooks/useHasUnreadNotifications.ts | 3 +- src/hooks/useNotifications.ts | 14 +++++---- src/lib/notificationTemplates.ts | 6 ++++ src/lib/schemas.ts | 1 + src/pages/NotificationSettings.tsx | 11 +++++-- src/pages/NotificationsPage.tsx | 29 ++++++++++++++++++- 9 files changed, 57 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/pub/ditto/app/NostrPoller.java b/android/app/src/main/java/pub/ditto/app/NostrPoller.java index 30ed0b94..dd297d01 100644 --- a/android/app/src/main/java/pub/ditto/app/NostrPoller.java +++ b/android/app/src/main/java/pub/ditto/app/NostrPoller.java @@ -265,6 +265,7 @@ public class NostrPoller { } return "commented on your post"; } + case 8211: return "sent you a letter"; default: return "mentioned you"; } } diff --git a/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java b/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java index ab01a2bf..f32a106e 100644 --- a/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java +++ b/android/app/src/main/java/pub/ditto/app/NotificationRelayService.java @@ -284,7 +284,7 @@ public class NotificationRelayService extends Service { try { JSONObject filter = new JSONObject(); JSONArray kinds = new JSONArray(); - kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111); + kinds.put(1); kinds.put(6); kinds.put(16); kinds.put(7); kinds.put(9735); kinds.put(1111); kinds.put(8211); filter.put("kinds", kinds); JSONArray pTags = new JSONArray(); pTags.put(userPubkey); diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts index 19485b6f..d70ce439 100644 --- a/src/hooks/useEncryptedSettings.ts +++ b/src/hooks/useEncryptedSettings.ts @@ -47,6 +47,7 @@ export interface EncryptedSettings { mentions?: boolean; comments?: boolean; badges?: boolean; + letters?: boolean; onlyFollowing?: boolean; }; /** Last sync timestamp */ diff --git a/src/hooks/useHasUnreadNotifications.ts b/src/hooks/useHasUnreadNotifications.ts index c652f5f6..ddf044e0 100644 --- a/src/hooks/useHasUnreadNotifications.ts +++ b/src/hooks/useHasUnreadNotifications.ts @@ -4,6 +4,7 @@ import { Capacitor } from '@capacitor/core'; import { useCurrentUser } from './useCurrentUser'; import { useEncryptedSettings } from './useEncryptedSettings'; +import { LETTER_KIND } from '@/lib/letterTypes'; /** * Lightweight hook that checks whether the user has any unread notifications. @@ -30,7 +31,7 @@ export function useHasUnreadNotifications(): boolean { const events = await nostr.query( [{ - kinds: [1, 6, 16, 7, 9735, 1111, 1222, 1244], + kinds: [1, 6, 16, 7, 9735, 1111, 1222, 1244, LETTER_KIND], '#p': [user.pubkey], since: notificationsCursor + 1, limit: 1, diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 5ebc99ac..b5dadc71 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -7,14 +7,15 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { useEncryptedSettings } from './useEncryptedSettings'; import { useFollowList } from './useFollowActions'; +import { LETTER_KIND } from '@/lib/letterTypes'; const PAGE_SIZE = 20; /** All kinds that can appear as notifications. */ -const ALL_NOTIFICATION_KINDS = [1, 6, 16, 7, 8, 9735, 1111, 1222, 1244] as const; +const ALL_NOTIFICATION_KINDS = [1, 6, 16, 7, 8, 9735, 1111, 1222, 1244, LETTER_KIND] as const; export interface NotificationItem { - /** The notification event (kind 1, 6, 16, 7, 8, 9735, 1111, 1222, or 1244). */ + /** The notification event (kind 1, 6, 16, 7, 8, 9735, 1111, 1222, 1244, or 8211). */ event: NostrEvent; /** The referenced event (the post that was liked/reposted/zapped), if available. */ referencedEvent?: NostrEvent; @@ -106,7 +107,7 @@ function groupKey(item: NotificationItem): string { return `${bucket}:${refId}`; } - // Mentions (kind 1) and comments (kind 1111) are always standalone + // Mentions (kind 1), comments (kind 1111), and letters (8211) are always standalone return event.id; } @@ -176,6 +177,7 @@ function getEnabledNotificationKinds( if (p.mentions !== false) kinds.push(1); if (p.comments !== false) kinds.push(1111, 1222, 1244); if (p.badges !== false) kinds.push(8); + if (p.letters !== false) kinds.push(LETTER_KIND); // Always fall back to all kinds so the query never sends an empty kinds array return kinds.length > 0 ? kinds : [...ALL_NOTIFICATION_KINDS]; @@ -248,9 +250,9 @@ export function useNotifications(): NotificationData { // Collect referenced event IDs for batch fetching const referencedIds: string[] = []; for (const ev of filtered) { - // kind 1 (mention) and voice messages (1222/1244) ARE the notification content; + // kind 1 (mention), voice messages (1222/1244), and letters (8211) ARE the notification content; // kind 1111 (comment) IS the content but we also fetch its parent for context. - if (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244) { + if (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) { const refId = getReferencedEventId(ev); if (refId) referencedIds.push(refId); } @@ -292,7 +294,7 @@ export function useNotifications(): NotificationData { // Build notification items, filtering out reactions/reposts on posts the // user didn't author (i.e. they were only tagged in them). const items: NotificationItem[] = filtered.flatMap((ev) => { - const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244) ? getReferencedEventId(ev) : undefined; + const refId = (ev.kind !== 1 && ev.kind !== 1222 && ev.kind !== 1244 && ev.kind !== LETTER_KIND) ? getReferencedEventId(ev) : undefined; const referencedEvent = refId ? referencedMap.get(refId) : undefined; // For reactions (7), reposts (6, 16), and zaps (9735), only notify if diff --git a/src/lib/notificationTemplates.ts b/src/lib/notificationTemplates.ts index c2af4e38..3a97e461 100644 --- a/src/lib/notificationTemplates.ts +++ b/src/lib/notificationTemplates.ts @@ -52,4 +52,10 @@ export const NOTIFICATION_TEMPLATES: NotificationTemplate[] = [ title: '{{author_name}} Commented!', body: '{{content}}', }, + { + id: 'letters', + kinds: [8211], + title: '{{author_name}} sent you a letter!', + body: 'You have a new letter waiting for you.', + }, ]; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 70b91513..8faeec0f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -310,6 +310,7 @@ export const EncryptedSettingsSchema = z.looseObject({ mentions: z.boolean().optional(), comments: z.boolean().optional(), badges: z.boolean().optional(), + letters: z.boolean().optional(), onlyFollowing: z.boolean().optional(), }).optional(), lastSync: z.number().optional(), diff --git a/src/pages/NotificationSettings.tsx b/src/pages/NotificationSettings.tsx index 91b9aab6..91176c96 100644 --- a/src/pages/NotificationSettings.tsx +++ b/src/pages/NotificationSettings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { Capacitor } from '@capacitor/core'; import { useSeoMeta } from '@unhead/react'; -import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award } from 'lucide-react'; +import { Bell, BellOff, AlertTriangle, Heart, Repeat2, Zap, AtSign, MessageSquare, Users, Award, Mail } from 'lucide-react'; import { Navigate } from 'react-router-dom'; import { PageHeader } from '@/components/PageHeader'; import { Switch } from '@/components/ui/switch'; @@ -11,7 +11,7 @@ import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; import { usePushNotifications } from '@/hooks/usePushNotifications'; import { toast } from '@/hooks/useToast'; -type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments' | 'badges'; +type NotificationPrefKey = 'reactions' | 'reposts' | 'zaps' | 'mentions' | 'comments' | 'badges' | 'letters'; interface NotificationTypeRow { key: NotificationPrefKey; @@ -64,6 +64,13 @@ const NOTIFICATION_TYPES: NotificationTypeRow[] = [ description: 'When someone awards you a badge', icon: , }, + { + key: 'letters', + label: 'Letters', + kinds: [8211], + description: 'When someone sends you a letter', + icon: , + }, ]; function KindBadge({ kind }: { kind: number }) { diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 578ea423..9ddf5101 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import { useInView } from 'react-intersection-observer'; import { useSeoMeta } from '@unhead/react'; import { useQueryClient } from '@tanstack/react-query'; -import { Zap, AtSign, MessageSquare, Loader2, Award, Check } from 'lucide-react'; +import { Zap, AtSign, MessageSquare, Loader2, Award, Check, Mail } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { Link } from 'react-router-dom'; import { PullToRefresh } from '@/components/PullToRefresh'; @@ -31,6 +31,7 @@ import { useAcceptBadge } from '@/hooks/useAcceptBadge'; import { useProfileBadges } from '@/hooks/useProfileBadges'; import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; +import { LETTER_KIND } from '@/lib/letterTypes'; import { Button } from '@/components/ui/button'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { useLayoutOptions } from '@/contexts/LayoutContext'; @@ -199,6 +200,8 @@ function GroupedNotificationView({ group }: { group: GroupedNotificationItem }) return solo ? : ; + case LETTER_KIND: + return ; default: return null; } @@ -596,6 +599,30 @@ function CommentNotification({ item, isNew }: { item: NotificationItem; isNew: b ); } +// ────────────────────────────────────── +// Letter Notification (kind 8211, always standalone) +// ────────────────────────────────────── +function LetterNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { + return ( + +
+ } + action="sent you a letter" + /> + + + View in Letters + +
+
+ ); +} + // ────────────────────────────────────── // Badge Award helpers // ────────────────────────────────────── From 8d02645e26a5040a524dc60d4ccd04f32f7238e2 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 14:31:05 -0500 Subject: [PATCH 02/16] Enhance letter notification with envelope card preview Show the sender's profile pic via the EnvelopeCard component (the same Wii-Mail-inspired envelope tile used in the letters inbox). The card auto-decrypts to display stationery colors and the sender's avatar as a wax seal. Clicking the envelope navigates to /letters. --- src/pages/NotificationsPage.tsx | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 9ddf5101..4c8846d1 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -4,7 +4,7 @@ import { useSeoMeta } from '@unhead/react'; import { useQueryClient } from '@tanstack/react-query'; import { Zap, AtSign, MessageSquare, Loader2, Award, Check, Mail } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { PullToRefresh } from '@/components/PullToRefresh'; import { useAppContext } from '@/hooks/useAppContext'; @@ -31,7 +31,8 @@ import { useAcceptBadge } from '@/hooks/useAcceptBadge'; import { useProfileBadges } from '@/hooks/useProfileBadges'; import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; -import { LETTER_KIND } from '@/lib/letterTypes'; +import { LETTER_KIND, type Letter } from '@/lib/letterTypes'; +import { EnvelopeCard } from '@/components/letter/EnvelopeCard'; import { Button } from '@/components/ui/button'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { useLayoutOptions } from '@/contexts/LayoutContext'; @@ -603,21 +604,34 @@ function CommentNotification({ item, isNew }: { item: NotificationItem; isNew: b // Letter Notification (kind 8211, always standalone) // ────────────────────────────────────── function LetterNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { + const navigate = useNavigate(); + + const letter = useMemo(() => ({ + event: item.event, + sender: item.event.pubkey, + recipient: item.event.tags.find(([name]) => name === 'p')?.[1] ?? '', + decrypted: false, + timestamp: item.event.created_at, + }), [item.event]); + return ( -
+
} action="sent you a letter" /> - - - View in Letters - +
+
+
+ navigate('/letters')} + /> +
); From abcb51c0e2dcdb649be2559b4576b45d786a4be6 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 14:44:33 -0500 Subject: [PATCH 03/16] Show badge thumbnail preview in badge award notifications Closes #186. Badge award notifications now display a visual preview card with the badge image, name, and description for both single and grouped badge notifications. --- src/pages/NotificationsPage.tsx | 53 +++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 578ea423..28ab453a 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -32,6 +32,8 @@ import { useProfileBadges } from '@/hooks/useProfileBadges'; import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions'; import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; import { Button } from '@/components/ui/button'; +import { BadgeThumbnail } from '@/components/BadgeThumbnail'; +import type { BadgeData } from '@/components/BadgeContent'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { useLayoutOptions } from '@/contexts/LayoutContext'; @@ -616,15 +618,19 @@ function unslugify(slug: string): string { .replace(/\b\w/g, (c) => c.toUpperCase()); } -/** Hook: resolve the display name for a single badge award event. */ -function useBadgeAwardName(awardEvent: NostrEvent): string | undefined { +/** Hook: resolve the display name and badge data for a single badge award event. */ +function useBadgeAward(awardEvent: NostrEvent): { name: string | undefined; badge: BadgeData | undefined } { const parsed = useMemo(() => parseBadgeATag(awardEvent), [awardEvent]); const refs = useMemo(() => (parsed ? [parsed] : []), [parsed]); const { badgeMap } = useBadgeDefinitions(refs); - if (!parsed) return undefined; + if (!parsed) return { name: undefined, badge: undefined }; const aTag = `${BADGE_DEFINITION_KIND}:${parsed.pubkey}:${parsed.identifier}`; - return badgeMap.get(aTag)?.name || unslugify(parsed.identifier); + const definition = badgeMap.get(aTag); + return { + name: definition?.name || unslugify(parsed.identifier), + badge: definition ?? undefined, + }; } // ────────────────────────────────────── @@ -675,7 +681,7 @@ function AcceptBadgeButton({ awardEvent }: { awardEvent: NostrEvent }) { // Badge Award Notification (single actor) // ────────────────────────────────────── function BadgeAwardNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) { - const badgeName = useBadgeAwardName(item.event); + const { name: badgeName, badge } = useBadgeAward(item.event); return ( @@ -692,6 +698,17 @@ function BadgeAwardNotification({ item, isNew }: { item: NotificationItem; isNew
+ {badge && ( +
+ +
+

{badge.name}

+ {badge.description && ( +

{badge.description}

+ )} +
+
+ )}
); @@ -723,17 +740,27 @@ function BadgeAwardNotificationGroup({ group }: { group: GroupedNotificationItem {group.actors.map((actor) => { const parsed = parseBadgeATag(actor.event); const aTag = parsed ? `${BADGE_DEFINITION_KIND}:${parsed.pubkey}:${parsed.identifier}` : undefined; - const badgeName = aTag ? badgeMap.get(aTag)?.name : undefined; - const displayName = badgeName || (parsed ? unslugify(parsed.identifier) : undefined); + const badge = aTag ? badgeMap.get(aTag) : undefined; + const displayName = badge?.name || (parsed ? unslugify(parsed.identifier) : undefined); return ( -
- {displayName && ( - - {displayName} - +
+ {badge ? ( + + ) : ( +
+ +
)} -
+
+ {displayName && ( +

{displayName}

+ )} + {badge?.description && ( +

{badge.description}

+ )} +
+
From 32b0cef65d9c676fefc13f9dafd973f578b581c8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 27 Mar 2026 14:57:51 -0500 Subject: [PATCH 04/16] Change toast swipe direction from right to up for mobile dismissal --- src/components/ui/toast.tsx | 2 +- src/components/ui/toaster.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 5a6dc996..bb837146 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -23,7 +23,7 @@ const ToastViewport = React.forwardRef< ToastViewport.displayName = ToastPrimitives.Viewport.displayName const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-y-0 data-[swipe=end]:translate-y-[var(--radix-toast-swipe-end-y)] data-[swipe=move]:translate-y-[var(--radix-toast-swipe-move-y)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", { variants: { variant: { diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 18edc7cf..7831be1d 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -12,7 +12,7 @@ export function Toaster() { const { toasts } = useToast() return ( - + {toasts.map(function ({ id, title, description, action, ...props }) { return ( From 3c54cd27fec167f585c6aa51134d2206a5e2bc98 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 27 Mar 2026 15:41:39 -0500 Subject: [PATCH 05/16] Fix package-lock name --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88335278..e84b6288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "mkstack", + "name": "ditto", "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mkstack", + "name": "ditto", "version": "2.1.0", "dependencies": { "@capacitor/app": "^8.0.0", From da27054a9b33f9f861862b09a04e68494a1528a6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 27 Mar 2026 16:42:36 -0500 Subject: [PATCH 06/16] Fix file downloads and URL opening on Capacitor iOS The and patterns don't work in WKWebView. Add downloadTextFile() and openUrl() utilities in src/lib/downloadFile.ts that use @capacitor/filesystem and @capacitor/share on native platforms, falling back to standard browser behavior on web. Update all call sites: onboarding key download (InitialSyncGate, SignupDialog), image lightbox buttons (ImageGallery, ProfilePage). Document Capacitor compatibility constraints in AGENTS.md. --- AGENTS.md | 62 ++++++++++++++++++++++++++++ android/app/capacitor.build.gradle | 2 + android/capacitor.settings.gradle | 6 +++ ios/App/CapApp-SPM/Package.swift | 4 ++ package-lock.json | 29 +++++++++++++ package.json | 2 + src/components/ImageGallery.tsx | 5 +-- src/components/InitialSyncGate.tsx | 14 ++----- src/components/auth/SignupDialog.tsx | 19 ++------- src/lib/downloadFile.ts | 57 +++++++++++++++++++++++++ src/pages/ProfilePage.tsx | 7 +--- 11 files changed, 172 insertions(+), 35 deletions(-) create mode 100644 src/lib/downloadFile.ts diff --git a/AGENTS.md b/AGENTS.md index 8e707ac2..229ea408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ This project is a Nostr client application built with React 18.x, TailwindCSS 3. - **React Router**: For client-side routing with BrowserRouter and ScrollToTop functionality - **TanStack Query**: For data fetching, caching, and state management - **TypeScript**: For type-safe JavaScript development +- **Capacitor**: Native iOS and Android shell wrapping the web app ## Project Structure @@ -1248,6 +1249,67 @@ When your changes are complete and validated, create a git commit with a descrip **ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.** +## Capacitor Compatibility + +The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features. + +### What Doesn't Work in WKWebView (iOS) + +- **`` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely. +- **`` new tabs** -- Programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app. +- **`window.open()`** -- May be blocked or behave unexpectedly without user gesture context. + +### File Downloads and URL Opening + +The project provides two utility functions in `src/lib/downloadFile.ts` that handle the web/native split automatically: + +#### `downloadTextFile(filename, content)` + +Saves a text file to the user's device. On web it uses the `` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`. + +```typescript +import { downloadTextFile } from '@/lib/downloadFile'; + +await downloadTextFile('backup.txt', fileContents); +``` + +#### `openUrl(url)` + +Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor. + +```typescript +import { openUrl } from '@/lib/downloadFile'; + +await openUrl('https://example.com/image.jpg'); +``` + +**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. Always use the utilities above. They handle the Capacitor/web split and will work correctly on all platforms. + +### Detecting Native Platforms + +Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior: + +```typescript +import { Capacitor } from '@capacitor/core'; + +if (Capacitor.isNativePlatform()) { + // iOS or Android +} else { + // Web browser +} +``` + +### Installed Capacitor Plugins + +- `@capacitor/app` -- App lifecycle events (deep links, back button) +- `@capacitor/core` -- Core runtime and platform detection +- `@capacitor/filesystem` -- Read/write files on the native filesystem +- `@capacitor/local-notifications` -- Schedule local push notifications +- `@capacitor/share` -- Native share sheet +- `@capacitor/status-bar` -- Control the native status bar style + +After adding or removing plugins, run `npx cap sync` to update the native projects. + ## CI/CD Pipeline The project uses GitLab CI (`.gitlab-ci.yml`) with the following stages: diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index b413aeb6..2e1cb4df 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,7 +10,9 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') + implementation project(':capacitor-filesystem') implementation project(':capacitor-local-notifications') + implementation project(':capacitor-share') implementation project(':capacitor-status-bar') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 7b0a75de..8e2701a5 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -5,8 +5,14 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-app' project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + include ':capacitor-local-notifications' project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/ios/App/CapApp-SPM/Package.swift b/ios/App/CapApp-SPM/Package.swift index 29e020d1..3fb8478b 100644 --- a/ios/App/CapApp-SPM/Package.swift +++ b/ios/App/CapApp-SPM/Package.swift @@ -13,7 +13,9 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"), .package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"), + .package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"), .package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"), + .package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"), .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") ], targets: [ @@ -23,7 +25,9 @@ let package = Package( .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), + .product(name: "CapacitorShare", package: "CapacitorShare"), .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") ] ) diff --git a/package-lock.json b/package-lock.json index e84b6288..448a1557 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", + "@capacitor/filesystem": "^8.1.2", "@capacitor/local-notifications": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capacitor/status-bar": "^8.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -341,6 +343,18 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/filesystem": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz", + "integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/ios": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz", @@ -360,6 +374,15 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/share": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz", + "integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/status-bar": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz", @@ -369,6 +392,12 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", diff --git a/package.json b/package.json index 6823d2b6..996f4594 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", + "@capacitor/filesystem": "^8.1.2", "@capacitor/local-notifications": "^8.0.1", + "@capacitor/share": "^8.0.1", "@capacitor/status-bar": "^8.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index 6a5a62d2..e5ba94ad 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'; import { Blurhash } from 'react-blurhash'; import { cn } from '@/lib/utils'; +import { openUrl } from '@/lib/downloadFile'; import { Skeleton } from '@/components/ui/skeleton'; import { useBlossomFallback } from '@/hooks/useBlossomFallback'; import { VideoPlayer } from '@/components/VideoPlayer'; @@ -475,9 +476,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT const handleDownload = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - const a = document.createElement('a'); - a.href = currentUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; - a.click(); + openUrl(currentUrl); }; // Only render the current image and its immediate neighbours diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index 19db3b36..e54471f0 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -13,6 +13,7 @@ import { Users, } from "lucide-react"; import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; +import { downloadTextFile } from "@/lib/downloadFile"; import { type ReactNode, useCallback, @@ -267,7 +268,7 @@ function SetupQuestionnaire({ }, [next]); // Download + login handler - const handleDownloadAndLogin = useCallback(() => { + const handleDownloadAndLogin = useCallback(async () => { try { const decoded = nip19.decode(nsec); if (decoded.type !== "nsec") throw new Error("Invalid nsec"); @@ -276,16 +277,7 @@ function SetupQuestionnaire({ const npub = nip19.npubEncode(pubkey); const filename = `nostr-${location.hostname.replaceAll(/\./g, "-")}-${npub.slice(5, 9)}.nsec.txt`; - const blob = new Blob([nsec], { type: "text/plain; charset=utf-8" }); - const url = globalThis.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); + await downloadTextFile(filename, nsec); // Log in with the new key login.nsec(nsec); diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index a50a56b6..b732049c 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -11,6 +11,7 @@ import { useLoginActions } from '@/hooks/useLoginActions'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useUploadFile } from '@/hooks/useUploadFile'; import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { downloadTextFile } from '@/lib/downloadFile'; import { ProfileCard } from '@/components/ProfileCard'; import { ImageCropDialog } from '@/components/ImageCropDialog'; import type { NostrMetadata } from '@nostrify/nostrify'; @@ -44,12 +45,8 @@ const SignupDialog: React.FC = ({ isOpen, onClose }) => { setStep('download'); }; - const downloadKey = () => { + const downloadKey = async () => { try { - // Create a blob with the key text - const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); - const url = globalThis.URL.createObjectURL(blob); - const decoded = nip19.decode(nsec); if (decoded.type !== 'nsec') { throw new Error('Invalid nsec key'); @@ -59,17 +56,7 @@ const SignupDialog: React.FC = ({ isOpen, onClose }) => { const npub = nip19.npubEncode(pubkey); const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`; - // Create a temporary link element and trigger download - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - - // Clean up immediately - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); + await downloadTextFile(filename, nsec); // Continue to profile step login.nsec(nsec); diff --git a/src/lib/downloadFile.ts b/src/lib/downloadFile.ts new file mode 100644 index 00000000..3b424ee1 --- /dev/null +++ b/src/lib/downloadFile.ts @@ -0,0 +1,57 @@ +import { Capacitor } from '@capacitor/core'; + +/** + * Download a text file to the user's device. + * + * On the web this uses the classic `` trick. + * On native (Capacitor iOS/Android) this writes to a temp file via + * the Filesystem plugin and presents the native share sheet so the + * user can save / AirDrop / share the file. + */ +export async function downloadTextFile(filename: string, content: string): Promise { + if (Capacitor.isNativePlatform()) { + const { Filesystem, Directory } = await import('@capacitor/filesystem'); + const { Share } = await import('@capacitor/share'); + + // Write to the cache directory (always writable, no permissions needed) + const result = await Filesystem.writeFile({ + path: filename, + data: content, + directory: Directory.Cache, + }); + + // Present the native share sheet so the user can save / share the file + await Share.share({ + title: filename, + url: result.uri, + }); + } else { + // Web: use the anchor-click download pattern + const blob = new Blob([content], { type: 'text/plain; charset=utf-8' }); + const url = globalThis.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + globalThis.URL.revokeObjectURL(url); + document.body.removeChild(a); + } +} + +/** + * Open a URL in a new browser tab, or present the native share sheet on Capacitor. + * + * The programmatic `` click pattern doesn't work inside + * WKWebView on iOS. On native platforms this presents the share sheet instead, + * letting the user open, save, or share the resource. + */ +export async function openUrl(url: string): Promise { + if (Capacitor.isNativePlatform()) { + const { Share } = await import('@capacitor/share'); + await Share.share({ url }); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index fae9916e..a97d13b5 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -46,6 +46,7 @@ import { genUserName } from '@/lib/genUserName'; import { canZap } from '@/lib/canZap'; import { shareOrCopy } from '@/lib/share'; +import { openUrl } from '@/lib/downloadFile'; import { EmojifiedText } from '@/components/CustomEmoji'; import { BioContent } from '@/components/BioContent'; import { EmbeddedNote } from '@/components/EmbeddedNote'; @@ -790,11 +791,7 @@ function ProfileImageLightbox({ imageUrl, onClose }: { imageUrl: string; onClose const handleDownload = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - const a = document.createElement('a'); - a.href = imageUrl; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - a.click(); + openUrl(imageUrl); }; return ( From d4a928b682ed4156dd3cbc35356501405840a5c2 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 16:52:20 -0500 Subject: [PATCH 07/16] Show full theme description on post detail page On the feed, theme descriptions are truncated to a single line. On the post detail page, the full description is now displayed so users can read long descriptions that don't fit in the thumbnail card. Closes #124 --- src/components/ThemeContent.tsx | 10 +++++++--- src/pages/PostDetailPage.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ThemeContent.tsx b/src/components/ThemeContent.tsx index 79e96d76..75f424ce 100644 --- a/src/components/ThemeContent.tsx +++ b/src/components/ThemeContent.tsx @@ -14,6 +14,8 @@ import { coreToTokens, type CoreThemeColors, type ThemeConfig } from '@/themes'; interface ThemeContentProps { event: NostrEvent; + /** When true, shows the full description instead of truncating it (used on detail page). */ + expanded?: boolean; } /** Extracts HSL color string from a theme token value like "258 70% 55%" */ @@ -26,7 +28,7 @@ function hsl(value: string): string { * and kind 16767 (Active Profile Theme) events within NoteCard. * Uses the same mini-mockup design as ThemeSelector, scaled up. */ -export function ThemeContent({ event }: ThemeContentProps) { +export function ThemeContent({ event, expanded }: ThemeContentProps) { const { user } = useCurrentUser(); const { theme, customTheme, setTheme, applyCustomTheme } = useTheme(); const isOwn = user?.pubkey === event.pubkey; @@ -105,7 +107,7 @@ export function ThemeContent({ event }: ThemeContentProps) { className="w-full text-left cursor-pointer transition-opacity hover:opacity-90 active:opacity-75" onClick={handleApplyTheme} > - + {/* Actions — only Edit for own theme definitions */} @@ -134,11 +136,13 @@ function ThemeMockup({ title, description, backgroundUrl, + expanded, }: { colors: CoreThemeColors; title: string; description?: string; backgroundUrl?: string; + expanded?: boolean; }) { const tokens = useMemo(() => coreToTokens(colors), [colors]); @@ -184,7 +188,7 @@ function ThemeMockup({ {title} {description && ( - + {description} )} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 550644fe..228c8f16 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -1665,7 +1665,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { ) : isFileMetadata ? ( ) : isTheme ? ( - + ) : isVoiceMessage ? ( ) : isCommunity ? ( From 81e42f24c845d5cbc9c3a315517f654dce57d6f5 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 16:59:24 -0500 Subject: [PATCH 08/16] Exclude Capacitor native-only plugins from Vite dep optimization @capacitor/filesystem and @capacitor/share are dynamically imported behind a Capacitor.isNativePlatform() guard, but Vite's import analysis plugin still tries to resolve them at transform time in dev mode. This causes a 'Failed to resolve import' error when running the dev server. Excluding them from optimizeDeps prevents Vite from pre-bundling these packages, letting the dynamic imports resolve naturally at runtime. --- vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index 7c54ce0e..cef0c56e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -150,6 +150,9 @@ export default defineConfig(() => { build: { target: 'esnext', }, + optimizeDeps: { + exclude: ['@capacitor/filesystem', '@capacitor/share'], + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), From b59eeeca812b5512e0983523a4f02a25b728c9f3 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 17:09:08 -0500 Subject: [PATCH 09/16] Show theme description on 'updated their theme' (kind 16767) posts Kind 16767 events previously hardcoded description to undefined, so the theme description never appeared on 'updated their theme' posts in the feed or detail view. Three changes: - buildActiveThemeTags now accepts and includes a description tag, so future kind 16767 events carry the description directly - setActiveTheme accepts description to thread it through publishing - ThemeContent extracts the description tag from kind 16767 events, and for older events without one, falls back to querying the source theme definition via the a-tag reference --- src/components/ThemeContent.tsx | 42 +++++++++++++++++++++++++++++++-- src/hooks/usePublishTheme.ts | 4 +++- src/lib/themeEvent.ts | 4 ++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/components/ThemeContent.tsx b/src/components/ThemeContent.tsx index 75f424ce..2b3b410d 100644 --- a/src/components/ThemeContent.tsx +++ b/src/components/ThemeContent.tsx @@ -2,6 +2,8 @@ import React, { useMemo, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Pencil } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { ToastAction } from '@/components/ui/toast'; @@ -29,6 +31,7 @@ function hsl(value: string): string { * Uses the same mini-mockup design as ThemeSelector, scaled up. */ export function ThemeContent({ event, expanded }: ThemeContentProps) { + const { nostr } = useNostr(); const { user } = useCurrentUser(); const { theme, customTheme, setTheme, applyCustomTheme } = useTheme(); const isOwn = user?.pubkey === event.pubkey; @@ -41,10 +44,11 @@ export function ThemeContent({ event, expanded }: ThemeContentProps) { const active = parseActiveProfileTheme(event); if (!active) return null; const title = event.tags.find(([n]) => n === 'title')?.[1]; + const description = event.tags.find(([n]) => n === 'description')?.[1]; return { colors: active.colors, title: title ?? 'Profile Theme', - description: undefined as string | undefined, + description, identifier: undefined as string | undefined, background: active.background, font: active.font, @@ -55,6 +59,39 @@ export function ThemeContent({ event, expanded }: ThemeContentProps) { return null; }, [event]); + // For kind 16767 events without a description tag, look up the source theme definition + const sourceRef = (parsed as { sourceRef?: string } | null)?.sourceRef; + const needsSourceLookup = event.kind === ACTIVE_THEME_KIND && !parsed?.description && !!sourceRef; + + const sourceCoords = useMemo(() => { + if (!needsSourceLookup || !sourceRef) return null; + const parts = sourceRef.split(':'); + if (parts.length < 3) return null; + return { kind: parseInt(parts[0], 10), pubkey: parts[1], identifier: parts[2] }; + }, [needsSourceLookup, sourceRef]); + + const { data: sourceDescription } = useQuery({ + queryKey: ['themeSourceDescription', sourceRef], + queryFn: async () => { + if (!sourceCoords) return null; + const events = await nostr.query([{ + kinds: [sourceCoords.kind], + authors: [sourceCoords.pubkey], + '#d': [sourceCoords.identifier], + limit: 1, + }], { signal: AbortSignal.timeout(5000) }); + const source = events[0]; + if (!source) return null; + return source.tags.find(([n]) => n === 'description')?.[1] ?? null; + }, + enabled: !!sourceCoords, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + // Use direct description from event, or fall back to source theme description + const resolvedDescription = parsed?.description ?? sourceDescription ?? undefined; + const previousThemeRef = useRef<{ mode: Theme; config?: ThemeConfig }>(); /** Apply the theme directly when clicked. */ @@ -94,7 +131,8 @@ export function ThemeContent({ event, expanded }: ThemeContentProps) { if (!parsed) return null; - const { colors, title, description } = parsed; + const { colors, title } = parsed; + const description = resolvedDescription; const backgroundUrl = parsed.background?.url; const isDefinition = event.kind === THEME_DEFINITION_KIND; diff --git a/src/hooks/usePublishTheme.ts b/src/hooks/usePublishTheme.ts index aec5409b..fca5a6cd 100644 --- a/src/hooks/usePublishTheme.ts +++ b/src/hooks/usePublishTheme.ts @@ -73,11 +73,13 @@ export function usePublishTheme() { sourceAuthor?: string; /** d-tag of the source theme definition */ sourceIdentifier?: string; + /** Optional description from the source theme definition */ + description?: string; }) => { if (!user) throw new Error('Must be logged in'); const resolved = resolveThemeForPublishing(opts.themeConfig); - const tags = buildActiveThemeTags(resolved, opts.sourceAuthor, opts.sourceIdentifier); + const tags = buildActiveThemeTags(resolved, opts.sourceAuthor, opts.sourceIdentifier, opts.description); await publishEvent({ kind: ACTIVE_THEME_KIND, diff --git a/src/lib/themeEvent.ts b/src/lib/themeEvent.ts index 7c95eb81..5ef6d1fc 100644 --- a/src/lib/themeEvent.ts +++ b/src/lib/themeEvent.ts @@ -264,6 +264,7 @@ export function buildActiveThemeTags( themeConfig: ThemeConfig, sourceAuthor?: string, sourceIdentifier?: string, + description?: string, ): string[][] { const tags: string[][] = [ ...buildColorTags(themeConfig.colors), @@ -274,6 +275,9 @@ export function buildActiveThemeTags( if (themeConfig.title) { tags.push(['title', themeConfig.title]); } + if (description) { + tags.push(['description', description]); + } if (sourceAuthor && sourceIdentifier) { tags.push(['a', `${THEME_DEFINITION_KIND}:${sourceAuthor}:${sourceIdentifier}`]); } From 1f5ce2546cb0b63e1feb7d78b1d1126573c8491a Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Fri, 27 Mar 2026 17:46:16 -0500 Subject: [PATCH 10/16] Add emoji picker and shortcode autocomplete to zap comment box Extract shared useInsertText hook to DRY up the duplicated text insertion logic across ComposeBox, DMChatArea, and ZapDialog. Add EmojiPicker (GUI) and EmojiShortcodeAutocomplete (:shortcode typing) to the zap comment textarea, and also add shortcode autocomplete to the DM chat input which was previously missing it. Closes #176 --- src/components/ComposeBox.tsx | 46 +++----------------- src/components/ZapDialog.tsx | 74 ++++++++++++++++++++++++++++---- src/components/dm/DMChatArea.tsx | 43 +++++++++---------- src/hooks/useInsertText.ts | 64 +++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 72 deletions(-) create mode 100644 src/hooks/useInsertText.ts diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 5bbad4c3..7e9c8030 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -38,6 +38,7 @@ import { extractWebxdcMeta } from '@/lib/webxdcMeta'; import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls'; import { parseImetaMap } from '@/lib/imeta'; import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { useInsertText } from '@/hooks/useInsertText'; import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'; import { formatTime } from '@/lib/formatTime'; import { DITTO_RELAY } from '@/lib/appRelays'; @@ -215,6 +216,7 @@ export function ComposeBox({ /** Maps .xdc URLs to extracted metadata (name + icon URL). */ const [webxdcMetas, setWebxdcMetas] = useState>(new Map()); const textareaRef = useRef(null); + const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent); const fileInputRef = useRef(null); // Voice recording @@ -464,49 +466,13 @@ export function ComposeBox({ }, [user, content, customEmojis, uploadedFileGroups, webxdcUuids, webxdcMetas]); const insertEmoji = useCallback((emoji: string) => { - const textarea = textareaRef.current; - if (textarea) { - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const newContent = content.slice(0, start) + emoji + content.slice(end); - setContent(newContent); - // Restore cursor position after the inserted emoji - requestAnimationFrame(() => { - textarea.focus(); - const pos = start + emoji.length; - textarea.setSelectionRange(pos, pos); - }); - } else { - setContent((prev) => prev + emoji); - } + insertEmojiAtCursor(emoji); expand(); - }, [content, expand]); + }, [insertEmojiAtCursor, expand]); - const handleInsertMention = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => { - const newContent = content.slice(0, start) + replacement + content.slice(end); - setContent(newContent); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (textarea) { - textarea.focus(); - const pos = start + replacement.length; - textarea.setSelectionRange(pos, pos); - } - }); - }, [content]); + const handleInsertMention = insertAtCursor; - const handleInsertShortcodeEmoji = useCallback(({ start, end, replacement }: { start: number; end: number; replacement: string }) => { - const newContent = content.slice(0, start) + replacement + content.slice(end); - setContent(newContent); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (textarea) { - textarea.focus(); - const pos = start + replacement.length; - textarea.setSelectionRange(pos, pos); - } - }); - }, [content]); + const handleInsertShortcodeEmoji = insertAtCursor; const handleFileUpload = useCallback(async (file: File) => { try { diff --git a/src/components/ZapDialog.tsx b/src/components/ZapDialog.tsx index 00dfab5b..fac2a28f 100644 --- a/src/components/ZapDialog.tsx +++ b/src/components/ZapDialog.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, forwardRef } from 'react'; -import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X } from 'lucide-react'; +import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react'; import { HelpTip } from '@/components/HelpTip'; import { Button } from '@/components/ui/button'; import { @@ -14,12 +14,18 @@ import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { EmojiPicker } from '@/components/EmojiPicker'; +import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; import { useZaps } from '@/hooks/useZaps'; import { useWallet } from '@/hooks/useWallet'; import { useAppContext } from '@/hooks/useAppContext'; +import { useCustomEmojis } from '@/hooks/useCustomEmojis'; +import { useFeedSettings } from '@/hooks/useFeedSettings'; +import { useInsertText } from '@/hooks/useInsertText'; import type { Event } from 'nostr-tools'; import QRCode from 'qrcode'; import type { WebLNProvider } from "@webbtc/webln-types"; @@ -52,6 +58,10 @@ interface ZapContentProps { setAmount: (amount: number | string) => void; setComment: (comment: string) => void; inputRef: React.RefObject; + commentTextareaRef: React.RefObject; + insertEmoji: (emoji: string) => void; + insertAtCursor: (params: { start: number; end: number; replacement: string }) => void; + customEmojis: Array<{ shortcode: string; url: string }>; zap: (amount: number, comment: string) => void; } @@ -70,6 +80,10 @@ const ZapContent = forwardRef(({ setAmount, setComment, inputRef, + commentTextareaRef, + insertEmoji, + insertAtCursor, + customEmojis, zap, }, ref) => (
@@ -197,14 +211,47 @@ const ZapContent = forwardRef(({ onChange={(e) => setAmount(e.target.value)} className="w-full" /> -