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/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/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/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index adf6ba01..6dfdc7d6 100644 Binary files a/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ 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 88335278..1026f9c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { - "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", "@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", @@ -3222,6 +3251,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3235,6 +3265,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3248,6 +3279,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3261,6 +3293,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3274,6 +3307,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3287,6 +3321,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3300,6 +3335,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3313,6 +3349,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3326,6 +3363,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3339,6 +3377,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3352,6 +3391,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3365,6 +3405,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3378,6 +3419,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3391,6 +3433,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3404,6 +3447,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3417,6 +3461,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3430,6 +3475,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3443,6 +3489,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3456,6 +3503,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3469,6 +3517,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3482,6 +3531,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3495,6 +3545,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3508,6 +3559,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3521,6 +3573,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3534,6 +3587,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ 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/scripts/generate-icons.sh b/scripts/generate-icons.sh index 9feb188a..033fbdac 100644 --- a/scripts/generate-icons.sh +++ b/scripts/generate-icons.sh @@ -6,7 +6,7 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color -echo -e "${GREEN}Generating Android app icons...${NC}\n" +echo -e "${GREEN}Generating app icons...${NC}\n" # Check for inkscape (preferred) or rsvg-convert as fallback if command -v inkscape &> /dev/null; then @@ -138,12 +138,33 @@ cat > "$BACKGROUND_COLOR_FILE" << 'EOF' EOF +# ── iOS App Icon (1024x1024, white logo on purple background) ── + +echo "Generating iOS app icon..." + +IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset" + +if [ -d "$IOS_ICON_DIR" ]; then + IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png" + # Logo at ~60% of canvas, centered on purple background (matches legacy Android style) + $MAGICK -size "1024x1024" "xc:${BG_COLOR}" \ + \( "$LOGO_WHITE" -resize "614x614" \) \ + -gravity center -compose over -composite \ + "$IOS_ICON" + echo -e " ${GREEN}✓${NC} $IOS_ICON" +else + echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}" +fi + # Cleanup temp files rm -rf "$TMPDIR" -echo -e "\n${GREEN}Android icons generated successfully!${NC}" +echo -e "\n${GREEN}App icons generated successfully!${NC}" echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)" echo -e "Generated:" -echo -e " - ic_launcher_foreground.png (adaptive, all densities)" -echo -e " - ic_launcher.png (legacy square, all densities)" -echo -e " - ic_launcher_round.png (legacy round, all densities)" +echo -e " Android:" +echo -e " - ic_launcher_foreground.png (adaptive, all densities)" +echo -e " - ic_launcher.png (legacy square, all densities)" +echo -e " - ic_launcher_round.png (legacy round, all densities)" +echo -e " iOS:" +echo -e " - AppIcon-512@2x.png (1024x1024)" diff --git a/src/components/BadgeDetailContent.tsx b/src/components/BadgeDetailContent.tsx index feb0566e..81817cb0 100644 --- a/src/components/BadgeDetailContent.tsx +++ b/src/components/BadgeDetailContent.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { Award, Copy, Check, Users, Gift, Loader2, MessageCircle, Newspaper, MoreHorizontal } from 'lucide-react'; +import { Award, Copy, Check, Users, Gift, Loader2, MessageCircle, Newspaper, MoreHorizontal, Zap } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { useNostr } from '@nostrify/react'; @@ -36,7 +36,9 @@ import { parseBadgeDefinition } from '@/components/BadgeContent'; import { useCardTilt } from '@/hooks/useCardTilt'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { AwardBadgeDialog } from '@/components/AwardBadgeDialog'; +import { ZapDialog } from '@/components/ZapDialog'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; +import { canZap } from '@/lib/canZap'; type DetailTab = 'awarded' | 'feed' | 'comments'; @@ -73,6 +75,7 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) { // Stats for action bar const { data: stats } = useEventStats(event.id, event); const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0); + const canZapAuthor = user && canZap(metadata); const awardsQuery = useQuery({ queryKey: ['badge-awards', badgeATag], @@ -280,6 +283,22 @@ export function BadgeDetailContent({ event }: { event: NostrEvent }) { {formatNumber(commentCount)} )} + + {canZapAuthor && ( + + + + )} {/* Tabs */} 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/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/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index 0a1452f5..c073cf16 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -50,6 +50,59 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu setPortalContainer(node ?? undefined); }, []); + // Prevent the compose modal from closing when the user interacts with a + // nested dialog (e.g. the emoji/GIF picker). On mobile it is very easy to + // tap the emoji picker overlay and accidentally dismiss the compose modal, + // losing the draft. We detect nested-dialog interactions by checking + // whether the click target lives inside another Radix Dialog portal that + // sits above this modal. + const isNestedDialogInteraction = useCallback((e: Event) => { + const target = e.target as HTMLElement | null; + if (!target) return false; + // Radix Dialog overlays have data-state and sit inside [role="dialog"] + // portals. If the target is inside a dialog element that is NOT our own + // DialogContent, a nested dialog is open. + const closestDialog = target.closest('[role="dialog"]'); + if (closestDialog && portalContainer && closestDialog !== portalContainer) { + return true; + } + // Also catch clicks on the overlay itself (data-radix-dialog-overlay or + // the backdrop element) that belongs to a nested dialog. + const closestOverlay = target.closest('[data-radix-dialog-overlay]'); + if (closestOverlay) { + // Check if this overlay belongs to our dialog or a nested one. + // Our overlay is a sibling of our DialogContent, not a descendant. + // If the overlay is rendered inside our portal container's parent + // (the same portal), it could be ours. But if there are multiple + // overlays, the topmost (last in DOM) belongs to the nested dialog. + const allOverlays = document.querySelectorAll('[data-radix-dialog-overlay]'); + if (allOverlays.length > 1) { + return true; + } + } + return false; + }, [portalContainer]); + + const handleInteractOutside = useCallback((e: Event) => { + if (isNestedDialogInteraction(e)) { + e.preventDefault(); + } + }, [isNestedDialogInteraction]); + + const handleEscapeKeyDown = useCallback((e: KeyboardEvent) => { + // When a nested dialog is open, Radix will close it first via its own + // handler. But the escape event can bubble and also close the parent + // modal. We prevent that by checking if any nested dialog is currently + // open (any dialog with data-state="open" that is not ours). + const openDialogs = document.querySelectorAll('[role="dialog"][data-state="open"]'); + const hasNestedDialog = Array.from(openDialogs).some( + (el) => portalContainer && el !== portalContainer, + ); + if (hasNestedDialog) { + e.preventDefault(); + } + }, [portalContainer]); + return ( {/* Header */} @@ -131,7 +186,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu )} {/* Compose area */} -
+
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, @@ -53,6 +59,39 @@ export function ThemeContent({ event }: 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. */ @@ -92,7 +131,8 @@ export function ThemeContent({ event }: 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; @@ -105,7 +145,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 +174,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 +226,7 @@ function ThemeMockup({ {title} {description && ( - + {description} )} 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" /> -