Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af822e7c63 | |||
| 4ac4f32b45 | |||
| 286a0fcadc | |||
| 013584da06 | |||
| 7b63f6112c | |||
| ce61d8d1a6 | |||
| 19df70baed | |||
| 76c6846e91 | |||
| cf7384523a | |||
| ac1e82b52d | |||
| 437b8de652 | |||
| adadb6ed53 | |||
| f7c90a4a23 | |||
| 82632bb76c | |||
| 3a70d34e6d | |||
| 221d3f4aff | |||
| 6a1a462ab0 | |||
| 5ee8bc1cc0 | |||
| 64729b9804 | |||
| 954339c3b9 |
@@ -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,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
@@ -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;"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -19,7 +19,12 @@ const config: CapacitorConfig = {
|
||||
backgroundColor: '#14161f',
|
||||
contentInset: 'never',
|
||||
scheme: 'Ditto'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
@@ -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
@@ -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}"],
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+260
-97
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
"./node_modules/@samthomson/nostr-messaging/dist/**/*.js",
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
|
||||
@@ -176,6 +176,7 @@ export default defineConfig(({ mode }) => {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
dedupe: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user