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:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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")
|
||||
]
|
||||
)
|
||||
|
||||
Generated
+56
-2
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface EncryptedSettings {
|
||||
mentions?: boolean;
|
||||
comments?: boolean;
|
||||
badges?: boolean;
|
||||
letters?: boolean;
|
||||
onlyFollowing?: boolean;
|
||||
};
|
||||
/** Last sync timestamp */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}`]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -150,6 +150,9 @@ export default defineConfig(() => {
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@capacitor/filesystem', '@capacitor/share'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
Reference in New Issue
Block a user