Merge remote-tracking branch 'origin/main' into fix/toast-swipe

# Conflicts:
#	src/components/ui/toast.tsx
#	src/components/ui/toaster.tsx
This commit is contained in:
Mary Kate Fain
2026-03-27 19:14:08 -05:00
33 changed files with 693 additions and 153 deletions
+62
View File
@@ -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)
- **`<a download>` file downloads** -- Programmatically creating an anchor element with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
- **`<a target="_blank">` 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 `<a download>` 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:
+2
View File
@@ -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')
}
@@ -265,6 +265,7 @@ public class NostrPoller {
}
return "commented on your post";
}
case 8211: return "sent you a letter";
default: return "mentioned you";
}
}
@@ -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);
+6
View File
@@ -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')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+4
View File
@@ -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")
]
)
+56 -2
View File
@@ -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": [
+2
View File
@@ -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",
+26 -5
View File
@@ -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'
</resources>
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)"
+20 -1
View File
@@ -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 }) {
<span className="text-sm tabular-nums">{formatNumber(commentCount)}</span>
)}
</button>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
{stats?.zapAmount ? (
<span className="text-sm tabular-nums">
{formatNumber(stats.zapAmount)}
</span>
) : null}
</button>
</ZapDialog>
)}
</div>
{/* Tabs */}
+6 -40
View File
@@ -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<Map<string, { name?: string; iconUrl?: string }>>(new Map());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { insertAtCursor, insertEmoji: insertEmojiAtCursor } = useInsertText(textareaRef, content, setContent);
const fileInputRef = useRef<HTMLInputElement>(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 {
+2 -3
View File
@@ -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
+3 -11
View File
@@ -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);
+56 -1
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
@@ -61,6 +114,8 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
const textarea = target.querySelector('textarea');
textarea?.focus();
}}
onInteractOutside={handleInteractOutside}
onEscapeKeyDown={handleEscapeKeyDown}
>
<PortalContainerProvider value={portalContainer}>
{/* Header */}
@@ -131,7 +186,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
)}
{/* Compose area */}
<div className="shrink-0">
<div className="min-h-0 overflow-y-auto">
<ComposeBox
replyTo={isQuote ? undefined : (event ?? undefined)}
quotedEvent={quotedEvent ?? undefined}
+47 -5
View File
@@ -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';
@@ -14,6 +16,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 +30,8 @@ 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 { nostr } = useNostr();
const { user } = useCurrentUser();
const { theme, customTheme, setTheme, applyCustomTheme } = useTheme();
const isOwn = user?.pubkey === event.pubkey;
@@ -39,10 +44,11 @@ export function ThemeContent({ event }: 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,
@@ -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}
>
<ThemeMockup colors={colors} title={title} description={description} backgroundUrl={backgroundUrl} />
<ThemeMockup colors={colors} title={title} description={description} backgroundUrl={backgroundUrl} expanded={expanded} />
</button>
{/* 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}
</span>
{description && (
<span className="text-xs block truncate mt-0.5" style={{ color: hsl(tokens.mutedForeground) }}>
<span className={`text-xs block mt-0.5 ${expanded ? 'whitespace-pre-wrap' : 'truncate'}`} style={{ color: hsl(tokens.mutedForeground) }}>
{description}
</span>
)}
+65 -9
View File
@@ -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<HTMLInputElement>;
commentTextareaRef: React.RefObject<HTMLTextAreaElement>;
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<HTMLDivElement, ZapContentProps>(({
setAmount,
setComment,
inputRef,
commentTextareaRef,
insertEmoji,
insertAtCursor,
customEmojis,
zap,
}, ref) => (
<div ref={ref}>
@@ -197,14 +211,47 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
onChange={(e) => setAmount(e.target.value)}
className="w-full"
/>
<Textarea
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none"
rows={2}
/>
<div className="relative">
<Textarea
ref={commentTextareaRef}
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none"
rows={2}
/>
<EmojiShortcodeAutocomplete
textareaRef={commentTextareaRef}
content={comment}
onInsertEmoji={insertAtCursor}
/>
</div>
<div className="flex items-center">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
>
<Smile className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
insertEmoji(text);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="px-4 pb-4">
<Button onClick={handleZap} className="w-full" disabled={isZapping} size="default">
@@ -237,6 +284,11 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const { feedSettings } = useFeedSettings();
const { emojis: allCustomEmojis } = useCustomEmojis();
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
const { insertAtCursor, insertEmoji } = useInsertText(commentTextareaRef, comment, setComment);
useEffect(() => {
if (target) {
@@ -333,6 +385,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setAmount,
setComment,
inputRef,
commentTextareaRef,
insertEmoji,
insertAtCursor,
customEmojis,
zap,
};
+3 -16
View File
@@ -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<SignupDialogProps> = ({ 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<SignupDialogProps> = ({ 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);
+20 -23
View File
@@ -21,8 +21,10 @@ import { cn } from '@/lib/utils';
import { NoteContent } from '@/components/NoteContent';
import { GifPicker } from '@/components/GifPicker';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInsertText } from '@/hooks/useInsertText';
import type { NostrEvent } from '@nostrify/nostrify';
interface DMChatAreaProps {
@@ -235,6 +237,7 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
const { emojis: allCustomEmojis } = useCustomEmojis();
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { insertAtCursor, insertEmoji } = useInsertText(textareaRef, messageText, setMessageText);
// Determine default protocol based on mode
const getDefaultProtocol = () => {
@@ -378,15 +381,22 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
<div className="p-4 border-t">
<div className="flex gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<Textarea
ref={textareaRef}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
<div className="relative">
<Textarea
ref={textareaRef}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={messageText}
onInsertEmoji={insertAtCursor}
/>
</div>
{/* Toolbar row */}
<div className="flex items-center gap-0.5">
{/* Emoji picker */}
@@ -413,20 +423,7 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
customEmojis={customEmojis}
onSelect={(selection) => {
const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = messageText.slice(0, start) + text + messageText.slice(end);
setMessageText(newText);
requestAnimationFrame(() => {
textarea.focus();
const pos = start + text.length;
textarea.setSelectionRange(pos, pos);
});
} else {
setMessageText((prev) => prev + text);
}
insertEmoji(text);
}}
/>
</PopoverContent>
+1
View File
@@ -47,6 +47,7 @@ export interface EncryptedSettings {
mentions?: boolean;
comments?: boolean;
badges?: boolean;
letters?: boolean;
onlyFollowing?: boolean;
};
/** Last sync timestamp */
+2 -1
View File
@@ -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,
+64
View File
@@ -0,0 +1,64 @@
import { useCallback } from 'react';
interface InsertAtCursorParams {
start: number;
end: number;
replacement: string;
}
/**
* Shared hook for inserting text at the cursor position within a textarea.
*
* Returns two helpers:
* - `insertAtCursor` splice a replacement string between explicit start/end
* offsets (used by autocomplete components like EmojiShortcodeAutocomplete).
* - `insertEmoji` insert text at the textarea's *current* selection
* (used by the EmojiPicker GUI button).
*
* Both restore focus and cursor position after the insertion.
*/
export function useInsertText(
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
content: string,
setContent: (value: string) => void,
) {
/** Insert a replacement between explicit `start` and `end` offsets. */
const insertAtCursor = useCallback(
({ start, end, replacement }: InsertAtCursorParams) => {
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, setContent, textareaRef],
);
/** Insert text at the textarea's current selection (or append if no ref). */
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);
requestAnimationFrame(() => {
textarea.focus();
const pos = start + emoji.length;
textarea.setSelectionRange(pos, pos);
});
} else {
setContent(content + emoji);
}
},
[content, setContent, textareaRef],
);
return { insertAtCursor, insertEmoji };
}
+8 -6
View File
@@ -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
+3 -1
View File
@@ -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,
+57
View File
@@ -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 `<a download>` 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<void> {
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 `<a target="_blank">` 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<void> {
if (Capacitor.isNativePlatform()) {
const { Share } = await import('@capacitor/share');
await Share.share({ url });
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
+6
View File
@@ -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.',
},
];
+1
View File
@@ -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(),
+4
View File
@@ -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}`]);
}
+9 -2
View File
@@ -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: <Award className="size-5" />,
},
{
key: 'letters',
label: 'Letters',
kinds: [8211],
description: 'When someone sends you a letter',
icon: <Mail className="size-5" />,
},
];
function KindBadge({ kind }: { kind: number }) {
+150 -20
View File
@@ -2,9 +2,9 @@ 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 { Link, useNavigate } from 'react-router-dom';
import { PullToRefresh } from '@/components/PullToRefresh';
import { useAppContext } from '@/hooks/useAppContext';
@@ -31,12 +31,72 @@ 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, type Letter } from '@/lib/letterTypes';
import { EnvelopeCard } from '@/components/letter/EnvelopeCard';
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';
type NotificationTab = 'all' | 'mentions';
/**
* Maps event kind numbers to bare noun labels for notification action text.
* e.g. "reacted to your **badge**", "reposted your **theme**".
* Falls back to "post" for unknown kinds.
*/
const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
1: 'post',
4: 'encrypted message',
6: 'repost',
7: 'reaction',
16: 'repost',
20: 'photo',
21: 'video',
22: 'video',
62: 'request to vanish',
1063: 'file',
1068: 'poll',
1111: 'comment',
1222: 'voice message',
1617: 'patch',
1618: 'pull request',
3367: 'color moment',
7516: 'found log',
15128: 'nsite',
16767: 'theme',
30008: 'profile badges',
30009: 'badge',
30023: 'article',
30030: 'emoji pack',
30054: 'podcast episode',
30055: 'podcast trailer',
30063: 'release',
30311: 'stream',
30315: 'status',
30617: 'repository',
30817: 'custom NIP',
31922: 'calendar event',
31923: 'calendar event',
32267: 'app',
34139: 'playlist',
34236: 'vine',
34550: 'community',
35128: 'nsite',
36767: 'theme',
36787: 'track',
37381: 'Magic deck',
37516: 'treasure',
39089: 'follow pack',
};
/** Get a bare noun label for a kind number, defaulting to "post". */
function getNotificationKindNoun(kind: number | undefined): string {
if (kind === undefined) return 'post';
return NOTIFICATION_KIND_NOUNS[kind] ?? 'post';
}
export function NotificationsPage() {
const { config } = useAppContext();
@@ -199,6 +259,8 @@ function GroupedNotificationView({ group }: { group: GroupedNotificationItem })
return solo
? <BadgeAwardNotification item={group.actors[0]} isNew={group.isNew} />
: <BadgeAwardNotificationGroup group={group} />;
case LETTER_KIND:
return <LetterNotification item={group.actors[0]} isNew={group.isNew} />;
default:
return null;
}
@@ -363,6 +425,7 @@ function ActorLink({ pubkey, name }: { pubkey: string; name: string }) {
// Like Notification (single actor)
// ──────────────────────────────────────
function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) {
const noun = getNotificationKindNoun(item.referencedEvent?.kind);
return (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
@@ -373,7 +436,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
</span>
}
action="reacted to your post"
action={`reacted to your ${noun}`}
/>
</div>
<ReferencedNoteCard item={item} />
@@ -385,13 +448,14 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
// Repost Notification (single actor)
// ──────────────────────────────────────
function RepostNotification({ item, isNew }: { item: NotificationItem; isNew: boolean }) {
const noun = getNotificationKindNoun(item.referencedEvent?.kind);
return (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<RepostIcon className="size-4 text-accent" />}
action="reposted your note"
action={`reposted your ${noun}`}
/>
</div>
<ReferencedNoteCard item={item} />
@@ -460,6 +524,7 @@ function ZapNotification({ item, isNew }: { item: NotificationItem; isNew: boole
function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
// Use the first actor's reaction emoji as the icon
const firstEvent = group.actors[0].event;
const noun = getNotificationKindNoun(group.referencedEvent?.kind);
return (
<NotificationWrapper isNew={group.isNew}>
<GroupHeader
@@ -469,7 +534,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
</span>
}
action="reacted to your post"
action={`reacted to your ${noun}`}
/>
<ReferencedNoteCard item={group.actors[0]} />
</NotificationWrapper>
@@ -480,12 +545,13 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
// Repost Notification (grouped)
// ──────────────────────────────────────
function RepostNotificationGroup({ group }: { group: GroupedNotificationItem }) {
const noun = getNotificationKindNoun(group.referencedEvent?.kind);
return (
<NotificationWrapper isNew={group.isNew}>
<GroupHeader
actors={group.actors}
icon={<RepostIcon className="size-4 text-accent" />}
action="reposted your note"
action={`reposted your ${noun}`}
/>
<ReferencedNoteCard item={group.actors[0]} />
</NotificationWrapper>
@@ -580,7 +646,9 @@ function CommentNotification({ item, isNew }: { item: NotificationItem; isNew: b
// If the parent kind tag is "1111", this is a reply to a comment; otherwise it's a
// top-level comment on a piece of content the user authored.
const parentKind = item.event.tags.find(([name]) => name === 'k')?.[1];
const action = parentKind === '1111' ? 'replied to your comment' : 'commented on your post';
const parentKindNum = parentKind ? parseInt(parentKind, 10) : undefined;
const noun = getNotificationKindNoun(isNaN(parentKindNum as number) ? undefined : parentKindNum);
const action = parentKind === '1111' ? 'replied to your comment' : `commented on your ${noun}`;
return (
<NotificationWrapper isNew={isNew}>
@@ -596,6 +664,43 @@ 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<Letter>(() => ({
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 (
<NotificationWrapper isNew={isNew}>
<div className="px-4 pt-3">
<NotificationHeader
actorPubkey={item.event.pubkey}
icon={<Mail className="size-4 text-primary" />}
action="sent you a letter"
/>
</div>
<div className="px-4 pb-3 pt-1">
<div className="max-w-[160px]">
<EnvelopeCard
letter={letter}
mode="inbox"
index={0}
onClick={() => navigate('/letters')}
/>
</div>
</div>
</NotificationWrapper>
);
}
// ──────────────────────────────────────
// Badge Award helpers
// ──────────────────────────────────────
@@ -616,15 +721,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 +784,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 (
<NotificationWrapper isNew={isNew}>
@@ -692,6 +801,17 @@ function BadgeAwardNotification({ item, isNew }: { item: NotificationItem; isNew
<AcceptBadgeButton awardEvent={item.event} />
</div>
</div>
{badge && (
<div className="mt-2 flex items-center gap-3 rounded-lg border border-border/50 bg-muted/30 p-2">
<BadgeThumbnail badge={badge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{badge.name}</p>
{badge.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{badge.description}</p>
)}
</div>
</div>
)}
</div>
</NotificationWrapper>
);
@@ -723,17 +843,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 (
<div key={actor.event.id} className="flex items-center justify-between gap-2">
{displayName && (
<span className="text-xs text-muted-foreground truncate">
{displayName}
</span>
<div key={actor.event.id} className="flex items-center gap-3 rounded-lg border border-border/50 bg-muted/30 p-2">
{badge ? (
<BadgeThumbnail badge={badge} size={36} className="shrink-0" />
) : (
<div className="shrink-0 size-9 rounded-lg border border-border bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
<Award className="size-4 text-primary/30" />
</div>
)}
<div className="shrink-0 ml-auto">
<div className="flex-1 min-w-0">
{displayName && (
<p className="text-sm font-medium truncate">{displayName}</p>
)}
{badge?.description && (
<p className="text-xs text-muted-foreground line-clamp-1">{badge.description}</p>
)}
</div>
<div className="shrink-0">
<AcceptBadgeButton awardEvent={actor.event} />
</div>
</div>
+1 -1
View File
@@ -1665,7 +1665,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
) : isFileMetadata ? (
<FileMetadataContent event={event} />
) : isTheme ? (
<ThemeContent event={event} />
<ThemeContent event={event} expanded />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCommunity ? (
+2 -5
View File
@@ -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 (
+3
View File
@@ -150,6 +150,9 @@ export default defineConfig(() => {
build: {
target: 'esnext',
},
optimizeDeps: {
exclude: ['@capacitor/filesystem', '@capacitor/share'],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),