Compare commits

...

20 Commits

Author SHA1 Message Date
sam af822e7c63 messaging++ 2026-04-15 11:36:05 +05:45
sam 4ac4f32b45 fix build 2026-04-12 08:44:17 +05:45
sam 286a0fcadc turnoffable chats 2026-04-11 11:18:36 +05:45
sam 013584da06 Merge branch 'main' into dms-rebased 2026-04-10 09:50:27 +05:45
Alex Gleason 7b63f6112c Clean up profile header: remove lightning address, NIP-05 check icon, and trailing slash from website URLs 2026-04-09 22:30:38 -05:00
Alex Gleason ce61d8d1a6 Restore right sidebar for profile pages, keep fields mobile-only 2026-04-09 22:00:50 -05:00
sam 19df70baed updated dm route 2026-04-09 20:13:35 +05:45
Chad Curtis 76c6846e91 Render BOLT11 lightning invoices in note content
Detect lnbc/lntb/lnbcrt/lntbs invoices (with optional lightning: prefix)
in note text and render them as interactive cards with a theme-aware QR
code, decoded amount, copy button, and Open in Wallet action.

- Add lightning-invoice token type to NoteContent tokenizer
- Create LightningInvoiceCard with tap-to-expand square QR, cqw-scaled
  amount text, and responsive layout
- Extract shared theme-aware QR color logic into src/lib/qrColors.ts
  (deduplicate from FollowQRDialog)
2026-04-09 08:02:26 -05:00
sam cf7384523a reinstate messages settings 2026-04-09 18:10:57 +05:45
Alex Gleason ac1e82b52d release: v2.6.2 2026-04-08 23:38:31 -05:00
Alex Gleason 437b8de652 Remove right sidebar content and show profile fields inline 2026-04-08 23:34:03 -05:00
Alex Gleason adadb6ed53 Fix native file downloads: save directly to Documents on iOS/Android 2026-04-08 22:54:46 -05:00
Alex Gleason f7c90a4a23 Remove trending hashtags section from logged-out homepage 2026-04-08 22:28:22 -05:00
Alex Gleason 82632bb76c Store nostr:login in secure storage on native platforms
Use capacitor-secure-storage-plugin to persist login credentials
(nsec keys) in iOS Keychain / Android KeyStore instead of plaintext
localStorage. Web behavior is unchanged. Existing native users are
auto-migrated on first launch: if secure storage is empty but
localStorage has data, it is moved over and the plaintext copy is
removed.

Also ignore ios/ directory in ESLint (Capacitor-generated files).
2026-04-08 22:20:48 -05:00
Alex Gleason 3a70d34e6d npm audit fix 2026-04-08 22:12:03 -05:00
Alex Gleason 221d3f4aff Merge branch 'mobile-search' 2026-04-08 22:11:38 -05:00
Alex Gleason 6a1a462ab0 Upgrade @nostrify/react to ^0.5.0 (async storage support)
Upgrade to the new version that includes the NLoginStorage interface
and storage/fallback props on NostrLoginProvider for pluggable async
storage backends (e.g. Capacitor Secure Storage).

- Add resolve.dedupe for react/react-dom to prevent dual-React issues
- Update NoteContent tests to use async findBy* queries since the
  provider now always awaits storage initialization
2026-04-08 22:08:56 -05:00
Alex Gleason 5ee8bc1cc0 Improve mobile search UX: lock scroll, hide bottom nav, dismiss accessory bar, and fix close behavior 2026-04-08 22:04:26 -05:00
sam 64729b9804 change route/menu 2026-04-05 14:47:04 +05:45
sam 954339c3b9 rework dms pr 2026-04-05 13:13:53 +05:45
50 changed files with 1604 additions and 390 deletions
+3
View File
@@ -37,6 +37,9 @@ deploy.sh
# Build-time configuration
ditto.json
# DM message sounds (copied from node_modules by postinstall)
public/sounds/
# Android build outputs and sensitive files
*.aab
resources/
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY package*.json ./
COPY .npmrc ./
COPY scripts/ ./scripts/
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -14,7 +14,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.6.1"
versionName "2.6.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+2
View File
@@ -11,9 +11,11 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-secure-storage-plugin')
}
+6
View File
@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-keyboard'
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
@@ -16,3 +19,6 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capacitor-secure-storage-plugin'
project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android')
+6 -1
View File
@@ -19,7 +19,12 @@ const config: CapacitorConfig = {
backgroundColor: '#14161f',
contentInset: 'never',
scheme: 'Ditto'
}
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
},
},
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
+31
View File
@@ -0,0 +1,31 @@
services:
web:
image: nginx:alpine
ports:
- "8082:80"
volumes:
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./dist:/usr/share/nginx/html:ro
restart: unless-stopped
depends_on:
- vite
networks:
- ditto-network
vite:
image: node:22-alpine
working_dir: /app
# Use host node_modules (no anonymous volume) so new deps added after merge
# are picked up after a plain "npm install" on the host and container restart.
command: sh -c "npm install && npm run dev"
volumes:
- .:/app
environment:
- NODE_ENV=development
networks:
- ditto-network
restart: unless-stopped
networks:
ditto-network:
driver: bridge
+1 -1
View File
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
import customRules from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "android"] },
{ ignores: ["dist", "android", "ios"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+2 -2
View File
@@ -311,7 +311,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.6.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -333,7 +333,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.6.1;
MARKETING_VERSION = 2.6.2;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+6 -2
View File
@@ -14,9 +14,11 @@ let package = Package(
.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: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.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")
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar"),
.package(name: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
],
targets: [
.target(
@@ -26,9 +28,11 @@ let package = Package(
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "CapacitorApp", package: "CapacitorApp"),
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
.product(name: "CapacitorShare", package: "CapacitorShare"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
.product(name: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
]
)
]
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
resolver 127.0.0.11 valid=10s;
set $vite_backend http://vite:8080;
proxy_pass $vite_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
+260 -97
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.1",
"version": "2.6.2",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -18,6 +18,7 @@
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.1.0",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/keyboard": "^8.0.2",
"@capacitor/local-notifications": "^8.0.1",
"@capacitor/share": "^8.0.1",
"@capacitor/status-bar": "^8.0.0",
@@ -66,7 +67,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.4.1",
"@nostrify/react": "^0.5.0",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -96,12 +97,14 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.2.8",
"@samthomson/nostr-messaging": "^0.14.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@unhead/addons": "^2.0.10",
"@unhead/react": "^2.0.10",
"blurhash": "^2.0.5",
"buffer": "^6.0.3",
"capacitor-secure-storage-plugin": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [2.6.2] - 2026-04-08
### Added
- Share follow packs and follow sets via link -- recipients see an immersive preview with member avatars, a "Follow All" button, and a combined feed from everyone in the pack
- Curated home feed with a mix of photos, short videos, livestreams, and music -- content types are spaced out so your timeline stays fresh and varied
- "Write a letter" option on profile menus for a more personal way to reach out
- Push vs persistent notification delivery option on Android
### Changed
- Webxdc games and apps always open fullscreen for a more immersive experience
- Login credentials are now stored in the device's secure keychain on iOS and Android instead of plain local storage
- Profile fields now appear inline instead of in a separate right sidebar
- Trending hashtags removed from the logged-out homepage for a cleaner first impression
### Fixed
- Webxdc and nsites work natively on iOS and Android without relying on browser sandboxing tricks
- File downloads now save directly to Documents on iOS and Android instead of silently failing
- Mobile search no longer scrolls the page behind it and properly hides the bottom navigation bar
- iOS swipe-back navigation works correctly throughout the app
- Blobbi companions appear reliably on profiles instead of sometimes going missing
- IndexedDB no longer crashes on devices with Lockdown Mode enabled
## [2.6.1] - 2026-04-06
### Added
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Copy default message sounds from @samthomson/nostr-messaging package
if [ -d "node_modules/@samthomson/nostr-messaging/assets/sounds" ]; then
mkdir -p public/sounds
cp node_modules/@samthomson/nostr-messaging/assets/sounds/*.mp3 public/sounds/
echo "Copied message sounds to public/sounds/"
fi
+6 -2
View File
@@ -16,7 +16,7 @@ import NostrProvider from "@/components/NostrProvider";
import { NostrSync } from "@/components/NostrSync";
import { PlausibleProvider } from "@/components/PlausibleProvider";
import { SentryProvider } from "@/components/SentryProvider";
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
@@ -24,6 +24,7 @@ import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { PROTOCOL_MODE } from "@/lib/dmConstants";
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
import AppRouter from "./AppRouter";
@@ -123,6 +124,7 @@ const hardcodedConfig: AppConfig = {
sidebarOrder: [
"feed",
"notifications",
"dms",
"search",
"blobbi",
"badges",
@@ -200,12 +202,13 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login">
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProviderWrapper>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
@@ -215,6 +218,7 @@ export function App() {
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</DMProviderWrapper>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+4
View File
@@ -79,6 +79,8 @@ const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ de
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage })));
const MessagingSettings = lazy(() => import("./pages/MessagingSettings"));
const pollsDef = getExtraKindDef("polls")!;
const colorsDef = getExtraKindDef("colors")!;
@@ -160,6 +162,8 @@ export function AppRouter() {
<Route path="/" element={<HomePage />} />
<Route path="/feed" element={<Index />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/chats" element={<MessagesPage />} />
<Route path="/settings/messaging" element={<MessagingSettings />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/trends" element={<TrendsPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
+110
View File
@@ -0,0 +1,110 @@
import { type ReactNode, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { DMProvider } from '@samthomson/nostr-messaging/core';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useAuthorsBatch } from '@/hooks/useAuthorsBatch';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useIsMobile } from '@/hooks/useIsMobile';
import { toast } from '@/hooks/useToast';
import { getDisplayName } from '@/lib/getDisplayName';
import { APP_NEW_MESSAGE_SOUNDS } from '@/lib/messagingSounds';
interface DMProviderWrapperProps {
children: ReactNode;
}
export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const { mutateAsync: uploadFileMutation } = useUploadFile();
const isMobile = useIsMobile();
// Get the current user's follows
const { data: profileData } = useProfileSupplementary(user?.pubkey);
const follows = useMemo(() => profileData?.following ?? [], [profileData]);
// Wrap publishEvent to match the expected signature
const handlePublishEvent = async (event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<void> => {
await publishEvent(event);
};
// Wrap uploadFile to return just the URL string
const handleUploadFile = async (file: File): Promise<string> => {
const tags = await uploadFileMutation(file);
return tags[0][1]; // Return the URL from the first tag
};
// Wrap getDisplayName to match the expected signature
const handleGetDisplayName = (pubkey: string, metadata?: Parameters<typeof getDisplayName>[0]) => {
return getDisplayName(metadata, pubkey);
};
// Wrap toast to match the expected signature
const handleNotify = (options: { title?: string; description?: string; variant?: 'default' | 'destructive' }) => {
toast({
title: options.title,
description: options.description,
variant: options.variant,
});
};
const messaging = useMemo(() => config.messaging ?? {}, [config.messaging]);
// Discovery relays for DM inbox discovery
const discoveryRelays = useMemo(() => {
if (messaging.discoveryRelays?.length) {
return messaging.discoveryRelays;
}
return config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
}, [messaging.discoveryRelays, config.relayMetadata.relays]);
const relayMode = messaging.relayMode ?? 'hybrid';
const messagingEnabled = messaging.enabled ?? false;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? APP_NEW_MESSAGE_SOUNDS[0]?.id ?? '';
const devMode = messaging.devMode ?? false;
return (
<DMProvider
nostr={nostr}
user={user ?? null}
messagingConfig={{
enabled: messagingEnabled,
discoveryRelays,
relayMode,
renderInlineMedia,
devMode,
appName: config.appName,
appDescription: `Direct messages on ${config.appName}`,
soundPref: {
options: APP_NEW_MESSAGE_SOUNDS,
value: { enabled: soundEnabled, soundId },
onChange: () => {},
},
}}
onNotify={handleNotify}
getDisplayName={handleGetDisplayName}
fetchAuthorsBatch={useAuthorsBatch}
publishEvent={handlePublishEvent}
uploadFile={handleUploadFile}
follows={follows}
ui={{
showShorts: false,
showSearch: true,
isMobile,
}}
>
{children}
</DMProvider>
);
}
+1 -119
View File
@@ -9,125 +9,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
const MIN_QR_CONTRAST = 3;
/** Saturation threshold (%) above which a color is considered "colorful". */
const COLORFUL_SAT_MIN = 15;
/** Lightness range within which a color appears visually colorful. */
const COLORFUL_L_MIN = 20;
const COLORFUL_L_MAX = 80;
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
if (typeof document === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
if (!raw) return null;
const { h, s, l } = parseHsl(raw);
if ([h, s, l].some(isNaN)) return null;
return { h, s, l };
}
/**
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function darkenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l > 0 && ratio < MIN_QR_CONTRAST) {
l = Math.max(0, l - 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function lightenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l < 100 && ratio < MIN_QR_CONTRAST) {
l = Math.min(100, l + 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Choose the best module color from primary and foreground.
*
* Strongly prefers primary since it carries the theme's brand identity.
* Only picks foreground if it is colorful (saturation > threshold) AND
* has significantly better contrast (> 1.5x) against the QR background.
*/
function pickModuleColor(
primary: { h: number; s: number; l: number },
foreground: { h: number; s: number; l: number } | null,
bgRgb: [number, number, number],
): { h: number; s: number; l: number } {
const fgIsColorful = foreground
&& foreground.s >= COLORFUL_SAT_MIN
&& foreground.l >= COLORFUL_L_MIN
&& foreground.l <= COLORFUL_L_MAX;
if (!fgIsColorful) return primary;
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
const fgContrast = getContrastRatio(fgRgb, bgRgb);
// Foreground must be significantly better to override primary
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
}
/**
* Derive QR module and background hex colors from the active theme.
*
* Light themes: white background, best themed color as modules (darkened if needed).
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
*
* "Best themed color" is --primary by default. If --foreground is colorful
* (saturation > 15%) and offers better contrast, it wins instead.
*/
function getThemedQRColors(): { dark: string; light: string } {
const primary = readCssHsl('--primary');
const foreground = readCssHsl('--foreground');
const background = readCssHsl('--background');
if (!primary) return { dark: '#000000', light: '#ffffff' };
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
if (!isDark) {
const white: [number, number, number] = [255, 255, 255];
const module = pickModuleColor(primary, foreground, white);
return { dark: darkenToContrast(module, white), light: '#ffffff' };
}
if (!background) return { dark: '#ffffff', light: '#000000' };
const bgRgb = hslToRgb(background.h, background.s, background.l);
const module = pickModuleColor(primary, foreground, bgRgb);
return {
dark: lightenToContrast(module, bgRgb),
light: rgbToHex(...bgRgb),
};
}
import { getThemedQRColors } from '@/lib/qrColors';
interface FollowQRDialogProps {
open: boolean;
-6
View File
@@ -1,4 +1,3 @@
import { Capacitor } from "@capacitor/core";
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
import { useNostr } from "@nostrify/react";
import { useQueryClient } from "@tanstack/react-query";
@@ -309,11 +308,6 @@ function SetupQuestionnaire({
await downloadTextFile(filename, nsec);
// Let the user know where the file ended up on Android
if (Capacitor.getPlatform() === "android") {
toast({ title: "Key saved", description: `Saved to Download/${filename}` });
}
// Log in with the new key
login.nsec(nsec);
next();
-29
View File
@@ -6,7 +6,6 @@ import { DittoLogo } from '@/components/DittoLogo';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useTrendingTags } from '@/hooks/useTrending';
import { themePresets, coreToTokens, type CoreThemeColors } from '@/themes';
import { cn } from '@/lib/utils';
@@ -93,7 +92,6 @@ function ThemeSwatch({
export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
const { config } = useAppContext();
const { theme, customTheme, applyCustomTheme, setTheme } = useTheme();
const { data: trendingData } = useTrendingTags();
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
@@ -116,8 +114,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
return null;
}, [theme, customTheme]);
const trendingTags = trendingData?.tags?.slice(0, 12) ?? [];
const updateScrollButtons = () => {
const el = scrollRef.current;
if (!el) return;
@@ -245,31 +241,6 @@ export function LandingHero({ onLoginClick, onSignupClick }: LandingHeroProps) {
</div>
</div>
{/* ── Trending Hashtags ── */}
{trendingTags.length > 0 && (
<div className="px-4 pb-4 landing-hero-fade" style={{ animationDelay: '320ms' }}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
Trending now
</p>
<div className="flex flex-wrap gap-1.5">
{trendingTags.map(({ tag, accounts }) => (
<Link
key={tag}
to={`/t/${tag}`}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-secondary/60 hover:bg-secondary text-xs font-medium text-secondary-foreground transition-colors"
>
<span className="text-primary">#</span>{tag}
{accounts > 1 && (
<span className="text-muted-foreground text-[10px] ml-0.5">
{accounts}
</span>
)}
</Link>
))}
</div>
</div>
)}
{/* ── Divider into feed ── */}
<div className="border-b border-border" />
</div>
+212
View File
@@ -0,0 +1,212 @@
import { useState, useCallback, useEffect } from 'react';
import { Zap, Copy, Check, ExternalLink } from 'lucide-react';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { openUrl } from '@/lib/downloadFile';
import { getThemedQRColors } from '@/lib/qrColors';
import { cn } from '@/lib/utils';
interface LightningInvoiceCardProps {
invoice: string;
className?: string;
}
/** Parse the sats amount from a BOLT11 invoice's human-readable part. */
function parseBolt11Amount(bolt11: string): number | null {
const match = bolt11.toLowerCase().match(/^ln\w+?(\d+)([munp]?)1/);
if (!match) return null;
const value = parseInt(match[1], 10);
if (isNaN(value)) return null;
const multiplier = match[2];
switch (multiplier) {
case 'm': return value * 100_000; // milli-BTC → sats
case 'u': return value * 100; // micro-BTC → sats
case 'n': return value / 10; // nano-BTC → sats
case 'p': return value / 10_000; // pico-BTC → sats
default: return value * 100_000_000; // BTC → sats
}
}
/** Format sats with thousands separator. */
function formatSats(sats: number): string {
if (sats < 1) return '<1';
const rounded = Math.round(sats);
return rounded.toLocaleString();
}
/**
* Inline card for rendering a BOLT11 lightning invoice found in note content.
* Horizontal layout with theme-aware QR that expands on tap.
* Amount text scales to fit via container query units.
*/
export function LightningInvoiceCard({ invoice, className }: LightningInvoiceCardProps) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const [paying, setPaying] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [qrExpanded, setQrExpanded] = useState(false);
const amount = parseBolt11Amount(invoice);
// Generate theme-aware QR code
useEffect(() => {
let cancelled = false;
const { dark, light } = getThemedQRColors();
QRCode.toDataURL(invoice.toUpperCase(), {
width: 400,
margin: 2,
color: { dark, light },
errorCorrectionLevel: 'M',
}).then((url) => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {});
return () => { cancelled = true; };
}, [invoice]);
const handleCopy = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({ title: 'Copied', description: 'Lightning invoice copied to clipboard' });
} catch {
toast({ title: 'Failed to copy', variant: 'destructive' });
}
}, [invoice, toast]);
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(t);
}, [copied]);
const handleOpenWallet = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
await openUrl(`lightning:${invoice}`);
}, [invoice]);
const handlePayWebLN = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
const webln = (globalThis as { webln?: { enable?: () => Promise<void>; sendPayment?: (invoice: string) => Promise<unknown> } }).webln;
if (!webln?.sendPayment) return;
try {
setPaying(true);
if (webln.enable) await webln.enable();
await webln.sendPayment(invoice);
toast({ title: 'Payment sent' });
} catch {
toast({ title: 'Payment failed', variant: 'destructive' });
} finally {
setPaying(false);
}
}, [invoice, toast]);
const toggleQr = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setQrExpanded((v) => !v);
}, []);
const hasWebLN = typeof globalThis !== 'undefined' && !!(globalThis as { webln?: unknown }).webln;
const qrImage = qrDataUrl ? (
<img
src={qrDataUrl}
alt="Lightning Invoice QR"
className="rounded-xl"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="aspect-square rounded-xl bg-muted animate-pulse" />
);
return (
<div
className={cn(
'isolate my-2.5 relative rounded-2xl border border-border overflow-hidden @container',
className,
)}
onClick={(e) => e.stopPropagation()}
>
{/* Subtle accent glow behind QR area */}
<div className="absolute -z-10 top-0 left-0 w-44 h-44 bg-primary/[0.06] rounded-full blur-2xl" />
{/* Expanded QR -- square container that replaces the normal layout */}
{qrExpanded ? (
<button
onClick={toggleQr}
className="w-full aspect-square cursor-pointer p-5"
>
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="Lightning Invoice QR"
className="w-full h-full rounded-xl"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<div className="w-full h-full rounded-xl bg-muted animate-pulse" />
)}
</button>
) : (
<div className="flex gap-1">
{/* QR code -- tappable thumbnail */}
<button onClick={toggleQr} className="shrink-0 p-3 cursor-pointer">
<div className="size-28 sm:size-40">{qrImage}</div>
</button>
{/* Info column */}
<div className="flex flex-col justify-between py-3.5 pr-3.5 min-w-0 flex-1 gap-2">
{/* Label + amount */}
<div>
<div className="flex items-center gap-1.5 text-muted-foreground font-medium whitespace-nowrap" style={{ fontSize: 'clamp(0.8rem, 3.5cqw, 1.05rem)' }}>
<span className="flex items-center justify-center size-5 sm:size-6 rounded-full bg-primary/15 shrink-0">
<Zap className="size-3 sm:size-3.5 text-primary fill-primary" />
</span>
Lightning Invoice
</div>
{amount !== null && (
<div className="font-bold tracking-tight leading-none mt-1 whitespace-nowrap" style={{ fontSize: 'clamp(1.5rem, 8cqw, 2.5rem)' }}>
{formatSats(amount)}
<span className="font-normal text-muted-foreground ml-1" style={{ fontSize: 'clamp(0.75rem, 3.5cqw, 1.125rem)' }}>sats</span>
</div>
)}
</div>
{/* Invoice string with copy */}
<button
onClick={handleCopy}
className="flex items-center gap-1.5 group max-w-full"
>
<span className="truncate text-xs font-mono text-muted-foreground group-hover:text-foreground transition-colors">
{invoice}
</span>
{copied
? <Check className="size-3.5 text-primary shrink-0" />
: <Copy className="size-3.5 text-muted-foreground group-hover:text-foreground shrink-0 transition-colors" />}
</button>
{/* Action buttons */}
<div className="flex items-center gap-2">
{hasWebLN && (
<Button
size="sm"
onClick={handlePayWebLN}
disabled={paying}
className="gap-1.5 h-9 rounded-xl"
>
<Zap className="size-3.5" />
{paying ? 'Paying...' : 'Pay'}
</Button>
)}
<Button size="sm" variant="outline" onClick={handleOpenWallet} className="gap-1.5 h-9 rounded-xl">
<ExternalLink className="size-3.5" />
Open in Wallet
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+4 -57
View File
@@ -1,7 +1,6 @@
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { RightSidebar } from '@/components/RightSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { MobileBottomNav } from '@/components/MobileBottomNav';
@@ -42,61 +41,8 @@ function PageSkeleton() {
))}
</div>
</main>
{/* Right sidebar skeleton — mirrors RightSidebar's container + widget card styling */}
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
{/* Trends widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-14" />
</div>
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-[28px] w-[50px] rounded" />
</div>
))}
</div>
</section>
{/* Hot Posts widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
))}
</div>
</section>
{/* New Accounts widget skeleton */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<Skeleton className="h-6 w-28 mb-3" />
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5 flex-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
))}
</div>
</section>
</aside>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
</>
);
}
@@ -158,7 +104,8 @@ function MainLayoutInner() {
</div>
)}
</div>
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
</Suspense>
</div>
+2 -2
View File
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
// Keep the nav visible while search is open regardless of scroll
const isHidden = hidden && !searchOpen;
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
+31 -11
View File
@@ -101,6 +101,28 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const wikipediaIndex = hasWikipedia ? nextMobileIdx++ : -1;
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
// Lock body scroll while the search sheet is open.
// overflow:hidden alone is unreliable on mobile Safari, so we also
// block touchmove on the document (except inside the results scroller).
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const preventScroll = (e: TouchEvent) => {
// Allow scrolling inside the results list
const target = e.target as HTMLElement;
if (target.closest?.('[data-mobile-search-results]')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener('touchmove', preventScroll);
};
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) {
@@ -224,8 +246,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
onClick={handleClose}
/>
{/* Bottom sheet — sits above the bottom nav bar */}
<div className="fixed left-0 right-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 bottom-mobile-nav">
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
@@ -293,7 +315,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
)}
{/* Input bar */}
<div className="flex items-center px-6 py-3">
<div className="flex items-center px-6 py-3 safe-area-bottom">
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
{isFetching ? (
<svg
@@ -321,14 +343,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
autoCapitalize="off"
spellCheck={false}
/>
{query.length > 0 && (
<button
onClick={() => setQuery('')}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
)}
<button
onClick={handleClose}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
</div>
</div>
</div>
+10 -10
View File
@@ -5,7 +5,7 @@ import { NoteContent } from './NoteContent';
import type { NostrEvent } from '@nostrify/nostrify';
describe('NoteContent', () => {
it('linkifies URLs in kind 1 events', () => {
it('linkifies URLs in kind 1 events', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://example.com' });
const link = await screen.findByRole('link', { name: 'https://example.com' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
});
it('linkifies URLs in kind 1111 events (comments)', () => {
it('linkifies URLs in kind 1111 events (comments)', async () => {
const event: NostrEvent = {
id: 'test-comment-id',
pubkey: 'test-pubkey',
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
expect(link).toHaveAttribute('target', '_blank');
});
it('handles text without URLs correctly', () => {
it('handles text without URLs correctly', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
</TestApp>
);
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders hashtags as links', () => {
it('renders hashtags as links', async () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
</TestApp>
);
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
expect(nostrHashtag).toBeInTheDocument();
@@ -102,7 +102,7 @@ describe('NoteContent', () => {
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
});
it('generates deterministic names for users without metadata and styles them differently', () => {
it('generates deterministic names for users without metadata and styles them differently', async () => {
// Use a valid npub for testing
const event: NostrEvent = {
id: 'test-id',
@@ -121,7 +121,7 @@ describe('NoteContent', () => {
);
// The mention should be rendered with a deterministic name
const mention = screen.getByRole('link');
const mention = await screen.findByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (muted-foreground instead of primary)
+20 -8
View File
@@ -8,6 +8,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { LinkEmbed } from '@/components/LinkEmbed';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
@@ -176,7 +177,8 @@ type ContentToken =
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
| { type: 'nostr-link'; id: string; raw: string }
| { type: 'hashtag'; tag: string; raw: string }
| { type: 'relay-link'; url: string };
| { type: 'relay-link'; url: string }
| { type: 'lightning-invoice'; invoice: string };
/**
* Regex segment matching a single visual emoji unit, including:
@@ -234,9 +236,10 @@ export function NoteContent({
}: NoteContentProps) {
const tokens = useMemo(() => {
const text = event.content;
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
const regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
const result: ContentToken[] = [];
let lastIndex = 0;
@@ -244,9 +247,11 @@ export function NoteContent({
let hadMatches = false;
while ((match = regex.exec(text)) !== null) {
let [fullMatch, url] = match;
const hashtag = match[6];
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
let [fullMatch] = match;
const bolt11 = match[1];
let url = match[2];
const hashtag = match[7];
const { 3: nostrPrefix, 4: nostrData, 5: barePrefix, 6: bareData } = match;
const index = match.index;
hadMatches = true;
@@ -255,7 +260,9 @@ export function NoteContent({
result.push({ type: 'text', value: text.substring(lastIndex, index) });
}
if (url) {
if (bolt11) {
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
} else if (url) {
// Strip common trailing punctuation that's likely not part of the URL
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
@@ -409,7 +416,7 @@ export function NoteContent({
for (let i = 0; i < result.length; i++) {
const token = result[i];
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|| (token.type === 'naddr-embed' && !token.url);
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
if (isBlock) {
// Strip all trailing whitespace from the preceding text token.
@@ -668,6 +675,11 @@ export function NoteContent({
{token.url}
</Link>
);
case 'lightning-invoice':
if (disableEmbeds) {
return <span key={i} className="text-primary break-all">{token.invoice}</span>;
}
return <LightningInvoiceCard key={i} invoice={token.invoice} />;
}
})}
-5
View File
@@ -1,7 +1,6 @@
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { Capacitor } from '@capacitor/core';
import React, { useState, useEffect, useRef } from 'react';
import { Download, Eye, EyeOff, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -59,10 +58,6 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
await downloadTextFile(filename, nsec);
if (Capacitor.getPlatform() === 'android') {
toast({ title: 'Key saved', description: `Saved to Download/${filename}` });
}
// Continue to profile step
login.nsec(nsec);
setStep('profile');
+12
View File
@@ -241,6 +241,18 @@ export interface AppConfig {
savedFeeds: SavedFeed[];
/** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */
imageQuality: 'compressed' | 'original';
/** Messaging configuration (custom sounds, discovery relays, etc.) */
messaging?: {
/** Whether direct messaging is enabled for this account/session. Default: false. */
enabled?: boolean;
customSoundUrl?: string;
discoveryRelays?: string[];
relayMode?: 'discovery' | 'hybrid' | 'strict_outbox';
renderInlineMedia?: boolean;
soundEnabled?: boolean;
soundId?: string;
devMode?: boolean;
};
/** Hex pubkey of the curator whose follow list defines the Ditto feed. */
curatorPubkey?: string;
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
+14
View File
@@ -0,0 +1,14 @@
import { useAuthors } from './useAuthors';
/**
* Batch fetch author profiles for DM messaging integration.
*
* This hook wraps useAuthors to match the interface expected by
* @samthomson/nostr-messaging's DMProvider.
*
* @param pubkeys - Array of pubkeys to fetch profiles for
* @returns Query result with map of pubkey -> AuthorData
*/
export function useAuthorsBatch(pubkeys: string[]) {
return useAuthors(pubkeys);
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Re-exports DM hooks from the @samthomson/nostr-messaging package.
* Separated from DMProviderWrapper to avoid Fast Refresh warnings.
*/
export {
useDMContext,
useConversationMessages,
} from '@samthomson/nostr-messaging/core';
+11 -26
View File
@@ -4,39 +4,24 @@ 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 Android it writes to the public Download folder via ExternalStorage.
* On iOS it writes to a temp file and presents the native share sheet.
* On native (Android & iOS) the file is saved to the app's Documents
* directory, which is visible in the iOS Files app and Android's
* app-scoped documents. No permissions are required.
*/
export async function downloadTextFile(filename: string, content: string): Promise<void> {
const platform = Capacitor.getPlatform();
if (Capacitor.isNativePlatform()) {
const { Filesystem, Directory, Encoding } = await import('@capacitor/filesystem');
if (platform === 'android') {
const { Filesystem, Directory } = await import('@capacitor/filesystem');
// Write to the public Download folder. On Android 11+ no storage
// permissions are required for app-created files in shared directories.
// Write straight to Documents — visible in the iOS Files app and
// Android's app-scoped documents. No storage permissions needed.
// NOTE: encoding is required — without it Capacitor expects base64 data
// and will throw for plain-text strings.
await Filesystem.writeFile({
path: `Download/${filename}`,
data: content,
directory: Directory.ExternalStorage,
});
} else if (platform === 'ios') {
const { Filesystem, Directory } = await import('@capacitor/filesystem');
const { Share } = await import('@capacitor/share');
const result = await Filesystem.writeFile({
path: filename,
data: content,
directory: Directory.Cache,
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
// On iOS there is no user-visible Downloads folder, so present the
// share sheet and let the user choose where to save / send the file.
try {
await Share.share({ title: filename, url: result.uri });
} catch {
// User dismissed the share sheet — not a real failure
}
} else {
// Web: use the anchor-click download pattern
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' });
+10
View File
@@ -0,0 +1,10 @@
import { DEFAULT_NEW_MESSAGE_SOUNDS, type NewMessageSoundOption } from '@samthomson/nostr-messaging/core';
export const APP_NEW_MESSAGE_SOUNDS: NewMessageSoundOption[] = [
...DEFAULT_NEW_MESSAGE_SOUNDS,
{
id: 'ditto',
label: 'Ditto',
url: '/custom-sounds/ditto.mp3',
},
];
+119
View File
@@ -0,0 +1,119 @@
import { parseHsl, hslToRgb, rgbToHex, getContrastRatio, isDarkTheme } from '@/lib/colorUtils';
/** Minimum contrast ratio between QR modules and background for reliable scanning. */
const MIN_QR_CONTRAST = 3;
/** Saturation threshold (%) above which a color is considered "colorful". */
const COLORFUL_SAT_MIN = 15;
/** Lightness range within which a color appears visually colorful. */
const COLORFUL_L_MIN = 20;
const COLORFUL_L_MAX = 80;
/** Read a CSS custom property as a parsed HSL object, or null if unavailable. */
function readCssHsl(prop: string): { h: number; s: number; l: number } | null {
if (typeof document === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop).trim();
if (!raw) return null;
const { h, s, l } = parseHsl(raw);
if ([h, s, l].some(isNaN)) return null;
return { h, s, l };
}
/**
* Darken an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function darkenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l > 0 && ratio < MIN_QR_CONTRAST) {
l = Math.max(0, l - 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Lighten an HSL color until it reaches the minimum contrast against a reference RGB.
* Returns the adjusted hex color.
*/
function lightenToContrast(
hsl: { h: number; s: number; l: number },
refRgb: [number, number, number],
): string {
let l = hsl.l;
let rgb = hslToRgb(hsl.h, hsl.s, l);
let ratio = getContrastRatio(rgb, refRgb);
while (l < 100 && ratio < MIN_QR_CONTRAST) {
l = Math.min(100, l + 2);
rgb = hslToRgb(hsl.h, hsl.s, l);
ratio = getContrastRatio(rgb, refRgb);
}
return rgbToHex(...rgb);
}
/**
* Choose the best module color from primary and foreground.
*
* Strongly prefers primary since it carries the theme's brand identity.
* Only picks foreground if it is colorful (saturation > threshold) AND
* has significantly better contrast (> 1.5x) against the QR background.
*/
function pickModuleColor(
primary: { h: number; s: number; l: number },
foreground: { h: number; s: number; l: number } | null,
bgRgb: [number, number, number],
): { h: number; s: number; l: number } {
const fgIsColorful = foreground
&& foreground.s >= COLORFUL_SAT_MIN
&& foreground.l >= COLORFUL_L_MIN
&& foreground.l <= COLORFUL_L_MAX;
if (!fgIsColorful) return primary;
const primaryRgb = hslToRgb(primary.h, primary.s, primary.l);
const fgRgb = hslToRgb(foreground.h, foreground.s, foreground.l);
const primaryContrast = getContrastRatio(primaryRgb, bgRgb);
const fgContrast = getContrastRatio(fgRgb, bgRgb);
// Foreground must be significantly better to override primary
return fgContrast > primaryContrast * 1.5 ? foreground : primary;
}
/**
* Derive QR module and background hex colors from the active theme.
*
* Light themes: white background, best themed color as modules (darkened if needed).
* Dark themes: --background as QR background, best themed color as modules (lightened if needed).
*
* "Best themed color" is --primary by default. If --foreground is colorful
* (saturation > 15%) and offers better contrast, it wins instead.
*/
export function getThemedQRColors(): { dark: string; light: string } {
const primary = readCssHsl('--primary');
const foreground = readCssHsl('--foreground');
const background = readCssHsl('--background');
if (!primary) return { dark: '#000000', light: '#ffffff' };
const isDark = background ? isDarkTheme(`${background.h} ${background.s}% ${background.l}%`) : false;
if (!isDark) {
const white: [number, number, number] = [255, 255, 255];
const module = pickModuleColor(primary, foreground, white);
return { dark: darkenToContrast(module, white), light: '#ffffff' };
}
if (!background) return { dark: '#ffffff', light: '#000000' };
const bgRgb = hslToRgb(background.h, background.s, background.l);
const module = pickModuleColor(primary, foreground, bgRgb);
return {
dark: lightenToContrast(module, bgRgb),
light: rgbToHex(...bgRgb),
};
}
+10
View File
@@ -245,6 +245,16 @@ export const AppConfigSchema = z.object({
})
).optional().default([]),
imageQuality: z.enum(['compressed', 'original']),
messaging: z.object({
enabled: z.boolean().optional(),
customSoundUrl: z.string().optional(),
discoveryRelays: z.array(z.string().url()).optional(),
relayMode: z.enum(['discovery', 'hybrid', 'strict_outbox']).optional(),
renderInlineMedia: z.boolean().optional(),
soundEnabled: z.boolean().optional(),
soundId: z.string().optional(),
devMode: z.boolean().optional(),
}).optional(),
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
});
+44
View File
@@ -0,0 +1,44 @@
import { Capacitor } from '@capacitor/core';
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
/**
* Storage adapter that uses native secure storage (iOS Keychain / Android KeyStore)
* on Capacitor builds and falls back to localStorage on web.
*
* Implements the `NLoginStorage` interface from @nostrify/react.
*
* On the first native read, if the key is not found in secure storage but exists
* in localStorage, it is automatically migrated to secure storage and the
* plaintext localStorage copy is removed.
*/
export const secureStorage = {
async getItem(key: string): Promise<string | null> {
if (!Capacitor.isNativePlatform()) {
return localStorage.getItem(key);
}
try {
const { value } = await SecureStoragePlugin.get({ key });
return value;
} catch {
// Key not found in secure storage — check localStorage for migration.
const legacy = localStorage.getItem(key);
if (legacy !== null) {
// Migrate to secure storage and remove the plaintext copy.
await SecureStoragePlugin.set({ key, value: legacy });
localStorage.removeItem(key);
return legacy;
}
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
if (!Capacitor.isNativePlatform()) {
localStorage.setItem(key, value);
return;
}
await SecureStoragePlugin.set({ key, value });
},
};
+8 -1
View File
@@ -15,7 +15,7 @@ import {
Earth,
Film,
HelpCircle,
Mail,
MessageSquare,
MessageSquareMore,
Mic,
@@ -110,6 +110,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
icon: Bell,
requiresAuth: true,
},
{
id: "dms",
label: "Chats",
path: "/chats",
icon: Mail,
requiresAuth: true,
},
{ id: "search", label: "Search", path: "/search", icon: Search },
{ id: "trends", label: "Trends", path: "/trends", icon: TrendingUp },
{
+3
View File
@@ -22,9 +22,12 @@ import '@fontsource-variable/inter';
// (class changes for builtin themes, style-content changes for custom themes).
import { Capacitor } from '@capacitor/core';
import { StatusBar, Style } from '@capacitor/status-bar';
import { Keyboard } from '@capacitor/keyboard';
import { getBackgroundThemeMode, getBackgroundHex } from '@/lib/colorUtils';
if (Capacitor.isNativePlatform()) {
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
/**
* Read --background from the computed style of <html>, convert the HSL
* value to a hex color, and update the native status bar to match.
+38
View File
@@ -0,0 +1,38 @@
import { DMMessagingInterface } from '@samthomson/nostr-messaging/ui';
import { Link } from 'react-router-dom';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
export function MessagesPage() {
const { config } = useAppContext();
const messagingEnabled = config.messaging?.enabled ?? false;
// Hide the right sidebar and expand the main content area for messaging.
// noOverscroll: avoid pb-overscroll on the main column so this fixed-height layout doesn't get extra scroll.
useLayoutOptions({
rightSidebar: null,
noMaxWidth: true,
noOverscroll: true,
wrapperClassName: 'max-w-full',
});
return (
<div className="h-dvh flex flex-col">
{messagingEnabled ? (
<DMMessagingInterface />
) : (
<div className="flex-1 flex items-center justify-center p-6">
<div className="max-w-md text-center space-y-3">
<h2 className="text-xl font-semibold">Chats are turned off</h2>
<p className="text-sm text-muted-foreground">
Enable messaging in Settings to start using chats.
</p>
<Link to="/settings/messaging" className="inline-block text-sm text-primary hover:underline">
Open Messaging Settings
</Link>
</div>
</div>
)}
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/useAppContext';
import { useDMContext } from '@/hooks/useDMHooks';
import { RelayListManager } from '@/components/RelayListManager';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, AlertCircle, Play } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { getMediaCacheStats, type RelayMode } from '@samthomson/nostr-messaging/core';
import { IntroImage } from '@/components/IntroImage';
import { APP_NEW_MESSAGE_SOUNDS } from '@/lib/messagingSounds';
export default function MessagingSettings() {
const { config, updateConfig } = useAppContext();
const {
subscriptions,
messagingState,
isLoading: dmIsLoading,
clearCacheAndRefetch,
} = useDMContext();
const messaging = config.messaging ?? {};
const [mediaCacheStats, setMediaCacheStats] = useState<{ count: number; size: number } | null>(null);
useSeoMeta({
title: 'Messages | Settings | Ditto',
description: 'Configure your direct messaging settings.',
});
useEffect(() => {
getMediaCacheStats().then(setMediaCacheStats).catch(() => {
setMediaCacheStats({ count: 0, size: 0 });
});
}, []);
const preloadedSoundsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
useEffect(() => {
const map = new Map<string, HTMLAudioElement>();
APP_NEW_MESSAGE_SOUNDS.forEach((sound) => {
const audio = new Audio(sound.url);
audio.volume = 0.5;
audio.preload = 'auto';
map.set(sound.url, audio);
});
preloadedSoundsRef.current = map;
return () => {
map.clear();
};
}, []);
const relayMode = messaging.relayMode ?? 'hybrid';
const messagingEnabled = messaging.enabled ?? false;
const renderInlineMedia = messaging.renderInlineMedia ?? true;
const soundEnabled = messaging.soundEnabled ?? false;
const soundId = messaging.soundId ?? APP_NEW_MESSAGE_SOUNDS[0]?.id ?? '';
const devMode = messaging.devMode ?? false;
const handleMessagingEnabledChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, enabled: checked },
}));
};
const handleRelayModeChange = (mode: string) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, relayMode: mode as RelayMode },
}));
};
const handleRenderInlineMediaChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, renderInlineMedia: checked },
}));
};
const handleSoundIdChange = (id: string) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, soundEnabled: true, soundId: id },
}));
};
const handleDevModeChange = (checked: boolean) => {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, devMode: checked },
}));
};
const handlePlaySound = useCallback((soundUrl: string) => {
try {
const preloaded = preloadedSoundsRef.current.get(soundUrl);
if (preloaded) {
preloaded.currentTime = 0;
preloaded.play().catch(() => {});
} else {
const audio = new Audio(soundUrl);
audio.volume = 0.5;
audio.play().catch(() => {});
}
} catch {
// Ignore errors
}
}, []);
const handleClearCache = async () => {
if (confirm('This will clear all cached messages and re-fetch from relays. Continue?')) {
await clearCacheAndRefetch();
const stats = await getMediaCacheStats();
setMediaCacheStats(stats);
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const conversationCount = messagingState ? Object.keys(messagingState.conversationMetadata).length : 0;
const totalMessages = messagingState
? Object.values(messagingState.conversationMessages).reduce((sum, msgs) => sum + msgs.length, 0)
: 0;
const lastSync = messagingState?.syncState?.lastCacheTime
? new Date(messagingState.syncState.lastCacheTime).toLocaleString()
: 'Never';
return (
<main className="">
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Messages</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Configure direct messaging settings, relays, and cache
</p>
</div>
</div>
</div>
<div className="p-4">
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
<IntroImage src="/messaging-intro.png" />
<div className="min-w-0">
<h2 className="text-sm font-semibold">Direct Messaging</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Manage your encrypted messaging settings and relay connections
</p>
</div>
</div>
<div className="space-y-6">
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Messaging</CardTitle>
<CardDescription>
Enable or disable chats in Ditto
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="messaging-enabled">Enable Messaging</Label>
<p className="text-sm text-muted-foreground">
Turn chats on to use inbox, sync, and messaging features
</p>
</div>
<Switch
id="messaging-enabled"
checked={messagingEnabled}
onCheckedChange={handleMessagingEnabledChange}
/>
</div>
</CardContent>
</Card>
{!messagingEnabled && (
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
Messaging is currently off. Enable it above to reveal relay, cache, and advanced chat settings.
</p>
</CardContent>
</Card>
)}
{messagingEnabled && (
<>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>General</CardTitle>
<CardDescription>
Configure how messages are displayed and notified
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="render-inline-media">Render Inline Media</Label>
<p className="text-sm text-muted-foreground">
Show images and videos directly in messages
</p>
</div>
<Switch
id="render-inline-media"
checked={renderInlineMedia}
onCheckedChange={handleRenderInlineMediaChange}
/>
</div>
<div className="border-t border-border/50 pt-6">
<div className="space-y-3">
<Label>Sound</Label>
<p className="text-sm text-muted-foreground mb-3">
Play a sound when a DM arrives
</p>
<RadioGroup
className="space-y-2"
value={soundEnabled ? soundId : 'none'}
onValueChange={(val) => {
if (val === 'none') {
updateConfig((prev) => ({
...prev,
messaging: { ...messaging, soundEnabled: false },
}));
} else {
handleSoundIdChange(val);
}
}}
>
<div className="flex min-h-9 items-center justify-between space-x-3">
<div className="flex items-center space-x-3">
<RadioGroupItem value="none" id="sound-none" />
<Label htmlFor="sound-none" className="font-normal cursor-pointer">
None
</Label>
</div>
</div>
{APP_NEW_MESSAGE_SOUNDS.map((sound) => (
<div
key={sound.id}
className="group flex min-h-9 items-center justify-between space-x-3"
>
<div className="flex items-center space-x-3">
<RadioGroupItem value={sound.id} id={`sound-${sound.id}`} />
<Label
htmlFor={`sound-${sound.id}`}
className="font-normal cursor-pointer"
>
{sound.label}
</Label>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handlePlaySound(sound.url)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</RadioGroup>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Relay Mode</CardTitle>
<CardDescription>
Control how relays are chosen for direct messages
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Label>Connection Mode</Label>
<RadioGroup value={relayMode} onValueChange={handleRelayModeChange}>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="discovery" id="mode-discovery" />
<div className="space-y-1">
<Label htmlFor="mode-discovery" className="font-normal cursor-pointer">
Discovery Only
</Label>
<p className="text-sm text-muted-foreground">
Only relays from the discovery list; fastest, may miss messages
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="hybrid" id="mode-hybrid" />
<div className="space-y-1">
<Label htmlFor="mode-hybrid" className="font-normal cursor-pointer">
Hybrid <Badge variant="secondary" className="ml-2">Recommended</Badge>
</Label>
<p className="text-sm text-muted-foreground">
Discovery relays + user inbox relays
</p>
</div>
</div>
<div className="flex items-start space-x-3 space-y-0">
<RadioGroupItem value="strict_outbox" id="mode-strict" />
<div className="space-y-1">
<Label htmlFor="mode-strict" className="font-normal cursor-pointer">
Strict Outbox
</Label>
<p className="text-sm text-muted-foreground">
Only each user's published inbox relays (NIP-65/NIP-17). More private, but not everyone publishes relay lists yet - you may miss DMs to or from people who don't.
</p>
</div>
</div>
</RadioGroup>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Relays</CardTitle>
<CardDescription>
Discovery relays, NIP-65 inbox/outbox, and DM inbox
</CardDescription>
</CardHeader>
<CardContent>
<RelayListManager />
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Cache & Storage</CardTitle>
<CardDescription>
View cache status and manage stored messages
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label>Connection Status</Label>
<div className="grid grid-cols-2 gap-3">
<div className="bg-secondary/20 p-3 rounded-lg">
<div className="text-sm font-medium mb-1">NIP-04 (Legacy)</div>
<Badge variant={subscriptions.isNIP4Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP4Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="bg-secondary/20 p-3 rounded-lg">
<div className="text-sm font-medium mb-1">NIP-17 (Private)</div>
<Badge variant={subscriptions.isNIP17Connected ? 'default' : 'secondary'}>
{subscriptions.isNIP17Connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
</div>
</div>
<div className="border-t border-border/50 pt-6">
<div className="space-y-3">
<Label>Cache Statistics</Label>
<div className="bg-secondary/20 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Conversations:</span>
<span className="font-medium">{conversationCount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Messages:</span>
<span className="font-medium">{totalMessages}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Sync:</span>
<span className="font-medium">{lastSync}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Media Files Cached:</span>
<span className="font-medium">{mediaCacheStats?.count ?? '...'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Media Cache Size:</span>
<span className="font-medium">
{mediaCacheStats ? formatBytes(mediaCacheStats.size) : '...'}
</span>
</div>
</div>
</div>
</div>
<div className="border-t border-border/50 pt-6">
<Button
variant="destructive"
onClick={handleClearCache}
disabled={dmIsLoading}
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Clear Cache & Refetch
</Button>
<p className="text-sm text-muted-foreground mt-2">
This will clear all cached messages and re-fetch from relays. Use this if messages are missing or out of sync.
</p>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle>Advanced</CardTitle>
<CardDescription>
Developer and debugging options
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<Label htmlFor="dev-mode">Developer Mode</Label>
<p className="text-sm text-muted-foreground">
Show extra debug UI (seal payload, decryption details)
</p>
<div className="flex items-center gap-2 mt-2">
<AlertCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Only enable if you need to debug message encryption
</span>
</div>
</div>
<Switch
id="dev-mode"
checked={devMode}
onCheckedChange={handleDevModeChange}
/>
</div>
</CardContent>
</Card>
</>
)}
</div>
</div>
</main>
);
}
+2 -8
View File
@@ -2185,13 +2185,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
) : displayName}
</h2>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" showCheck />
)}
{(metadata?.lud16 || metadata?.lud06) && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Zap className="size-3.5 text-amber-500 shrink-0" />
<span className="truncate">{metadata.lud16 || metadata.lud06}</span>
</div>
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
)}
{metadata?.website && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
@@ -2202,7 +2196,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
>
{metadata.website.replace(/^https?:\/\//, '')}
{metadata.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
</div>
)}
+8
View File
@@ -57,6 +57,14 @@ const settingsSections: SettingsSection[] = [
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'messaging',
label: 'Messages',
description: 'Direct messaging settings, relays, and cache',
illustration: '/messaging-intro.png',
path: '/settings/messaging',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
+1
View File
@@ -8,6 +8,7 @@ export default {
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@samthomson/nostr-messaging/dist/**/*.js",
],
prefix: "",
theme: {
+1
View File
@@ -176,6 +176,7 @@ export default defineConfig(({ mode }) => {
alias: {
"@": path.resolve(__dirname, "./src"),
},
dedupe: ['react', 'react-dom', 'react/jsx-runtime'],
},
};
});