diff --git a/app/e2e/navigation.spec.ts b/app/e2e/navigation.spec.ts index bd491bf..6471b2e 100644 --- a/app/e2e/navigation.spec.ts +++ b/app/e2e/navigation.spec.ts @@ -41,22 +41,69 @@ test.describe("UI navigation", () => { await expect(appPage.getByText("New Group", { exact: false })).not.toBeVisible(); }); - test("settings modal opens from sidebar", async ({ appPage }) => { + test("settings overlay opens from sidebar gear icon", async ({ appPage }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ timeout: 10_000, }); - // Click the Settings gear icon - await appPage.getByTitle("Settings").click(); + // Click the Settings gear icon (there are two — header and footer; click the first) + await appPage.getByTitle("Settings").first().click(); - // Settings modal should be visible - await expect(appPage.getByText("Settings")).toBeVisible(); + // Full-screen settings overlay should be visible with sidebar tabs + await expect(appPage.getByRole("button", { name: "Account" })).toBeVisible(); + await expect(appPage.getByRole("button", { name: "Network" })).toBeVisible(); + await expect(appPage.getByRole("button", { name: "Storage" })).toBeVisible(); + await expect(appPage.getByRole("button", { name: "Appearance" })).toBeVisible(); - // Should have Identity section - await expect(appPage.getByText("Identity")).toBeVisible(); + // Account section loads by default — has Identity and Security headings + await expect(appPage.getByRole("heading", { name: "Identity" })).toBeVisible(); + await expect(appPage.getByRole("heading", { name: "Security" })).toBeVisible(); - // Should have Security section - await expect(appPage.getByText("Security")).toBeVisible(); + // Close via the X button + await appPage.getByTitle("Close settings").click(); + await expect(appPage.getByRole("button", { name: "Account" })).not.toBeVisible(); + }); + + test("settings overlay closes on Escape key", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + await appPage.getByTitle("Settings").first().click(); + await expect(appPage.getByRole("button", { name: "Account" })).toBeVisible(); + + // Press Escape to close + await appPage.keyboard.press("Escape"); + await expect(appPage.getByRole("button", { name: "Account" })).not.toBeVisible(); + }); + + test("settings overlay navigates between sections", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + await appPage.getByTitle("Settings").first().click(); + + // Default: Account section + await expect(appPage.getByRole("heading", { name: "Identity" })).toBeVisible(); + + // Navigate to Network + await appPage.getByRole("button", { name: "Network" }).click(); + await expect(appPage.getByRole("heading", { name: "Relay Servers" })).toBeVisible(); + + // Navigate to Storage + await appPage.getByRole("button", { name: "Storage" }).click(); + await expect(appPage.getByRole("heading", { name: "Message Retention" })).toBeVisible(); + + // Navigate to Appearance + await appPage.getByRole("button", { name: "Appearance" }).click(); + await expect(appPage.getByRole("heading", { name: "Theme" })).toBeVisible(); + + // Navigate back to Account + await appPage.getByRole("button", { name: "Account" }).click(); + await expect(appPage.getByRole("heading", { name: "Identity" })).toBeVisible(); + + await appPage.getByTitle("Close settings").click(); }); test("create a group via UI and see it in the sidebar", async ({ appPage, apiPort }) => { diff --git a/app/e2e/onboarding.spec.ts b/app/e2e/onboarding.spec.ts index 3b679b6..27be166 100644 --- a/app/e2e/onboarding.spec.ts +++ b/app/e2e/onboarding.spec.ts @@ -37,8 +37,9 @@ test.describe("Onboarding flow", () => { await expect(freshPage.getByText("abandon").first()).toBeVisible(); await expect(freshPage.getByText("about")).toBeVisible(); - // Check the confirmation checkbox - await freshPage.getByRole("checkbox").check(); + // Check the confirmation checkbox (click the label — custom Checkbox hides + // the native input behind sr-only, so Playwright's .check() can't reach it) + await freshPage.getByText("I have written down").click(); // Click Continue await freshPage.getByRole("button", { name: "Continue" }).click(); @@ -88,7 +89,7 @@ test.describe("Onboarding flow", () => { await freshPage.getByRole("button", { name: "Continue without passphrase" }).click(); await freshPage.getByRole("button", { name: "Generate my identity" }).click(); await expect(freshPage.getByText("Your Recovery Phrase")).toBeVisible({ timeout: 10_000 }); - await freshPage.getByRole("checkbox").check(); + await freshPage.getByText("I have written down").click(); await freshPage.getByRole("button", { name: "Continue" }).click(); await expect(freshPage.getByText("Identity Ready")).toBeVisible(); await freshPage.getByRole("button", { name: "Start Variance" }).click(); diff --git a/app/e2e/ui-interactions.spec.ts b/app/e2e/ui-interactions.spec.ts index 9ca3171..a617cdf 100644 --- a/app/e2e/ui-interactions.spec.ts +++ b/app/e2e/ui-interactions.spec.ts @@ -137,20 +137,17 @@ test.describe("Send group message via UI", () => { }); }); -test.describe("Settings modal interactions", () => { +test.describe("Settings overlay interactions", () => { test("displays identity DID and copy button works", async ({ appPage }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ timeout: 10_000, }); - // Open settings - await appPage.getByTitle("Settings").click(); - await expect(appPage.getByText("Settings")).toBeVisible(); + // Open settings — Account section loads by default + await appPage.getByTitle("Settings").first().click(); + await expect(appPage.getByRole("heading", { name: "Identity" })).toBeVisible(); - // Identity section should show - await expect(appPage.getByText("Identity")).toBeVisible(); - - // DID should be visible in the modal + // DID should be visible await expect(appPage.getByText("did:variance:").first()).toBeVisible(); // Copy button should be present — it says "Copy DID" (no username set yet) @@ -163,9 +160,11 @@ test.describe("Settings modal interactions", () => { // After clicking, the button text should change to "Copied!" await expect(appPage.getByText("Copied!")).toBeVisible({ timeout: 2_000 }); + + await appPage.getByTitle("Close settings").click(); }); - test("retention dropdown changes value", async ({ appPage, apiPort }) => { + test("retention dropdown changes value via custom Select", async ({ appPage, apiPort }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ timeout: 10_000, }); @@ -174,16 +173,25 @@ test.describe("Settings modal interactions", () => { const retRes = await fetch(`http://127.0.0.1:${apiPort}/config/retention`); const original = (await retRes.json()) as { group_message_max_age_days: number }; - // Open settings - await appPage.getByTitle("Settings").click(); - await expect(appPage.getByText("Message History")).toBeVisible(); + // Open settings and navigate to Storage section + await appPage.getByTitle("Settings").first().click(); + await appPage.getByRole("button", { name: "Storage" }).click(); + await expect(appPage.getByText("Message Retention")).toBeVisible(); + + // The custom Select has role="combobox" — click to open the dropdown + const selectTrigger = appPage.getByRole("combobox"); + await expect(selectTrigger).toBeVisible(); + await selectTrigger.click(); - // Find the retention select - const retentionSelect = appPage.locator("#retention-select"); - await expect(retentionSelect).toBeVisible(); + // The dropdown portal should appear with role="listbox" + const listbox = appPage.getByRole("listbox"); + await expect(listbox).toBeVisible(); - // Change to 14 days - await retentionSelect.selectOption("14"); + // Click "14 days" option + await listbox.getByRole("option", { name: "14 days" }).click(); + + // Wait for the API call to complete + await appPage.waitForTimeout(500); // Verify the backend was updated const verifyRes = await fetch(`http://127.0.0.1:${apiPort}/config/retention`); @@ -191,29 +199,47 @@ test.describe("Settings modal interactions", () => { expect(updated.group_message_max_age_days).toBe(14); // Restore original value - await retentionSelect.selectOption(String(original.group_message_max_age_days)); + await selectTrigger.click(); + const listbox2 = appPage.getByRole("listbox"); + await expect(listbox2).toBeVisible(); + + // Find the matching option text for the original value + const originalLabel = + original.group_message_max_age_days === 0 + ? "Keep forever" + : original.group_message_max_age_days === 90 + ? "90 days" + : original.group_message_max_age_days === 14 + ? "14 days" + : "30 days (default)"; + await listbox2.getByRole("option", { name: originalLabel }).click(); + + await appPage.getByTitle("Close settings").click(); }); - test("relay CRUD through the UI", async ({ appPage, apiPort }) => { + test("relay CRUD through the settings UI", async ({ appPage, apiPort }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ timeout: 10_000, }); - // Open settings - await appPage.getByTitle("Settings").click(); + // Open settings and navigate to Network section + await appPage.getByTitle("Settings").first().click(); + await appPage.getByRole("button", { name: "Network" }).click(); await expect(appPage.getByRole("heading", { name: "Relay Servers" })).toBeVisible(); - // Initially no relays configured message should show (or existing relays from prior tests) // Fill in relay form const peerIdInput = appPage.getByPlaceholder("Peer ID"); const multiaddrInput = appPage.getByPlaceholder("Multiaddr", { exact: false }); await peerIdInput.fill("12D3KooWTestUIRelay1234567890123456789012345678"); await multiaddrInput.fill("/ip4/10.0.0.1/tcp/4001"); - // Click "Add to list" - const addBtn = appPage.getByRole("button", { name: "Add to list" }); + // Click "Add relay" — this auto-saves (no separate Save button) + const addBtn = appPage.getByRole("button", { name: "Add relay" }); await addBtn.click(); + // Wait for the API call to complete + await appPage.waitForTimeout(500); + // The relay should appear in the list await expect( appPage.getByText("12D3KooWTestUIRelay1234567890123456789012345678").first() @@ -224,11 +250,6 @@ test.describe("Settings modal interactions", () => { await expect(peerIdInput).toHaveValue(""); await expect(multiaddrInput).toHaveValue(""); - // Click Save to persist - const saveBtn = appPage.getByRole("button", { name: "Save" }); - await expect(saveBtn).toBeEnabled(); - await saveBtn.click(); - // Verify relay was saved on the backend const relayRes = await fetch(`http://127.0.0.1:${apiPort}/config/relays`); const relays = (await relayRes.json()) as { peer_id: string }[]; @@ -238,12 +259,11 @@ test.describe("Settings modal interactions", () => { expect(found).toBeTruthy(); // Remove the relay via the UI — click the remove button next to it - const removeBtn = appPage.getByTitle("Remove").first(); + const removeBtn = appPage.getByTitle("Remove relay").first(); await removeBtn.click(); - // Save the removal - await expect(saveBtn).toBeEnabled(); - await saveBtn.click(); + // Wait for the API call to complete + await appPage.waitForTimeout(500); // Verify relay was removed on the backend const verifyRes = await fetch(`http://127.0.0.1:${apiPort}/config/relays`); @@ -252,17 +272,21 @@ test.describe("Settings modal interactions", () => { (r) => r.peer_id === "12D3KooWTestUIRelay1234567890123456789012345678" ); expect(stillFound).toBeFalsy(); + + await appPage.getByTitle("Close settings").click(); }); - test("Add to list button disabled when inputs empty", async ({ appPage }) => { + test("Add relay button disabled when inputs empty", async ({ appPage }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ timeout: 10_000, }); - await appPage.getByTitle("Settings").click(); + // Open settings and navigate to Network section + await appPage.getByTitle("Settings").first().click(); + await appPage.getByRole("button", { name: "Network" }).click(); await expect(appPage.getByRole("heading", { name: "Relay Servers" })).toBeVisible(); - const addBtn = appPage.getByRole("button", { name: "Add to list" }); + const addBtn = appPage.getByRole("button", { name: "Add relay" }); await expect(addBtn).toBeDisabled(); // Fill only peer ID — still disabled @@ -272,6 +296,64 @@ test.describe("Settings modal interactions", () => { // Fill multiaddr too — now enabled await appPage.getByPlaceholder("Multiaddr", { exact: false }).fill("/ip4/1.2.3.4/tcp/4001"); await expect(addBtn).toBeEnabled(); + + await appPage.getByTitle("Close settings").click(); + }); + + test("restore defaults shows confirmation dialog and removes all relays", async ({ + appPage, + apiPort, + }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + // First, add a relay via the API so we have something to restore + await fetch(`http://127.0.0.1:${apiPort}/config/relays`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + peer_id: "12D3KooWRestoreTest12345678901234567890123456", + multiaddr: "/ip4/10.0.0.99/tcp/4001", + }), + }); + + // Open settings and navigate to Network section + await appPage.getByTitle("Settings").first().click(); + await appPage.getByRole("button", { name: "Network" }).click(); + await expect(appPage.getByRole("heading", { name: "Relay Servers" })).toBeVisible(); + + // Wait for relay list to load + await expect( + appPage.getByText("12D3KooWRestoreTest12345678901234567890123456").first() + ).toBeVisible({ timeout: 5_000 }); + + // "Restore defaults" button should be visible when relays exist + const restoreBtn = appPage.getByRole("button", { name: "Restore defaults" }); + await expect(restoreBtn).toBeVisible(); + await restoreBtn.click(); + + // ConfirmDialog should appear + await expect(appPage.getByRole("heading", { name: "Restore Defaults" })).toBeVisible(); + await expect(appPage.getByText("This will remove all configured relay servers")).toBeVisible(); + + // Click "Remove all" to confirm + await appPage.getByRole("button", { name: "Remove all" }).click(); + + // Wait for the operation to complete + await appPage.waitForTimeout(500); + + // Relay should be gone + await expect( + appPage.getByText("12D3KooWRestoreTest12345678901234567890123456") + ).not.toBeVisible({ timeout: 3_000 }); + + // Verify on backend + const verifyRes = await fetch(`http://127.0.0.1:${apiPort}/config/relays`); + const afterRestore = (await verifyRes.json()) as { peer_id: string }[]; + expect(afterRestore.length).toBe(0); + + await appPage.getByTitle("Close settings").click(); }); }); @@ -327,6 +409,107 @@ test.describe("Theme switching", () => { }); }); +test.describe("Quick-action popover", () => { + test("popover opens from avatar click and shows copy DID action", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + // The avatar/username area in the footer should be clickable. + // It doesn't have a title, so we locate it by the avatar or "No username" text. + // The footer contains the avatar button. Since the e2e identity has no username set, + // it shows "No username". + const avatarBtn = appPage.locator("button").filter({ hasText: /No username/ }); + // If a prior test set a username, fall back to the avatar area + const hasNoUsername = (await avatarBtn.count()) > 0; + + if (hasNoUsername) { + await avatarBtn.click(); + } else { + // Username is set — the footer shows the display name next to the avatar + // Click the first button in the footer that contains an avatar (img or svg) + const footerAvatarBtn = appPage.locator(".border-t button").first(); + await footerAvatarBtn.click(); + } + + // The popover should appear with "Copy DID" + await expect(appPage.getByRole("button", { name: "Copy DID" })).toBeVisible({ timeout: 2_000 }); + + // Click "Copy DID" + await appPage.getByRole("button", { name: "Copy DID" }).click(); + + // Should show "Copied!" feedback + await expect(appPage.getByText("Copied!")).toBeVisible({ timeout: 2_000 }); + }); + + test("popover closes on Escape", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + // Open the popover + const footerBtn = appPage.locator(".border-t button").first(); + await footerBtn.click(); + await expect(appPage.getByRole("button", { name: "Copy DID" })).toBeVisible({ timeout: 2_000 }); + + // Press Escape to close + await appPage.keyboard.press("Escape"); + await expect(appPage.getByRole("button", { name: "Copy DID" })).not.toBeVisible(); + }); + + test("popover closes on outside click", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + // Open the popover + const footerBtn = appPage.locator(".border-t button").first(); + await footerBtn.click(); + await expect(appPage.getByRole("button", { name: "Copy DID" })).toBeVisible({ timeout: 2_000 }); + + // Click somewhere outside (the main content area) + await appPage.locator("main").click(); + await expect(appPage.getByRole("button", { name: "Copy DID" })).not.toBeVisible(); + }); +}); + +test.describe("Appearance section theme cards", () => { + test("theme cards switch theme from Appearance settings section", async ({ appPage }) => { + await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ + timeout: 10_000, + }); + + const html = appPage.locator("html"); + + // Open settings and navigate to Appearance + await appPage.getByTitle("Settings").first().click(); + await appPage.getByRole("button", { name: "Appearance" }).click(); + await expect(appPage.getByRole("heading", { name: "Theme" })).toBeVisible(); + + // The three theme cards should be visible (Light, System, Dark) + await expect(appPage.getByText("Light").first()).toBeVisible(); + await expect(appPage.getByText("System").first()).toBeVisible(); + await expect(appPage.getByText("Dark").first()).toBeVisible(); + + // Click the "Dark" card + await appPage.getByText("Always use dark theme").click(); + await expect(html).toHaveAttribute("data-theme", "dark", { timeout: 2_000 }); + + // Should show "Currently using dark theme" + await expect(appPage.getByText("Currently using dark theme")).toBeVisible(); + + // Click the "Light" card + await appPage.getByText("Always use light theme").click(); + await expect(html).not.toHaveAttribute("data-theme", "dark", { timeout: 2_000 }); + await expect(appPage.getByText("Currently using light theme")).toBeVisible(); + + // Restore to system + await appPage.getByText("Follow your OS setting").click(); + + await appPage.getByTitle("Close settings").click(); + }); +}); + test.describe("Group view member sidebar", () => { test("toggle member sidebar open and closed", async ({ appPage, apiPort }) => { await expect(appPage.getByText("Select a conversation", { exact: false })).toBeVisible({ diff --git a/app/scripts/dev-two-instances.sh b/app/scripts/dev-two-instances.sh index 9523425..f5de0a9 100755 --- a/app/scripts/dev-two-instances.sh +++ b/app/scripts/dev-two-instances.sh @@ -92,10 +92,7 @@ stun_servers = ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] turn_servers = [] [storage] -base_dir = "$DIR" -identity_path = "$DIR/identity.json" -identity_cache_dir = "$DIR/identity_cache" -message_db_path = "$DIR/messages.db" +group_message_max_age_days = 30 TOML echo " ✓ Wrote relay config → $DIR/config.toml" fi diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index c41a993..e40d14c 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -1,5 +1,8 @@ use tauri::State; -use variance_app::{identity_gen, start_node as node_start, AppConfig, AppState, StorageConfig}; +use variance_app::{ + config::variance_data_dir, identity_gen, start_node as node_start, AppConfig, AppState, + StorageConfig, +}; use crate::state::NodeState; @@ -107,39 +110,13 @@ pub async fn recover_identity( Ok(identity.did) } -/// Resolve the base data directory for this instance. -/// -/// Reads `VARIANCE_DATA_DIR` first so a second instance can be run with a -/// different identity by setting that variable before launching the binary: -/// -/// VARIANCE_DATA_DIR=/tmp/peer-b ./variance-app -/// -/// Falls back to the platform default. In debug builds, uses `variance-dev` -/// to keep dev data separate from the installed release app: -/// - Release: `~/Library/Application Support/variance` (macOS) -/// - Debug: `~/Library/Application Support/variance-dev` (macOS) -fn data_dir() -> std::path::PathBuf { - if let Ok(dir) = std::env::var("VARIANCE_DATA_DIR") { - std::path::PathBuf::from(dir) - } else { - let dir_name = if cfg!(debug_assertions) { - "variance-dev" - } else { - "variance" - }; - dirs::data_local_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(dir_name) - } -} - /// Return the default identity file path for this instance. /// /// Respects `VARIANCE_DATA_DIR` so multiple instances can each have their own /// identity without conflicting. #[tauri::command] pub fn default_identity_path() -> String { - data_dir() + variance_data_dir() .join("identity.json") .to_string_lossy() .into_owned() @@ -172,38 +149,29 @@ pub async fn start_node( return Ok(port); } - let base_dir = data_dir(); + let base_dir = variance_data_dir(); // Ensure the data directory exists before sled or the identity loader touch it. std::fs::create_dir_all(&base_dir) .map_err(|e| format!("Failed to create data directory: {}", e))?; // Load config from {data_dir}/config.toml, creating it with defaults if absent. - // Storage paths are always derived from base_dir at runtime and override whatever - // is in the file, so the file only needs to carry user-editable settings (relay - // peers, bootstrap peers, etc.). + // Storage paths are always derived from base_dir at runtime (never serialized + // to the file), so the file only carries user-editable settings (relay peers, + // bootstrap peers, retention, etc.). let config_path = base_dir.join("config.toml"); - let mut config = if config_path.exists() { - AppConfig::from_file(config_path.to_str().unwrap_or_default()) + let config = if config_path.exists() { + AppConfig::from_file(config_path.to_str().unwrap_or_default(), base_dir) .map_err(|e| format!("Failed to load config.toml: {}", e))? } else { - let default_cfg = AppConfig::default(); + let mut default_cfg = AppConfig::default(); + default_cfg.storage = StorageConfig::for_base_dir(base_dir); if let Err(e) = default_cfg.to_file(config_path.to_str().unwrap_or_default()) { tracing::warn!("Failed to write default config.toml: {}", e); } default_cfg }; - // Always derive storage paths from the runtime base_dir so multiple instances - // (each with their own VARIANCE_DATA_DIR) get correct, non-overlapping paths. - config.storage = StorageConfig { - identity_path: base_dir.join("identity.json"), - identity_cache_dir: base_dir.join("identity_cache"), - message_db_path: base_dir.join("messages.db"), - group_message_max_age_days: config.storage.group_message_max_age_days, - base_dir, - }; - let identity_file_path = std::path::Path::new(&identity_path); // Verify the identity file exists before handing off to node startup, diff --git a/app/src/App.tsx b/app/src/App.tsx index 1c174dc..2137de3 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -6,6 +6,7 @@ import { UnlockScreen } from "./components/onboarding/UnlockScreen"; import { ConversationList } from "./components/conversations/ConversationList"; import { MessageView } from "./components/messages/MessageView"; import { GroupView } from "./components/messages/GroupView"; +import { SettingsPage } from "./components/settings/SettingsPage"; import { Toaster } from "./components/ui/Toaster"; import { useWebSocket } from "./hooks/useWebSocket"; import { usePresencePolling } from "./hooks/usePresencePolling"; @@ -167,6 +168,7 @@ export function App() { const nodeStatus = useAppStore((s) => s.nodeStatus); const setNodeStatus = useAppStore((s) => s.setNodeStatus); const setApiPort = useAppStore((s) => s.setApiPort); + const showSettings = useAppStore((s) => s.showSettings); // Apply theme (incl. system dark mode) before any child screen renders. useTheme(); @@ -272,6 +274,7 @@ export function App() { return ( <> + {showSettings && } ); diff --git a/app/src/components/conversations/ConversationList.tsx b/app/src/components/conversations/ConversationList.tsx index 1babe74..04c964a 100644 --- a/app/src/components/conversations/ConversationList.tsx +++ b/app/src/components/conversations/ConversationList.tsx @@ -1,12 +1,21 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, MessageSquare, Plus, Settings, Users } from "lucide-react"; +import { + ChevronDown, + Copy, + Check, + QrCode, + MessageSquare, + Plus, + Settings, + Users, +} from "lucide-react"; import { ConversationItem } from "./ConversationItem"; import { GroupConversationItem } from "./GroupConversationItem"; import { InvitationsSection } from "./InvitationsSection"; import { NewConversationModal } from "./NewConversationModal"; import { CreateGroupModal } from "./CreateGroupModal"; -import { SettingsModal } from "./SettingsModal"; +import { ShareContactModal } from "./ShareContactModal"; import { ThemeToggle } from "../ui/ThemeToggle"; import { ScrollArea } from "../ui/ScrollArea"; import { Avatar } from "../ui/Avatar"; @@ -14,15 +23,23 @@ import { IconButton } from "../ui/IconButton"; import { conversationsApi, groupsApi } from "../../api/client"; import { useMessagingStore } from "../../stores/messagingStore"; import { useIdentityStore } from "../../stores/identityStore"; +import { useAppStore } from "../../stores/appStore"; import { cn } from "../../utils/cn"; import type { MlsGroupInfo } from "../../api/types"; export function ConversationList({ width }: { width: number }) { const [showNew, setShowNew] = useState(false); const [showNewGroup, setShowNewGroup] = useState(false); - const [showSettings, setShowSettings] = useState(false); const [conversationsOpen, setConversationsOpen] = useState(true); + // Quick-action popover for user identity + const [showQuickActions, setShowQuickActions] = useState(false); + const [showShareQr, setShowShareQr] = useState(false); + const [copied, setCopied] = useState<"username" | "did" | null>(null); + const quickActionsRef = useRef(null); + + const openSettings = useAppStore((s) => s.openSettings); + const activeConversation = useMessagingStore((s) => s.activeConversation); const setActiveConversation = useMessagingStore((s) => s.setActiveConversation); const unreadConversations = useMessagingStore((s) => s.unreadConversations); @@ -123,7 +140,7 @@ export function ConversationList({ width }: { width: number }) { Messages
- setShowSettings(true)} title="Settings"> + openSettings()} title="Settings"> setShowNewGroup(true)} title="New group"> @@ -215,9 +232,9 @@ export function ConversationList({ width }: { width: number }) { {/* Footer */}
-
+
- {width >= 257 && } + {/* Quick-action popover */} + {showQuickActions && did && ( + { + void navigator.clipboard.writeText(displayName ?? did); + setCopied("username"); + setTimeout(() => setCopied(null), 2000); + }} + onCopyDid={() => { + void navigator.clipboard.writeText(did); + setCopied("did"); + setTimeout(() => setCopied(null), 2000); + }} + onShareQr={() => { + setShowShareQr(true); + setShowQuickActions(false); + }} + onClose={() => setShowQuickActions(false)} + containerRef={quickActionsRef} + /> + )} + +
+ {width >= 257 && } + openSettings()} title="Settings"> + + +
@@ -253,7 +299,91 @@ export function ConversationList({ width }: { width: number }) { }} /> - setShowSettings(false)} /> + {did && ( + setShowShareQr(false)} + did={did} + displayName={displayName} + /> + )} +
+ ); +} + +/** Popover with quick actions: copy username, copy DID, share QR */ +function QuickActionPopover({ + displayName, + copied, + onCopyUsername, + onCopyDid, + onShareQr, + onClose, + containerRef, +}: { + displayName: string | null; + copied: "username" | "did" | null; + onCopyUsername: () => void; + onCopyDid: () => void; + onShareQr: () => void; + onClose: () => void; + containerRef: React.RefObject; +}) { + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose, containerRef]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+
+ {displayName && ( + + )} + + +
); } diff --git a/app/src/components/conversations/SettingsModal.tsx b/app/src/components/conversations/SettingsModal.tsx deleted file mode 100644 index 301c8eb..0000000 --- a/app/src/components/conversations/SettingsModal.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { useState, useEffect } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AtSign, Copy, Check, QrCode, Trash2, Lock } from "lucide-react"; -import { invoke } from "@tauri-apps/api/core"; -import { Dialog } from "../ui/Dialog"; -import { Button } from "../ui/Button"; -import { IconButton } from "../ui/IconButton"; -import { Avatar } from "../ui/Avatar"; -import { Input } from "../ui/Input"; -import { ChangeUsernameDialog } from "./ChangeUsernameDialog"; -import { ShareContactModal } from "./ShareContactModal"; -import { configApi } from "../../api/client"; -import { useIdentityStore } from "../../stores/identityStore"; -import { useAppStore } from "../../stores/appStore"; -import { useToastStore } from "../../stores/toastStore"; -import type { RelayPeer } from "../../api/types"; - -interface SettingsModalProps { - open: boolean; - onClose: () => void; -} - -export function SettingsModal({ open, onClose }: SettingsModalProps) { - const did = useIdentityStore((s) => s.did); - const displayName = useIdentityStore((s) => s.displayName); - - const [copied, setCopied] = useState(false); - const [showUsernameDialog, setShowUsernameDialog] = useState(false); - const [showShareQr, setShowShareQr] = useState(false); - - // Relay form state - const [relayPeerId, setRelayPeerId] = useState(""); - const [relayMultiaddr, setRelayMultiaddr] = useState(""); - // null = no pending edits (show saved); non-null = local draft - const [pendingRelays, setPendingRelays] = useState(null); - const [saving, setSaving] = useState(false); - - const setNodeStatus = useAppStore((s) => s.setNodeStatus); - const queryClient = useQueryClient(); - const addToast = useToastStore((s) => s.addToast); - - // Passphrase change state - const [showPassphraseSection, setShowPassphraseSection] = useState(false); - const [currentPassphrase, setCurrentPassphrase] = useState(""); - const [newPassphrase, setNewPassphrase] = useState(""); - const [confirmPassphrase, setConfirmPassphrase] = useState(""); - const [changingPassphrase, setChangingPassphrase] = useState(false); - - const { data: savedRelays = [] } = useQuery({ - queryKey: ["relays"], - queryFn: configApi.getRelays, - enabled: open, - }); - - const { data: retention } = useQuery({ - queryKey: ["retention"], - queryFn: configApi.getRetention, - enabled: open, - }); - - // Reset draft when modal closes - useEffect(() => { - if (!open) { - setPendingRelays(null); - setRelayPeerId(""); - setRelayMultiaddr(""); - setShowPassphraseSection(false); - setCurrentPassphrase(""); - setNewPassphrase(""); - setConfirmPassphrase(""); - } - }, [open]); - - async function handleChangePassphrase() { - if (newPassphrase !== confirmPassphrase) { - addToast("New passphrases do not match", "error"); - return; - } - setChangingPassphrase(true); - try { - await invoke("change_passphrase", { - currentPassphrase: currentPassphrase || null, - newPassphrase: newPassphrase || null, - }); - addToast("Passphrase changed. Please restart the app.", "success"); - setNodeStatus("idle"); - onClose(); - } catch (e) { - addToast(String(e), "error"); - } finally { - setChangingPassphrase(false); - } - } - - const relays = pendingRelays ?? savedRelays; - const isDirty = pendingRelays !== null; - - function handleAddToList() { - if (!relayPeerId || !relayMultiaddr) return; - setPendingRelays([...relays, { peer_id: relayPeerId, multiaddr: relayMultiaddr }]); - setRelayPeerId(""); - setRelayMultiaddr(""); - } - - function handleRemoveRelay(peerId: string) { - setPendingRelays(relays.filter((r) => r.peer_id !== peerId)); - } - - function handleRestoreDefaults() { - setPendingRelays([]); - } - - async function handleRetentionChange(days: number) { - try { - await configApi.setRetention({ group_message_max_age_days: days }); - await queryClient.invalidateQueries({ queryKey: ["retention"] }); - } catch (e) { - addToast(String(e), "error"); - } - } - - async function handleSave() { - if (!isDirty) return; - setSaving(true); - try { - for (const r of savedRelays) { - await configApi.removeRelay(r.peer_id); - } - for (const r of pendingRelays!) { - await configApi.addRelay(r); - } - await queryClient.invalidateQueries({ queryKey: ["relays"] }); - setPendingRelays(null); - } catch (e) { - addToast(String(e), "error"); - } finally { - setSaving(false); - } - } - - return ( - <> - - {did && ( -
- {/* Identity */} -
-

- Identity -

- -
- -
- {displayName &&

{displayName}

} -

- {did} -

-
-
- -
- - - - - -
-
- - {/* Security */} -
-

- Security -

- {!showPassphraseSection ? ( - - ) : ( -
- setCurrentPassphrase(e.target.value)} - /> - setNewPassphrase(e.target.value)} - /> - setConfirmPassphrase(e.target.value)} - /> -

- The app will restart after changing the passphrase. -

-
- - -
-
- )} -
- - {/* Relay Servers */} -
-

- Relay Servers -

- - {relays.length === 0 ? ( -

No relay servers configured.

- ) : ( -
    - {relays.map((relay) => ( -
  • -
    -

    - {relay.peer_id} -

    -

    - {relay.multiaddr} -

    -
    - handleRemoveRelay(relay.peer_id)} - className="shrink-0 mt-0.5 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" - title="Remove" - > - - -
  • - ))} -
- )} - -
- setRelayPeerId(e.target.value)} - placeholder="Peer ID" - className="rounded-md px-2.5 py-1.5 text-xs" - /> - setRelayMultiaddr(e.target.value)} - placeholder="Multiaddr (e.g. /ip4/1.2.3.4/tcp/4001)" - className="rounded-md px-2.5 py-1.5 text-xs" - /> - -
- -

- Changes take effect after restarting the app. -

- -
- - -
-
- - {/* Message History */} -
-

- Message History -

- -
- - -
- -

- Applies to both direct and group messages locally stored on this device. -

-
-
- )} -
- - setShowUsernameDialog(false)} - /> - - {did && ( - setShowShareQr(false)} - did={did} - displayName={displayName} - /> - )} - - ); -} diff --git a/app/src/components/onboarding/MnemonicDisplay.tsx b/app/src/components/onboarding/MnemonicDisplay.tsx index ffeaa1e..df5144f 100644 --- a/app/src/components/onboarding/MnemonicDisplay.tsx +++ b/app/src/components/onboarding/MnemonicDisplay.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { cn } from "../../utils/cn"; +import { Checkbox } from "../ui/Checkbox"; interface MnemonicDisplayProps { words: string[]; @@ -39,17 +40,11 @@ export function MnemonicDisplay({ words, onConfirmed }: MnemonicDisplayProps) { ))}
- + setConfirmed(e.target.checked)} + label="I have written down these 12 words and stored them safely." + /> + + + + + + + + {/* Divider */} +
+ + {/* Security */} +
+

Security

+ + {!showPassphraseSection ? ( + + ) : ( +
+ setCurrentPassphrase(e.target.value)} + /> + setNewPassphrase(e.target.value)} + /> + setConfirmPassphrase(e.target.value)} + /> +

+ The app will restart after changing the passphrase. +

+
+ + +
+
+ )} +
+ + + setShowUsernameDialog(false)} + /> + + {did && ( + setShowShareQr(false)} + did={did} + displayName={displayName} + /> + )} + + ); +} diff --git a/app/src/components/settings/AppearanceSection.tsx b/app/src/components/settings/AppearanceSection.tsx new file mode 100644 index 0000000..979b5fa --- /dev/null +++ b/app/src/components/settings/AppearanceSection.tsx @@ -0,0 +1,71 @@ +import { Moon, Sun, Monitor } from "lucide-react"; +import { useTheme, type Theme } from "../../hooks/useTheme"; +import { cn } from "../../utils/cn"; + +const options: { value: Theme; icon: React.ReactNode; label: string; description: string }[] = [ + { + value: "light", + icon: , + label: "Light", + description: "Always use light theme", + }, + { + value: "system", + icon: , + label: "System", + description: "Follow your OS setting", + }, + { + value: "dark", + icon: , + label: "Dark", + description: "Always use dark theme", + }, +]; + +export function AppearanceSection() { + const { theme, resolvedTheme, setTheme } = useTheme(); + + return ( +
+
+

Appearance

+

+ Customize how Variance looks on your device. +

+
+ + {/* Theme */} +
+

Theme

+ +
+ {options.map((opt) => ( + + ))} +
+ +

+ Currently using{" "} + + {resolvedTheme} + {" "} + theme. +

+
+
+ ); +} diff --git a/app/src/components/settings/NetworkSection.tsx b/app/src/components/settings/NetworkSection.tsx new file mode 100644 index 0000000..e38c6f7 --- /dev/null +++ b/app/src/components/settings/NetworkSection.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { Trash2, RotateCcw } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "../ui/Button"; +import { IconButton } from "../ui/IconButton"; +import { Input } from "../ui/Input"; +import { ConfirmDialog } from "../ui/ConfirmDialog"; +import { configApi } from "../../api/client"; +import { useToastStore } from "../../stores/toastStore"; + +export function NetworkSection() { + const queryClient = useQueryClient(); + const addToast = useToastStore((s) => s.addToast); + + const [relayPeerId, setRelayPeerId] = useState(""); + const [relayMultiaddr, setRelayMultiaddr] = useState(""); + const [addingRelay, setAddingRelay] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); + + const { data: relays = [] } = useQuery({ + queryKey: ["relays"], + queryFn: configApi.getRelays, + }); + + async function handleAddRelay() { + if (!relayPeerId || !relayMultiaddr) return; + setAddingRelay(true); + try { + await configApi.addRelay({ peer_id: relayPeerId, multiaddr: relayMultiaddr }); + await queryClient.invalidateQueries({ queryKey: ["relays"] }); + setRelayPeerId(""); + setRelayMultiaddr(""); + } catch (e) { + addToast(String(e), "error"); + } finally { + setAddingRelay(false); + } + } + + async function handleRemoveRelay(peerId: string) { + try { + await configApi.removeRelay(peerId); + await queryClient.invalidateQueries({ queryKey: ["relays"] }); + } catch (e) { + addToast(String(e), "error"); + } + } + + async function handleRestoreDefaults() { + setResetting(true); + try { + for (const r of relays) { + await configApi.removeRelay(r.peer_id); + } + await queryClient.invalidateQueries({ queryKey: ["relays"] }); + setShowResetConfirm(false); + } catch (e) { + addToast(String(e), "error"); + } finally { + setResetting(false); + } + } + + return ( + <> +
+
+
+

Network

+

+ Configure relay servers for offline message delivery. +

+
+ {relays.length > 0 && ( + + )} +
+ + {/* Relay Servers */} +
+

+ Relay Servers +

+ + {relays.length === 0 ? ( +

No relay servers configured.

+ ) : ( +
    + {relays.map((relay) => ( +
  • +
    +

    + {relay.peer_id} +

    +

    {relay.multiaddr}

    +
    + void handleRemoveRelay(relay.peer_id)} + className="shrink-0 mt-0.5 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" + title="Remove relay" + > + + +
  • + ))} +
+ )} + + {/* Add form */} +
+

+ Add relay +

+ setRelayPeerId(e.target.value)} + placeholder="Peer ID" + /> + setRelayMultiaddr(e.target.value)} + placeholder="Multiaddr (e.g. /ip4/1.2.3.4/tcp/4001)" + /> + +
+ +

+ Changes take effect after restarting the app. +

+
+
+ + setShowResetConfirm(false)} + onConfirm={() => void handleRestoreDefaults()} + title="Restore Defaults" + message="This will remove all configured relay servers. You can add them back later." + confirmLabel="Remove all" + destructive + loading={resetting} + /> + + ); +} diff --git a/app/src/components/settings/SettingsPage.tsx b/app/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..81ffd11 --- /dev/null +++ b/app/src/components/settings/SettingsPage.tsx @@ -0,0 +1,102 @@ +import { User, Globe, Database, Palette, X, Keyboard } from "lucide-react"; +import { useEffect } from "react"; +import { IconButton } from "../ui/IconButton"; +import { AccountSection } from "./AccountSection"; +import { NetworkSection } from "./NetworkSection"; +import { StorageSection } from "./StorageSection"; +import { AppearanceSection } from "./AppearanceSection"; +import { useAppStore, type SettingsSection } from "../../stores/appStore"; +import { cn } from "../../utils/cn"; + +const sections: { key: SettingsSection; label: string; icon: React.ReactNode }[] = [ + { key: "account", label: "Account", icon: }, + { key: "network", label: "Network", icon: }, + { key: "storage", label: "Storage", icon: }, + { key: "appearance", label: "Appearance", icon: }, +]; + +const sectionComponents: Record = { + account: AccountSection, + network: NetworkSection, + storage: StorageSection, + appearance: AppearanceSection, +}; + +export function SettingsPage() { + const activeSection = useAppStore((s) => s.settingsSection); + const setSection = useAppStore((s) => s.setSettingsSection); + const closeSettings = useAppStore((s) => s.closeSettings); + + // Close on Escape + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closeSettings(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [closeSettings]); + + const ActiveComponent = sectionComponents[activeSection]; + + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ {/* Top bar with close button */} +
+ + + +
+ + {/* Section content */} +
+ +
+
+
+ ); +} diff --git a/app/src/components/settings/StorageSection.tsx b/app/src/components/settings/StorageSection.tsx new file mode 100644 index 0000000..b16756a --- /dev/null +++ b/app/src/components/settings/StorageSection.tsx @@ -0,0 +1,58 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Select, Option } from "../ui/Select"; +import { configApi } from "../../api/client"; +import { useToastStore } from "../../stores/toastStore"; + +export function StorageSection() { + const queryClient = useQueryClient(); + const addToast = useToastStore((s) => s.addToast); + + const { data: retention } = useQuery({ + queryKey: ["retention"], + queryFn: configApi.getRetention, + }); + + async function handleRetentionChange(days: number) { + try { + await configApi.setRetention({ group_message_max_age_days: days }); + await queryClient.invalidateQueries({ queryKey: ["retention"] }); + } catch (e) { + addToast(String(e), "error"); + } + } + + return ( +
+
+

Storage

+

+ Configure how long messages are kept on this device. +

+
+ + {/* Retention */} +
+

+ Message Retention +

+ +
+ +
+ +

+ Applies to both direct and group messages stored locally on this device. +

+
+
+ ); +} diff --git a/app/src/components/ui/Checkbox.tsx b/app/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..699a2d8 --- /dev/null +++ b/app/src/components/ui/Checkbox.tsx @@ -0,0 +1,73 @@ +import { forwardRef, useId } from "react"; +import { Check } from "lucide-react"; +import { cn } from "../../utils/cn"; + +interface CheckboxProps extends Omit, "type"> { + label?: string; + error?: string; +} + +/** + * Custom styled checkbox. Hides the native `` and renders a + * themed box with a check-mark icon. Forwards ref to the hidden input + * so form libraries still work. + * + * ```tsx + * setAgreed(e.target.checked)} + * label="I agree to the terms" + * /> + * ``` + */ +export const Checkbox = forwardRef( + ({ className, label, error, id, checked, disabled, ...props }, ref) => { + const autoId = useId(); + const checkboxId = id ?? autoId; + + return ( +
+ + {error &&

{error}

} +
+ ); + } +); + +Checkbox.displayName = "Checkbox"; diff --git a/app/src/components/ui/Select.tsx b/app/src/components/ui/Select.tsx new file mode 100644 index 0000000..905169c --- /dev/null +++ b/app/src/components/ui/Select.tsx @@ -0,0 +1,340 @@ +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { ChevronDown } from "lucide-react"; +import { cn } from "../../utils/cn"; + +/* ------------------------------------------------------------------ */ +/* Option */ +/* ------------------------------------------------------------------ */ + +interface OptionProps { + value: string | number; + children: ReactNode; + disabled?: boolean; +} + +interface OptionEntry { + value: string | number; + label: ReactNode; + disabled?: boolean; +} + +interface SelectCtx { + selected: string | number | undefined; + highlighted: number; + onSelect: (value: string | number) => void; + onHighlight: (index: number) => void; + registerOption: (entry: OptionEntry) => number; +} + +const SelectContext = createContext(null); + +/** + * Statically collect `