Compare commits

..

13 Commits

Author SHA1 Message Date
Alex Gleason 3253d6a2ec Use full sandbox ID for origin isolation on iOS, fix Android bridge origin
iOS: Remove the 12-character truncation of the sandbox ID in the custom
URL scheme. The full HMAC-derived ID is now used (sbx-<fullId>), giving
each sandbox the full 256 bits of entropy for origin isolation. There is
no documented length limit on custom URL schemes in RFC 3986 or Apple's
WKURLSchemeHandler API.

Android: Fix the bridge script's synthesized event.origin to report the
sandbox's actual WebView origin (https://<id>.sandbox.native) instead of
a hardcoded 'sandbox://app' shared by all sandboxes.
2026-04-08 19:52:37 -05:00
Alex Gleason e035d8894f Fix nsite embed overlapping nav bar on iOS Capacitor
The nav bar used h-11 (fixed 44px height) with safe-area-top padding,
which compressed the padding inside the fixed height instead of expanding
to accommodate it. This caused the native WebView to be positioned too
high, overlapping the nav bar by roughly half its height.

Changing to min-h-11 lets the nav bar grow when safe-area-inset-top is
present, pushing the iframe container to the correct position.
2026-04-08 19:15:57 -05:00
Alex Gleason 6dc7b5fa53 Fix bottom nav safe area: use semi-transparent spacer div
Use bg-background/85 on the safe area spacer to match the arc's
fill opacity, instead of the previous opaque bg-background.
2026-04-08 19:07:14 -05:00
Alex Gleason 5d2adce90e Fix bottom nav icon position: move safe-area padding to nav element
Putting safe-area-bottom on the arc container stretched the arc
SVG and shifted the icons. Instead, apply the safe-area padding
and matching bg-background/85 on the outer nav element so the
arc container and icon positions are unchanged.
2026-04-08 19:00:30 -05:00
Alex Gleason 36c4d6f877 Fix iOS bottom nav gap and enable swipe-back navigation
Bottom nav: Remove the separate opaque safe-area spacer div and
instead add safe-area-bottom padding to the arc container. The
arc SVG now stretches to cover the home-indicator zone with the
same semi-transparent fill, eliminating the jarring solid block.

contentInset: Change from 'automatic' to 'never' so WKWebView
does not double-up safe-area insets on top of the CSS env()
values already applied by the app.

Swipe-back: Capacitor 8 has no config option for this. Add a
DittoBridgeViewController subclass that sets
allowsBackForwardNavigationGestures on the WKWebView, and
point the storyboard at it.
2026-04-08 18:54:38 -05:00
Alex Gleason 9f7a77d3b9 Fix iOS bottom gap: change contentInset from 'automatic' to 'never'
WKWebView's 'automatic' contentInset adds its own safe-area
compensation to the scroll view, doubling up with the CSS
env(safe-area-inset-*) padding already applied by the app.
This caused a large gap below the bottom nav and shifted
fixed-position overlays like the nsite preview panel.

Setting contentInset to 'never' lets the CSS handle all safe
area insets exclusively, eliminating the double compensation.
2026-04-08 18:38:42 -05:00
Alex Gleason ceed989a57 Revert "Fix nsite preview panel layout on iOS/mobile"
This reverts commit 3a96c58de0.
2026-04-08 18:36:52 -05:00
Alex Gleason 3a96c58de0 Fix nsite preview panel layout on iOS/mobile
On mobile, the panel now uses fixed inset-0 instead of manually
calculated top/left/width/height from the center column rect.
This prevents the iframe from shifting into the nav bar area and
leaves no gap at the bottom. Safe-area padding handles the notch
and home indicator. Desktop retains the column-aligned positioning.
2026-04-08 18:33:02 -05:00
Alex Gleason 6aecb04e69 Enable iOS swipe-back navigation gesture in WKWebView 2026-04-08 18:22:50 -05:00
Alex Gleason feb3739222 Fix fullscreen sandbox z-index and safe area color mismatch
Portal fullscreen webxdc container to document.body so it escapes the
z-0 center column stacking context and renders above the mobile top bar.

Move safe-area-top from the outer container to the nav/controls bar in
both WebxdcEmbed and NsitePreviewDialog so the notch area is filled
with the bar's background color instead of the generic bg-background.
2026-04-08 17:59:33 -05:00
Alex Gleason d7c23a0a32 Add safe area padding to fullscreen webxdc and nsite preview on iOS 2026-04-08 17:50:43 -05:00
Alex Gleason 2dc28e98e6 Fix native SandboxPlugin registration on iOS/Android
Capacitor's JS proxy requires plugin headers exported at startup to
route calls to the native bridge. Without a header, calls short-circuit
to UNIMPLEMENTED entirely in JS, never reaching the native lazy-loader.

Since npx cap sync only discovers SPM-packaged plugins for
packageClassList, local plugins like SandboxPlugin are omitted.

Add scripts/patch-cap-config.mjs that appends local plugin class names
to capacitor.config.json after sync. Wire it into:
- npm run cap:sync (new convenience script)
- .gitlab-ci.yml build-apk job
2026-04-08 17:38:20 -05:00
Alex Gleason 2418d6af2f Add native SandboxPlugin for iOS and Android
SandboxFrame now detects Capacitor.isNativePlatform() and branches:

- Web: unchanged iframe.diy postMessage protocol
- Native: creates a WKWebView (iOS) or WebView (Android) overlay with
  a custom URL scheme handler that intercepts all requests and routes
  them through the same resolveFile/onRpc/injectedScripts/csp props

The consumers (Webxdc, NsitePreviewDialog) require no changes.

iOS plugin uses WKURLSchemeHandler (sbx-<id>://) for origin isolation
and WKScriptMessageHandler for bidirectional message passing.

Android plugin uses shouldInterceptRequest with a blocking CountDownLatch
pattern and addJavascriptInterface for the bridge.

Both plugins overlay the native WebView on top of Capacitor's WebView,
positioned via a placeholder div with ResizeObserver tracking.
2026-04-08 17:19:16 -05:00
53 changed files with 546 additions and 1725 deletions
-3
View File
@@ -37,9 +37,6 @@ 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
@@ -1 +0,0 @@
legacy-peer-deps=true
-22
View File
@@ -1,27 +1,5 @@
# 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
@@ -1,15 +0,0 @@
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.2"
versionName "2.6.1"
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,11 +11,9 @@ 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')
}
@@ -13,7 +13,7 @@ import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import android.widget.FrameLayout;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
@@ -80,11 +80,9 @@ public class SandboxPlugin extends Plugin {
sandboxes.put(sandboxId, sandbox);
// Add the WebView on top of the Capacitor WebView.
// The parent is a CoordinatorLayout — using the wrong LayoutParams
// type causes a ClassCastException when it intercepts touch events.
View capWebView = getBridge().getWebView();
ViewGroup parent = (ViewGroup) capWebView.getParent();
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
parent.addView(sandbox.webView, params);
@@ -128,7 +126,7 @@ public class SandboxPlugin extends Plugin {
return;
}
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(pxWidth, pxHeight);
params.leftMargin = pxX;
params.topMargin = pxY;
sandbox.webView.setLayoutParams(params);
@@ -387,7 +385,7 @@ public class SandboxPlugin extends Plugin {
" onMessage: function(data) {" +
" var event = {" +
" data: data," +
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
" origin: 'https://" + id + ".sandbox.native'," +
" source: window.parent," +
" type: 'message'" +
" };" +
-6
View File
@@ -8,9 +8,6 @@ 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')
@@ -19,6 +16,3 @@ 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')
+1 -6
View File
@@ -19,12 +19,7 @@ const config: CapacitorConfig = {
backgroundColor: '#14161f',
contentInset: 'never',
scheme: 'Ditto'
},
plugins: {
Keyboard: {
resizeOnFullScreen: true,
},
},
}
};
export default config;
-6
View File
@@ -1,6 +0,0 @@
services:
web:
build: .
restart: unless-stopped
expose:
- "80"
-31
View File
@@ -1,31 +0,0 @@
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", "ios"] },
{ ignores: ["dist", "android"] },
{
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.2;
MARKETING_VERSION = 2.6.1;
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.2;
MARKETING_VERSION = 2.6.1;
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
+2 -6
View File
@@ -14,11 +14,9 @@ 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: "CapacitorSecureStoragePlugin", path: "../../../node_modules/capacitor-secure-storage-plugin")
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar")
],
targets: [
.target(
@@ -28,11 +26,9 @@ 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: "CapacitorSecureStoragePlugin", package: "CapacitorSecureStoragePlugin")
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar")
]
)
]
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,35 +0,0 @@
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;
}
}
+97 -260
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -1,7 +1,7 @@
{
"name": "ditto",
"private": true,
"version": "2.6.2",
"version": "2.6.1",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -18,7 +18,6 @@
"@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",
@@ -67,7 +66,7 @@
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.1",
"@nostrify/react": "^0.5.0",
"@nostrify/react": "^0.4.1",
"@nostrify/types": "^0.36.9",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -97,14 +96,12 @@
"@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,27 +1,5 @@
# 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.

Before

Width:  |  Height:  |  Size: 13 KiB

-8
View File
@@ -1,8 +0,0 @@
#!/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
+2 -6
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,7 +24,6 @@ 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";
@@ -124,7 +123,6 @@ const hardcodedConfig: AppConfig = {
sidebarOrder: [
"feed",
"notifications",
"dms",
"search",
"blobbi",
"badges",
@@ -202,13 +200,12 @@ export function App() {
<SentryProvider>
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<NostrLoginProvider storageKey="nostr:login">
<NostrProvider>
<NostrSync />
<NativeNotifications />
<NWCProvider>
<DMProviderWrapper>
<DMProvider config={dmConfig}>
<EmotionDevProvider>
<TooltipProvider>
@@ -218,7 +215,6 @@ export function App() {
</TooltipProvider>
</EmotionDevProvider>
</DMProvider>
</DMProviderWrapper>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
-4
View File
@@ -79,8 +79,6 @@ 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")!;
@@ -162,8 +160,6 @@ 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
@@ -1,110 +0,0 @@
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>
);
}
+119 -1
View File
@@ -9,7 +9,125 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { getThemedQRColors } from '@/lib/qrColors';
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),
};
}
interface FollowQRDialogProps {
open: boolean;
+6
View File
@@ -1,3 +1,4 @@
import { Capacitor } from "@capacitor/core";
import type { NostrEvent, NostrMetadata } from "@nostrify/nostrify";
import { useNostr } from "@nostrify/react";
import { useQueryClient } from "@tanstack/react-query";
@@ -308,6 +309,11 @@ 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,6 +6,7 @@ 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';
@@ -92,6 +93,7 @@ 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);
@@ -114,6 +116,8 @@ 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;
@@ -241,6 +245,31 @@ 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
@@ -1,212 +0,0 @@
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>
);
}
+57 -4
View File
@@ -1,6 +1,7 @@
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';
@@ -41,8 +42,61 @@ function PageSkeleton() {
))}
</div>
</main>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
{/* 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>
</>
);
}
@@ -104,8 +158,7 @@ function MainLayoutInner() {
</div>
)}
</div>
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
{rightSidebar !== null && (rightSidebar ?? <RightSidebar />)}
</Suspense>
</div>
+2 -2
View File
@@ -40,8 +40,8 @@ export function MobileBottomNav() {
setSearchOpen((v) => !v);
}, []);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
// Keep the nav visible while search is open regardless of scroll
const isHidden = hidden && !searchOpen;
const displayName = metadata?.name || metadata?.display_name;
const isOnProfile = user && location.pathname === profileUrl;
+11 -31
View File
@@ -101,28 +101,6 @@ 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) {
@@ -246,8 +224,8 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
onClick={handleClose}
/>
{/* 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">
{/* 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">
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
@@ -315,7 +293,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
)}
{/* Input bar */}
<div className="flex items-center px-6 py-3 safe-area-bottom">
<div className="flex items-center px-6 py-3">
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
{isFetching ? (
<svg
@@ -343,12 +321,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
autoCapitalize="off"
spellCheck={false}
/>
<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>
{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>
)}
</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', async () => {
it('linkifies URLs in kind 1 events', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -22,13 +22,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = await screen.findByRole('link', { name: 'https://example.com' });
const link = screen.getByRole('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)', async () => {
it('linkifies URLs in kind 1111 events (comments)', () => {
const event: NostrEvent = {
id: 'test-comment-id',
pubkey: 'test-pubkey',
@@ -49,13 +49,13 @@ describe('NoteContent', () => {
</TestApp>
);
const link = await screen.findByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
const link = screen.getByRole('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', async () => {
it('handles text without URLs correctly', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -72,11 +72,11 @@ describe('NoteContent', () => {
</TestApp>
);
expect(await screen.findByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders hashtags as links', async () => {
it('renders hashtags as links', () => {
const event: NostrEvent = {
id: 'test-id',
pubkey: 'test-pubkey',
@@ -93,7 +93,7 @@ describe('NoteContent', () => {
</TestApp>
);
const nostrHashtag = await screen.findByRole('link', { name: '#nostr' });
const nostrHashtag = screen.getByRole('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', async () => {
it('generates deterministic names for users without metadata and styles them differently', () => {
// 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 = await screen.findByRole('link');
const mention = screen.getByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (muted-foreground instead of primary)
+8 -20
View File
@@ -8,7 +8,6 @@ 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';
@@ -177,8 +176,7 @@ 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: 'lightning-invoice'; invoice: string };
| { type: 'relay-link'; url: string };
/**
* Regex segment matching a single visual emoji unit, including:
@@ -236,10 +234,9 @@ export function NoteContent({
}: NoteContentProps) {
const tokens = useMemo(() => {
const text = event.content;
// 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)
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
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 regex = /((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu;
const result: ContentToken[] = [];
let lastIndex = 0;
@@ -247,11 +244,9 @@ export function NoteContent({
let hadMatches = false;
while ((match = regex.exec(text)) !== null) {
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;
let [fullMatch, url] = match;
const hashtag = match[6];
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
const index = match.index;
hadMatches = true;
@@ -260,9 +255,7 @@ export function NoteContent({
result.push({ type: 'text', value: text.substring(lastIndex, index) });
}
if (bolt11) {
result.push({ type: 'lightning-invoice', invoice: bolt11.toLowerCase() });
} else if (url) {
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(/^(.*?)([.,;:!?)\]]+)$/);
@@ -416,7 +409,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 === 'lightning-invoice';
|| (token.type === 'naddr-embed' && !token.url);
if (isBlock) {
// Strip all trailing whitespace from the preceding text token.
@@ -675,11 +668,6 @@ 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} />;
}
})}
+23 -32
View File
@@ -413,34 +413,7 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
const rect = el.getBoundingClientRect();
// Register listeners BEFORE creating the WebView. On Android,
// `shouldInterceptRequest` fires on a background thread as soon
// as the WebView starts loading — if the fetch listener isn't
// registered yet, the event is lost and the request times out
// (the thread blocks via CountDownLatch waiting for a response
// that never arrives).
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
if (event.id !== id) return;
handleNativeFetch(event);
},
);
listeners.push(fetchListener);
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
if (cancelled || destroyedRef.current) return;
// Create the native WebView. Fetch events from the initial load
// will be handled by the listeners registered above.
// Create the native WebView.
await SandboxPlugin.create({
id,
frame: {
@@ -458,6 +431,26 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
}
createdRef.current = true;
// Listen for fetch requests from the native URL scheme handler.
const fetchListener = await SandboxPlugin.addListener(
'fetch',
(event: SandboxFetchEvent) => {
if (event.id !== id) return;
handleNativeFetch(event);
},
);
listeners.push(fetchListener);
// Listen for script messages (custom JSON-RPC from injected scripts).
const scriptListener = await SandboxPlugin.addListener(
'scriptMessage',
(event: SandboxScriptMessageEvent) => {
if (event.id !== id) return;
handleNativeScriptMessage(event);
},
);
listeners.push(scriptListener);
}
// ---------------------------------------------------------------
@@ -570,10 +563,6 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
// -----------------------------------------------------------------
// Keep frame in sync with placeholder size/position
//
// Both consumers (WebxdcEmbed, NsitePreviewDialog) render inside
// position:fixed panels, so the placeholder never moves on scroll.
// A ResizeObserver is sufficient to track layout changes.
// -----------------------------------------------------------------
useEffect(() => {
@@ -598,9 +587,11 @@ const SandboxFrameNative = forwardRef<SandboxFrameHandle, SandboxFrameProps>(
const ro = new ResizeObserver(updateFrame);
ro.observe(el);
window.addEventListener('scroll', updateFrame, { passive: true });
return () => {
ro.disconnect();
window.removeEventListener('scroll', updateFrame);
};
}, [id]);
+129 -83
View File
@@ -1,10 +1,10 @@
import { useState, useRef, useCallback, useEffect, forwardRef } from 'react';
import { useState, useRef, useCallback, forwardRef } from 'react';
import { createPortal } from 'react-dom';
import { Blocks, Play, X, Gamepad2 } from 'lucide-react';
import { Blocks, Play, Maximize2, Minimize2, RotateCcw, X, Gamepad2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip';
import { Webxdc, type WebxdcHandle } from '@/components/Webxdc';
import { GameControls } from '@/components/GameControls';
import { useCenterColumn } from '@/contexts/LayoutContext';
import { useWebxdc } from '@/hooks/useWebxdc';
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
import { cn } from '@/lib/utils';
@@ -21,52 +21,38 @@ export interface WebxdcEmbedProps {
className?: string;
}
interface Rect { left: number; top: number; width: number; height: number }
/** Track the viewport-relative bounding rect of an element, updating on resize. */
function useElementRect(el: HTMLElement | null): Rect | null {
const [rect, setRect] = useState<Rect | null>(null);
useEffect(() => {
if (!el) { setRect(null); return; }
const measure = () => {
const r = el.getBoundingClientRect();
setRect({ left: r.left, top: r.top, width: r.width, height: r.height });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, [el]);
return rect;
}
/**
* Renders a webxdc app embedded in the feed. Shows a launch card initially,
* then opens a fullscreen panel (covering the center column on desktop, the
* full screen on mobile) when the user clicks Play — matching the nsite UX.
* Renders a webxdc app embedded in the feed. Shows a launch button initially,
* then loads the sandboxed iframe when the user clicks to interact.
*/
export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedProps) {
const [launched, setLaunched] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [iframeKey, setIframeKey] = useState(0);
const [showGamepad, setShowGamepad] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const webxdcHandleRef = useRef<WebxdcHandle>(null);
const centerColumn = useCenterColumn();
const columnRect = useElementRect(launched ? centerColumn : null);
// Derive a private, stable subdomain from a device-local seed + the identifier.
// This prevents event authors from choosing a subdomain that collides with
// another app's origin on iframe.diy.
const identifier = uuid ?? url;
const iframeId = deriveIframeSubdomain('webxdc', identifier);
const handleReload = useCallback(() => {
setIframeKey((k) => k + 1);
}, []);
const handleClose = useCallback(() => {
setLaunched(false);
setIsFullscreen(false);
setShowGamepad(false);
}, []);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
const toggleGamepad = useCallback(() => {
setShowGamepad((prev) => {
if (!prev) webxdcHandleRef.current?.focus();
@@ -109,83 +95,141 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
);
}
if (!centerColumn || !columnRect) return null;
// Clamp to viewport top edge so the panel never grows taller than the viewport.
const panelTop = Math.max(0, columnRect.top);
const panelHeight = window.innerHeight - panelTop;
return createPortal(
const content = (
<div
className="fixed z-50 flex flex-col bg-background"
style={{
left: columnRect.left,
top: panelTop,
width: columnRect.width,
height: panelHeight,
}}
ref={containerRef}
className={cn(
isFullscreen
? 'fixed inset-0 z-50 bg-background flex flex-col'
: 'mt-3 rounded-2xl border border-border overflow-hidden flex flex-col',
!isFullscreen && className,
)}
onClick={(e) => e.stopPropagation()}
>
{/* Nav bar */}
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
{/* App icon + name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Controls bar */}
<div className={cn(
'flex items-center justify-between px-3 py-1.5 bg-muted/60 border-b border-border',
isFullscreen ? 'safe-area-top' : 'rounded-t-2xl',
)}>
<div className="flex items-center gap-2 min-w-0">
{icon ? (
<img
src={icon}
alt={name ?? 'Webxdc App'}
className="size-6 rounded-md object-cover shrink-0"
className="size-5 rounded-md object-cover flex-shrink-0"
/>
) : (
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<Blocks className="size-3.5 text-primary/50" />
</div>
<Blocks className="size-4 text-muted-foreground flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">{name ?? 'Webxdc App'}</span>
<span className="text-xs font-medium text-muted-foreground truncate">
{name ?? 'Webxdc App'}
</span>
</div>
{/* Controls */}
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className={cn('h-7 w-7 p-0 shrink-0', showGamepad && 'text-primary')}
onClick={toggleGamepad}
title={showGamepad ? 'Hide gamepad' : 'Show gamepad'}
>
<Gamepad2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={handleClose}
title="Close"
>
<X className="size-3.5" />
</Button>
</div>
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('size-7', showGamepad && 'text-primary')}
onClick={toggleGamepad}
>
<Gamepad2 className="size-3.5" />
<span className="sr-only">
{showGamepad ? 'Hide gamepad' : 'Show gamepad'}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{showGamepad ? 'Hide gamepad' : 'Show gamepad'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleReload}
>
<RotateCcw className="size-3.5" />
<span className="sr-only">Reload</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Reload</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
<span className="sr-only">
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={handleClose}
>
<X className="size-3.5" />
<span className="sr-only">Close</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
{/* Iframe area */}
<div className="flex-1 min-h-0 bg-white relative">
<div className={cn("bg-white", isFullscreen ? 'flex-1 relative' : 'relative')}>
<WebxdcIframe
key={iframeKey}
ref={webxdcHandleRef}
id={iframeId}
url={url}
uuid={uuid}
isFullscreen={isFullscreen}
/>
</div>
{/* Game controls overlay */}
{showGamepad && (
<div className="border-t border-border bg-background/80 backdrop-blur-sm">
<div className={cn(
'border-t border-border bg-background/80 backdrop-blur-sm',
isFullscreen ? '' : 'rounded-b-2xl',
)}>
<GameControls webxdcHandle={webxdcHandleRef.current} />
</div>
)}
</div>,
document.body,
</div>
);
// Portal fullscreen out of any parent stacking context (e.g. z-0 center column)
// so it reliably sits above the mobile top bar and other fixed UI.
return isFullscreen ? createPortal(content, document.body) : content;
}
/**
@@ -196,7 +240,8 @@ const WebxdcIframe = forwardRef<WebxdcHandle, {
id: string;
url: string;
uuid?: string;
}>(function WebxdcIframe({ id, url, uuid }, ref) {
isFullscreen: boolean;
}>(function WebxdcIframe({ id, url, uuid, isFullscreen }, ref) {
const webxdc = useWebxdc(uuid ?? '');
return (
@@ -206,7 +251,8 @@ const WebxdcIframe = forwardRef<WebxdcHandle, {
xdc={url}
webxdc={webxdc}
allow="autoplay; fullscreen; gamepad"
className="w-full h-full border-0"
className="w-full border-0"
style={{ height: isFullscreen ? '100%' : '400px' }}
/>
);
});
+5
View File
@@ -1,6 +1,7 @@
// 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';
@@ -58,6 +59,10 @@ 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,18 +241,6 @@ 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
@@ -1,14 +0,0 @@
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
@@ -1,8 +0,0 @@
/**
* 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';
+26 -11
View File
@@ -4,24 +4,39 @@ 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 (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.
* 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.
*/
export async function downloadTextFile(filename: string, content: string): Promise<void> {
if (Capacitor.isNativePlatform()) {
const { Filesystem, Directory, Encoding } = await import('@capacitor/filesystem');
const platform = Capacitor.getPlatform();
// 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.
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.
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.Documents,
encoding: Encoding.UTF8,
directory: Directory.Cache,
});
// 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
@@ -1,10 +0,0 @@
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
@@ -1,119 +0,0 @@
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,16 +245,6 @@ 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
@@ -1,44 +0,0 @@
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 });
},
};
+1 -8
View File
@@ -15,7 +15,7 @@ import {
Earth,
Film,
HelpCircle,
Mail,
MessageSquare,
MessageSquareMore,
Mic,
@@ -110,13 +110,6 @@ 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,12 +22,9 @@ 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
@@ -1,38 +0,0 @@
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
@@ -1,453 +0,0 @@
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>
);
}
+8 -2
View File
@@ -2185,7 +2185,13 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
) : displayName}
</h2>
{metadata?.nip05 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
<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>
)}
{metadata?.website && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
@@ -2196,7 +2202,7 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
rel="noopener noreferrer"
className="truncate text-primary hover:underline"
>
{metadata.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
{metadata.website.replace(/^https?:\/\//, '')}
</a>
</div>
)}
-8
View File
@@ -57,14 +57,6 @@ 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,7 +8,6 @@ export default {
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@samthomson/nostr-messaging/dist/**/*.js",
],
prefix: "",
theme: {
-1
View File
@@ -176,7 +176,6 @@ export default defineConfig(({ mode }) => {
alias: {
"@": path.resolve(__dirname, "./src"),
},
dedupe: ['react', 'react-dom', 'react/jsx-runtime'],
},
};
});