From bbcb934211098ac151ffef8be9a49ce3c5bd93a3 Mon Sep 17 00:00:00 2001 From: Zack Kollar Date: Fri, 20 Mar 2026 02:22:12 -0400 Subject: [PATCH 1/4] :hammer: Add custom settings view, keep shortcuts for username copy, QR and DID names --- app/e2e/navigation.spec.ts | 65 ++- app/e2e/onboarding.spec.ts | 7 +- app/e2e/ui-interactions.spec.ts | 259 ++++++++++-- app/scripts/dev-two-instances.sh | 5 +- app/src-tauri/src/commands.rs | 58 +-- app/src/App.tsx | 3 + .../conversations/ConversationList.tsx | 141 ++++++- .../conversations/SettingsModal.tsx | 373 ------------------ .../components/onboarding/MnemonicDisplay.tsx | 17 +- .../components/settings/AccountSection.tsx | 194 +++++++++ .../components/settings/AppearanceSection.tsx | 54 +++ .../components/settings/NetworkSection.tsx | 169 ++++++++ app/src/components/settings/SettingsPage.tsx | 100 +++++ .../components/settings/StorageSection.tsx | 58 +++ app/src/components/ui/Checkbox.tsx | 77 ++++ app/src/components/ui/Select.tsx | 314 +++++++++++++++ app/src/stores/appStore.ts | 12 + crates/variance-app/src/api/config.rs | 41 +- crates/variance-app/src/api/mod.rs | 12 +- crates/variance-app/src/config.rs | 268 +++++++++++-- crates/variance-app/src/node.rs | 6 +- crates/variance-app/src/state.rs | 14 +- crates/variance-app/tests/api_lifecycle.rs | 2 +- crates/variance-cli/src/main.rs | 25 +- 24 files changed, 1702 insertions(+), 572 deletions(-) delete mode 100644 app/src/components/conversations/SettingsModal.tsx create mode 100644 app/src/components/settings/AccountSection.tsx create mode 100644 app/src/components/settings/AppearanceSection.tsx create mode 100644 app/src/components/settings/NetworkSection.tsx create mode 100644 app/src/components/settings/SettingsPage.tsx create mode 100644 app/src/components/settings/StorageSection.tsx create mode 100644 app/src/components/ui/Checkbox.tsx create mode 100644 app/src/components/ui/Select.tsx 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..f5994d2 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,66 @@ 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 +411,109 @@ 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..63d2f68 100644 --- a/app/src/components/conversations/ConversationList.tsx +++ b/app/src/components/conversations/ConversationList.tsx @@ -1,12 +1,12 @@ -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 +14,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 +131,7 @@ export function ConversationList({ width }: { width: number }) { Messages
- setShowSettings(true)} title="Settings"> + openSettings()} title="Settings"> setShowNewGroup(true)} title="New group"> @@ -215,9 +223,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 +290,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..e47d875 --- /dev/null +++ b/app/src/components/settings/AppearanceSection.tsx @@ -0,0 +1,54 @@ +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..bfd7ecb --- /dev/null +++ b/app/src/components/settings/NetworkSection.tsx @@ -0,0 +1,169 @@ +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..2875cc2 --- /dev/null +++ b/app/src/components/settings/SettingsPage.tsx @@ -0,0 +1,100 @@ +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..2bfa3bf --- /dev/null +++ b/app/src/components/ui/Checkbox.tsx @@ -0,0 +1,77 @@ +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..c1a68a6 --- /dev/null +++ b/app/src/components/ui/Select.tsx @@ -0,0 +1,314 @@ +import { + createContext, + 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); + +/** + * A single option inside a ` + * + * + * + * ``` + */ +export function Option({ value, children, disabled }: OptionProps) { + const ctx = useContext(SelectContext); + const indexRef = useRef(-1); + + // Register on mount so Select knows about us. + useEffect(() => { + if (ctx) { + indexRef.current = ctx.registerOption({ value, label: children, disabled }); + } + // Only register once on mount — the parent rebuilds the list each render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!ctx) return null; + + const isSelected = ctx.selected !== undefined && String(ctx.selected) === String(value); + const isHighlighted = ctx.highlighted === indexRef.current; + + return ( +
{ + if (!disabled) ctx.onHighlight(indexRef.current); + }} + onMouseDown={(e) => { + // Prevent blur on the trigger so the menu stays manageable. + e.preventDefault(); + if (!disabled) ctx.onSelect(value); + }} + className={cn( + "flex items-center px-3 py-1.5 text-sm cursor-pointer select-none transition-colors", + isHighlighted && "bg-surface-200/60 dark:bg-surface-700/60", + isSelected && "text-primary-500 font-medium", + !isSelected && "text-surface-800 dark:text-surface-200", + disabled && "text-surface-400 dark:text-surface-500 cursor-default opacity-50" + )} + > + {children} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Select */ +/* ------------------------------------------------------------------ */ + +interface SelectProps { + value?: string | number; + onChange?: (value: string | number) => void; + children: ReactNode; + label?: string; + error?: string; + id?: string; + disabled?: boolean; + className?: string; + placeholder?: string; +} + +/** + * Custom styled dropdown that replaces native ` + * + * + * + * ``` + */ +export function Select({ + value, + onChange, + children, + label, + error, + id, + disabled, + className, + placeholder = "Select\u2026", +}: SelectProps) { + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(-1); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const optionsRef = useRef([]); + const selectId = id ?? label?.toLowerCase().replace(/\s+/g, "-"); + + // Rebuild the options list every render (children may change). + optionsRef.current = []; + let registerIndex = 0; + const registerOption = useCallback((entry: OptionEntry): number => { + const idx = registerIndex; + optionsRef.current.push(entry); + registerIndex++; + return idx; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, value, children]); + + const selectedOption = optionsRef.current.find( + (o) => o.value !== undefined && String(o.value) === String(value) + ); + + function handleSelect(v: string | number) { + onChange?.(v); + setOpen(false); + triggerRef.current?.focus(); + } + + // Close on outside click + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDown, true); + return () => document.removeEventListener("mousedown", onDown, true); + }, [open]); + + // Keyboard navigation + function handleKeyDown(e: React.KeyboardEvent) { + const opts = optionsRef.current; + if (e.key === "Escape") { + setOpen(false); + return; + } + if (!open && (e.key === "Enter" || e.key === " " || e.key === "ArrowDown")) { + e.preventDefault(); + setOpen(true); + // Highlight the currently selected option, or the first one. + const selIdx = opts.findIndex((o) => String(o.value) === String(value)); + setHighlighted(selIdx >= 0 ? selIdx : 0); + return; + } + if (!open) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + let next = highlighted + 1; + while (next < opts.length && opts[next].disabled) next++; + if (next < opts.length) setHighlighted(next); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + let prev = highlighted - 1; + while (prev >= 0 && opts[prev].disabled) prev--; + if (prev >= 0) setHighlighted(prev); + } else if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (highlighted >= 0 && highlighted < opts.length && !opts[highlighted].disabled) { + handleSelect(opts[highlighted].value); + } + } + } + + // Position the dropdown relative to the trigger + const [menuStyle, setMenuStyle] = useState({}); + useEffect(() => { + if (!open || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setMenuStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + minWidth: rect.width, + }); + }, [open]); + + return ( +
+ {label && ( + + )} + + + + {error &&

{error}

} + + {/* Render options children so they register, but visually hidden when closed */} + + {open && + createPortal( +
+ {children} +
, + document.body + )} + + {/* Hidden render so options register even when closed (for selectedOption label) */} + {!open &&
{children}
} +
+
+ ); +} diff --git a/app/src/stores/appStore.ts b/app/src/stores/appStore.ts index b15f942..c5a17a5 100644 --- a/app/src/stores/appStore.ts +++ b/app/src/stores/appStore.ts @@ -2,16 +2,23 @@ import { create } from "zustand"; export type NodeStatus = "idle" | "starting" | "running" | "stopping" | "error" | "needs-unlock"; +export type SettingsSection = "account" | "network" | "storage" | "appearance"; + interface AppStore { nodeStatus: NodeStatus; apiPort: number | null; error: string | null; wsConnected: boolean; + showSettings: boolean; + settingsSection: SettingsSection; setNodeStatus: (status: NodeStatus) => void; setApiPort: (port: number | null) => void; setError: (error: string | null) => void; setWsConnected: (connected: boolean) => void; + openSettings: (section?: SettingsSection) => void; + closeSettings: () => void; + setSettingsSection: (section: SettingsSection) => void; } export const useAppStore = create((set) => ({ @@ -19,9 +26,14 @@ export const useAppStore = create((set) => ({ apiPort: null, error: null, wsConnected: false, + showSettings: false, + settingsSection: "account", setNodeStatus: (nodeStatus) => set({ nodeStatus }), setApiPort: (apiPort) => set({ apiPort }), setError: (error) => set({ error }), setWsConnected: (wsConnected) => set({ wsConnected }), + openSettings: (section) => set({ showSettings: true, ...(section && { settingsSection: section }) }), + closeSettings: () => set({ showSettings: false }), + setSettingsSection: (settingsSection) => set({ settingsSection }), })); diff --git a/crates/variance-app/src/api/config.rs b/crates/variance-app/src/api/config.rs index 49c5438..bc27583 100644 --- a/crates/variance-app/src/api/config.rs +++ b/crates/variance-app/src/api/config.rs @@ -19,12 +19,7 @@ pub struct AddRelayRequest { } pub async fn get_relays(State(state): State) -> Result>> { - let base_dir = state - .config_path - .parent() - .unwrap_or(&state.config_path) - .to_path_buf(); - let config = crate::config::AppConfig::load_or_default(&base_dir); + let config = crate::config::AppConfig::load_or_default(&state.config_dir); Ok(Json(config.p2p.relay_peers)) } @@ -32,29 +27,19 @@ pub async fn add_relay( State(state): State, Json(body): Json, ) -> Result> { - let base_dir = state - .config_path - .parent() - .unwrap_or(&state.config_path) - .to_path_buf(); - let mut config = crate::config::AppConfig::load_or_default(&base_dir); + let mut config = crate::config::AppConfig::load_or_default(&state.config_dir); config.p2p.relay_peers.push(RelayPeerConfig { peer_id: body.peer_id, multiaddr: body.multiaddr, }); - config.save(&base_dir).map_err(|e| Error::App { + config.save(&state.config_dir).map_err(|e| Error::App { message: e.to_string(), })?; Ok(Json(serde_json::json!({ "success": true }))) } pub async fn get_retention(State(state): State) -> Result> { - let base_dir = state - .config_path - .parent() - .unwrap_or(&state.config_path) - .to_path_buf(); - let config = crate::config::AppConfig::load_or_default(&base_dir); + let config = crate::config::AppConfig::load_or_default(&state.config_dir); Ok(Json(RetentionConfig { group_message_max_age_days: config.storage.group_message_max_age_days, })) @@ -64,14 +49,9 @@ pub async fn set_retention( State(state): State, Json(body): Json, ) -> Result> { - let base_dir = state - .config_path - .parent() - .unwrap_or(&state.config_path) - .to_path_buf(); - let mut config = crate::config::AppConfig::load_or_default(&base_dir); + let mut config = crate::config::AppConfig::load_or_default(&state.config_dir); config.storage.group_message_max_age_days = body.group_message_max_age_days; - config.save(&base_dir).map_err(|e| Error::App { + config.save(&state.config_dir).map_err(|e| Error::App { message: e.to_string(), })?; Ok(Json(serde_json::json!({ "success": true }))) @@ -81,14 +61,9 @@ pub async fn remove_relay( State(state): State, Path(peer_id): Path, ) -> Result> { - let base_dir = state - .config_path - .parent() - .unwrap_or(&state.config_path) - .to_path_buf(); - let mut config = crate::config::AppConfig::load_or_default(&base_dir); + let mut config = crate::config::AppConfig::load_or_default(&state.config_dir); config.p2p.relay_peers.retain(|r| r.peer_id != peer_id); - config.save(&base_dir).map_err(|e| Error::App { + config.save(&state.config_dir).map_err(|e| Error::App { message: e.to_string(), })?; Ok(Json(serde_json::json!({ "success": true }))) diff --git a/crates/variance-app/src/api/mod.rs b/crates/variance-app/src/api/mod.rs index ec23e39..89852e3 100644 --- a/crates/variance-app/src/api/mod.rs +++ b/crates/variance-app/src/api/mod.rs @@ -1847,8 +1847,8 @@ mod tests { let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path("did:variance:test".to_string(), db_path.to_str().unwrap()); - // Point config_path to the temp dir so load_or_default creates a default config - state.config_path = dir.path().join("config.toml"); + // Point config_dir to the temp dir so load_or_default creates a default config + state.config_dir = dir.path().to_path_buf(); let app = create_router(state); let response = app @@ -1875,7 +1875,7 @@ mod tests { let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path("did:variance:test".to_string(), db_path.to_str().unwrap()); - state.config_path = dir.path().join("config.toml"); + state.config_dir = dir.path().to_path_buf(); let app = create_router(state); // Add a relay @@ -1925,7 +1925,7 @@ mod tests { let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path("did:variance:test".to_string(), db_path.to_str().unwrap()); - state.config_path = dir.path().join("config.toml"); + state.config_dir = dir.path().to_path_buf(); let app = create_router(state); // Add a relay first @@ -1982,7 +1982,7 @@ mod tests { let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path("did:variance:test".to_string(), db_path.to_str().unwrap()); - state.config_path = dir.path().join("config.toml"); + state.config_dir = dir.path().to_path_buf(); let app = create_router(state); let response = app @@ -2009,7 +2009,7 @@ mod tests { let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path("did:variance:test".to_string(), db_path.to_str().unwrap()); - state.config_path = dir.path().join("config.toml"); + state.config_dir = dir.path().to_path_buf(); let app = create_router(state); // Set retention to 90 days diff --git a/crates/variance-app/src/config.rs b/crates/variance-app/src/config.rs index 7f085d3..b16f22e 100644 --- a/crates/variance-app/src/config.rs +++ b/crates/variance-app/src/config.rs @@ -2,7 +2,13 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; -fn variance_data_dir() -> PathBuf { +/// Resolve the platform-specific data directory for this Variance instance. +/// +/// Precedence: +/// 1. `VARIANCE_DATA_DIR` environment variable (for multi-instance testing) +/// 2. Debug: `/variance-dev` +/// 3. Release: `/variance` +pub fn variance_data_dir() -> PathBuf { if let Ok(dir) = std::env::var("VARIANCE_DATA_DIR") { return PathBuf::from(dir); } @@ -16,7 +22,10 @@ fn variance_data_dir() -> PathBuf { .join(dir_name) } -/// Application configuration +/// Application configuration persisted to `config.toml`. +/// +/// Only user-editable settings belong here; storage paths are derived at +/// runtime from `StorageConfig::base_dir` and never serialized to the file. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { /// HTTP server configuration @@ -31,7 +40,12 @@ pub struct AppConfig { /// Media configuration pub media: MediaConfig, - /// Storage paths + /// Storage settings (only `group_message_max_age_days` is persisted). + /// + /// Machine-specific paths (`base_dir`, `identity_path`, etc.) are derived + /// at runtime and excluded from serialization so the config file stays + /// portable across machines and instances. + #[serde(default)] pub storage: StorageConfig, } @@ -89,16 +103,24 @@ pub struct TurnServer { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageConfig { - /// Base directory for all storage + /// Base directory for all storage. + /// Always derived at runtime; never persisted to config.toml. + #[serde(skip)] pub base_dir: PathBuf, - /// Local identity file (DID + signing keys) + /// Local identity file (DID + signing keys). + /// Always derived at runtime; never persisted to config.toml. + #[serde(skip)] pub identity_path: PathBuf, - /// Identity cache directory + /// Identity cache directory. + /// Always derived at runtime; never persisted to config.toml. + #[serde(skip)] pub identity_cache_dir: PathBuf, - /// Message database path + /// Message database path. + /// Always derived at runtime; never persisted to config.toml. + #[serde(skip)] pub message_db_path: PathBuf, /// Maximum age in days for messages (direct and group) before they are purged. @@ -114,6 +136,18 @@ fn default_group_message_max_age_days() -> u64 { DEFAULT_GROUP_MESSAGE_MAX_AGE_DAYS } +impl Default for StorageConfig { + fn default() -> Self { + Self { + base_dir: PathBuf::new(), + identity_path: PathBuf::new(), + identity_cache_dir: PathBuf::new(), + message_db_path: PathBuf::new(), + group_message_max_age_days: DEFAULT_GROUP_MESSAGE_MAX_AGE_DAYS, + } + } +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -140,36 +174,53 @@ impl Default for AppConfig { ], turn_servers: vec![], }, - storage: StorageConfig { - base_dir: variance_data_dir(), - identity_path: variance_data_dir().join("identity.json"), - identity_cache_dir: variance_data_dir().join("identity_cache"), - message_db_path: variance_data_dir().join("messages.db"), - group_message_max_age_days: DEFAULT_GROUP_MESSAGE_MAX_AGE_DAYS, // 30 days - }, + storage: StorageConfig::for_base_dir(variance_data_dir()), + } + } +} + +impl StorageConfig { + /// Derive all storage paths from a base directory. + pub fn for_base_dir(base_dir: PathBuf) -> Self { + Self { + identity_path: base_dir.join("identity.json"), + identity_cache_dir: base_dir.join("identity_cache"), + message_db_path: base_dir.join("messages.db"), + base_dir, + group_message_max_age_days: DEFAULT_GROUP_MESSAGE_MAX_AGE_DAYS, } } } impl AppConfig { - /// Load configuration from TOML file - pub fn from_file(path: &str) -> anyhow::Result { + /// Load configuration from TOML file, deriving storage paths from `base_dir`. + /// + /// Storage paths are never read from the TOML — they are always computed + /// from `base_dir` so the file stays portable. + pub fn from_file(path: &str, base_dir: PathBuf) -> anyhow::Result { let contents = fs::read_to_string(path)?; - let config: AppConfig = toml::from_str(&contents)?; + let mut config: AppConfig = toml::from_str(&contents)?; + let max_age = config.storage.group_message_max_age_days; + config.storage = StorageConfig::for_base_dir(base_dir); + config.storage.group_message_max_age_days = max_age; Ok(config) } - /// Save configuration to TOML file + /// Save configuration to TOML file. pub fn to_file(&self, path: &str) -> anyhow::Result<()> { let contents = toml::to_string_pretty(self)?; fs::write(path, contents)?; Ok(()) } - /// Try to load `base_dir/config.toml`; fall back to [`AppConfig::default()`] if absent or unparseable. + /// Try to load `base_dir/config.toml`; fall back to [`AppConfig::default()`] + /// if absent or unparseable. + /// + /// Storage paths are derived from `base_dir` regardless of what the file + /// contains (they are `#[serde(skip)]` and never persisted). pub fn load_or_default(base_dir: &Path) -> Self { let config_path = base_dir.join("config.toml"); - match fs::read_to_string(&config_path) { + let mut config = match fs::read_to_string(&config_path) { Ok(contents) => match toml::from_str(&contents) { Ok(config) => config, Err(e) => { @@ -182,7 +233,11 @@ impl AppConfig { } }, Err(_) => AppConfig::default(), - } + }; + let max_age = config.storage.group_message_max_age_days; + config.storage = StorageConfig::for_base_dir(base_dir.to_path_buf()); + config.storage.group_message_max_age_days = max_age; + config } /// Write this config to `base_dir/config.toml`. @@ -211,13 +266,19 @@ mod tests { #[test] fn test_config_roundtrip() { - let config = AppConfig::default(); + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + let mut config = AppConfig::default(); + config.storage = StorageConfig::for_base_dir(base_dir.to_path_buf()); - let toml_str = toml::to_string(&config).unwrap(); - let parsed: AppConfig = toml::from_str(&toml_str).unwrap(); + // Save and reload + config.save(base_dir).unwrap(); + let parsed = AppConfig::load_or_default(base_dir); assert_eq!(config.server.port, parsed.server.port); assert_eq!(config.identity.ipfs_api, parsed.identity.ipfs_api); + // Storage paths are derived from base_dir, not from the file + assert_eq!(parsed.storage.base_dir, base_dir); } #[test] @@ -233,4 +294,163 @@ mod tests { assert_eq!(config.server.port, 8080); assert_eq!(config.media.turn_servers.len(), 1); } + + #[test] + fn test_relay_save_load_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + + // Save defaults (no relays) + let mut default_cfg = AppConfig::default(); + default_cfg.storage = StorageConfig::for_base_dir(base_dir.to_path_buf()); + default_cfg.save(base_dir).unwrap(); + + // Load, add relay, save (simulates API handler) + let mut config = AppConfig::load_or_default(base_dir); + config.p2p.relay_peers.push(RelayPeerConfig { + peer_id: "12D3KooWTest".to_string(), + multiaddr: "/ip4/1.2.3.4/tcp/4001".to_string(), + }); + config.save(base_dir).unwrap(); + + // Reload and verify relay persisted + let reloaded = AppConfig::load_or_default(base_dir); + assert_eq!(reloaded.p2p.relay_peers.len(), 1); + assert_eq!(reloaded.p2p.relay_peers[0].peer_id, "12D3KooWTest"); + assert_eq!( + reloaded.p2p.relay_peers[0].multiaddr, + "/ip4/1.2.3.4/tcp/4001" + ); + } + + #[test] + fn test_storage_paths_not_serialized() { + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + + let mut config = AppConfig::default(); + config.storage = StorageConfig::for_base_dir(base_dir.to_path_buf()); + config.save(base_dir).unwrap(); + + let contents = fs::read_to_string(base_dir.join("config.toml")).unwrap(); + // Machine-specific paths should never appear in the TOML + assert!( + !contents.contains("base_dir"), + "base_dir should not be in config.toml" + ); + assert!( + !contents.contains("identity_path"), + "identity_path should not be in config.toml" + ); + assert!( + !contents.contains("identity_cache_dir"), + "identity_cache_dir should not be in config.toml" + ); + assert!( + !contents.contains("message_db_path"), + "message_db_path should not be in config.toml" + ); + // But user-editable settings should be there + assert!(contents.contains("group_message_max_age_days")); + } + + #[test] + fn test_storage_paths_derived_from_base_dir() { + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + + // Write a config with no storage section at all + let toml = r#" +[server] +host = "127.0.0.1" +port = 3000 + +[p2p] +listen_addrs = ["/ip4/0.0.0.0/tcp/0"] +bootstrap_peers = [] + +[[p2p.relay_peers]] +peer_id = "12D3KooWRelay" +multiaddr = "/ip4/10.0.0.1/tcp/4001" + +[identity] +ipfs_api = "http://127.0.0.1:5001" +cache_ttl_secs = 3600 + +[media] +stun_servers = [] +turn_servers = [] +"#; + fs::write(base_dir.join("config.toml"), toml).unwrap(); + + let config = AppConfig::load_or_default(base_dir); + assert_eq!(config.storage.base_dir, base_dir); + assert_eq!(config.storage.identity_path, base_dir.join("identity.json")); + assert_eq!( + config.storage.identity_cache_dir, + base_dir.join("identity_cache") + ); + assert_eq!(config.storage.message_db_path, base_dir.join("messages.db")); + // Relay from file should be loaded + assert_eq!(config.p2p.relay_peers.len(), 1); + assert_eq!(config.p2p.relay_peers[0].peer_id, "12D3KooWRelay"); + } + + #[test] + fn test_retention_persisted_in_storage_section() { + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + + // Save config with custom retention + let mut config = AppConfig::default(); + config.storage = StorageConfig::for_base_dir(base_dir.to_path_buf()); + config.storage.group_message_max_age_days = 90; + config.save(base_dir).unwrap(); + + // Reload and verify + let reloaded = AppConfig::load_or_default(base_dir); + assert_eq!(reloaded.storage.group_message_max_age_days, 90); + } + + #[test] + fn test_load_legacy_config_with_storage_paths() { + // Legacy config.toml files may have storage paths serialized. + // They should be ignored in favor of the runtime base_dir. + let dir = tempfile::tempdir().unwrap(); + let base_dir = dir.path(); + + let toml = r#" +[server] +host = "127.0.0.1" +port = 3000 + +[p2p] +listen_addrs = ["/ip4/0.0.0.0/tcp/0"] +bootstrap_peers = [] +relay_peers = [] + +[identity] +ipfs_api = "http://127.0.0.1:5001" +cache_ttl_secs = 3600 + +[media] +stun_servers = [] +turn_servers = [] + +[storage] +base_dir = "/some/old/machine/path" +identity_path = "/some/old/machine/path/identity.json" +identity_cache_dir = "/some/old/machine/path/identity_cache" +message_db_path = "/some/old/machine/path/messages.db" +group_message_max_age_days = 14 +"#; + fs::write(base_dir.join("config.toml"), toml).unwrap(); + + let config = AppConfig::load_or_default(base_dir); + // Paths should come from base_dir, NOT from the file + assert_eq!(config.storage.base_dir, base_dir); + assert_eq!(config.storage.identity_path, base_dir.join("identity.json")); + // But retention should be from the file + assert_eq!(config.storage.group_message_max_age_days, 14); + } } diff --git a/crates/variance-app/src/node.rs b/crates/variance-app/src/node.rs index 82dfc86..a60283f 100644 --- a/crates/variance-app/src/node.rs +++ b/crates/variance-app/src/node.rs @@ -116,10 +116,6 @@ pub async fn start_node( ) -> Result { tracing::info!("Initializing Variance node..."); - // Load on-disk config overrides (relay peers, STUN servers, etc.). - // Falls back to the provided defaults if no config.toml exists yet. - let config = AppConfig::load_or_default(&config.storage.base_dir); - // Load identity once — used for keypair derivation and AppState construction. let mut identity = AppState::load_identity_with_passphrase(identity_path, passphrase).map_err(|e| { @@ -193,7 +189,7 @@ pub async fn start_node( ipfs_storage, passphrase, config.media.stun_servers.clone(), - config.storage.base_dir.join("config.toml"), + config.storage.base_dir.clone(), ) .map_err(|e| crate::Error::App { message: format!("Failed to initialize app state: {}", e), diff --git a/crates/variance-app/src/state.rs b/crates/variance-app/src/state.rs index b75c632..20b677b 100644 --- a/crates/variance-app/src/state.rs +++ b/crates/variance-app/src/state.rs @@ -109,8 +109,8 @@ pub struct AppState { /// Wrapped in `Zeroizing` so the passphrase is scrubbed from heap memory on drop. pub identity_passphrase: Option>>, - /// Path to the on-disk config file (`base_dir/config.toml`) used for relay management. - pub config_path: PathBuf, + /// Base directory for config persistence (`config.toml` lives here). + pub config_dir: PathBuf, /// Opaque relay mailbox token: SHA-256(signing_key || "variance-mailbox-v1"). /// Passed to the relay when fetching offline messages; never a human-readable DID. @@ -208,7 +208,7 @@ impl AppState { ipfs_storage: Arc, passphrase: Option<&str>, stun_servers: Vec, - config_path: PathBuf, + config_dir: PathBuf, ) -> anyhow::Result { let identity = Self::load_identity_with_passphrase(identity_path, passphrase)?; Self::from_identity( @@ -221,7 +221,7 @@ impl AppState { ipfs_storage, passphrase, stun_servers, - config_path, + config_dir, ) } @@ -237,7 +237,7 @@ impl AppState { ipfs_storage: Arc, passphrase: Option<&str>, stun_servers: Vec, - config_path: PathBuf, + config_dir: PathBuf, ) -> anyhow::Result { // Parse signing key from hex let signing_key_bytes = hex::decode(&identity.signing_key) @@ -322,7 +322,7 @@ impl AppState { identity_path: identity_path.to_path_buf(), ipfs_storage, identity_passphrase: passphrase.map(|p| Arc::new(Zeroizing::new(p.to_string()))), - config_path, + config_dir, mailbox_token, signing_key, }) @@ -487,7 +487,7 @@ impl AppState { LocalStorage::new(ipfs_storage_path).expect("Failed to create test IPFS storage"), ), identity_passphrase: None, - config_path: PathBuf::from("/tmp/test-config.toml"), + config_dir: PathBuf::from("/tmp"), mailbox_token, signing_key, } diff --git a/crates/variance-app/tests/api_lifecycle.rs b/crates/variance-app/tests/api_lifecycle.rs index fd615d5..c13fcb8 100644 --- a/crates/variance-app/tests/api_lifecycle.rs +++ b/crates/variance-app/tests/api_lifecycle.rs @@ -17,7 +17,7 @@ fn fresh_state(did: &str) -> AppState { let dir = tempdir().unwrap(); let db_path = dir.path().join("test.db"); let mut state = AppState::with_db_path(did.to_string(), db_path.to_str().unwrap()); - state.config_path = dir.path().join("config.toml"); + state.config_dir = dir.path().to_path_buf(); std::mem::forget(dir); state } diff --git a/crates/variance-cli/src/main.rs b/crates/variance-cli/src/main.rs index de25ddd..09b9e97 100644 --- a/crates/variance-cli/src/main.rs +++ b/crates/variance-cli/src/main.rs @@ -6,7 +6,7 @@ use std::path::Path; use std::time::Duration; use tokio::signal; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use variance_app::{identity_gen, start_node, AppConfig}; +use variance_app::{config::variance_data_dir, identity_gen, start_node, AppConfig}; #[derive(Parser)] #[command(name = "variance")] @@ -144,12 +144,20 @@ async fn start_node_cmd( tracing::info!("Starting Variance node"); // Load configuration - let config = if Path::new(&config_path).exists() { + let config_file = Path::new(&config_path); + let base_dir = config_file + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(variance_data_dir); + let config = if config_file.exists() { tracing::info!("Loading configuration from {}", config_path); - AppConfig::from_file(&config_path).context("Failed to load configuration file")? + AppConfig::from_file(&config_path, base_dir).context("Failed to load configuration file")? } else { tracing::warn!("Configuration file not found, using defaults"); - AppConfig::default() + let mut cfg = AppConfig::default(); + cfg.storage = variance_app::StorageConfig::for_base_dir(base_dir); + cfg }; // Determine listen address @@ -261,7 +269,14 @@ fn init_config(output: String, force: bool) -> Result<()> { fn show_config(config_path: String) -> Result<()> { tracing::info!("Loading configuration from: {}", config_path); - let config = AppConfig::from_file(&config_path).context("Failed to load configuration file")?; + let config_file = Path::new(&config_path); + let base_dir = config_file + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(|p| p.to_path_buf()) + .unwrap_or_else(variance_data_dir); + let config = + AppConfig::from_file(&config_path, base_dir).context("Failed to load configuration file")?; println!("\n{}", "=".repeat(60)); println!("Variance Configuration"); From fcdad6ecc92a4bc9280c16a4df7af3707bb9e5a7 Mon Sep 17 00:00:00 2001 From: Zack Kollar Date: Fri, 20 Mar 2026 03:06:18 -0400 Subject: [PATCH 2/4] :hammer: Settings UI style matching, the slightest style is broken and I'm crashng out --- .../conversations/ConversationList.tsx | 11 +++- app/src/components/settings/SettingsPage.tsx | 6 +-- app/src/components/ui/Select.tsx | 54 ++++++++++++++----- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/app/src/components/conversations/ConversationList.tsx b/app/src/components/conversations/ConversationList.tsx index 63d2f68..04c964a 100644 --- a/app/src/components/conversations/ConversationList.tsx +++ b/app/src/components/conversations/ConversationList.tsx @@ -1,6 +1,15 @@ import { useState, useRef, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, Copy, Check, QrCode, 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"; diff --git a/app/src/components/settings/SettingsPage.tsx b/app/src/components/settings/SettingsPage.tsx index 2875cc2..4625074 100644 --- a/app/src/components/settings/SettingsPage.tsx +++ b/app/src/components/settings/SettingsPage.tsx @@ -45,13 +45,13 @@ export function SettingsPage() { {/* Spacer — clears macOS traffic lights */}
-
-

+
+

Settings

-
+
{sections.map((s) => ( diff --git a/app/src/components/settings/AppearanceSection.tsx b/app/src/components/settings/AppearanceSection.tsx index e47d875..979b5fa 100644 --- a/app/src/components/settings/AppearanceSection.tsx +++ b/app/src/components/settings/AppearanceSection.tsx @@ -3,9 +3,24 @@ 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" }, + { + 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() { @@ -14,9 +29,7 @@ export function AppearanceSection() { return (
-

- Appearance -

+

Appearance

Customize how Variance looks on your device.

@@ -46,7 +59,11 @@ export function AppearanceSection() {

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

diff --git a/app/src/components/settings/NetworkSection.tsx b/app/src/components/settings/NetworkSection.tsx index bfd7ecb..e38c6f7 100644 --- a/app/src/components/settings/NetworkSection.tsx +++ b/app/src/components/settings/NetworkSection.tsx @@ -67,9 +67,7 @@ export function NetworkSection() {
-

- Network -

+

Network

Configure relay servers for offline message delivery.

@@ -106,9 +104,7 @@ export function NetworkSection() {

{relay.peer_id}

-

- {relay.multiaddr} -

+

{relay.multiaddr}

void handleRemoveRelay(relay.peer_id)} diff --git a/app/src/components/ui/Checkbox.tsx b/app/src/components/ui/Checkbox.tsx index 2bfa3bf..699a2d8 100644 --- a/app/src/components/ui/Checkbox.tsx +++ b/app/src/components/ui/Checkbox.tsx @@ -2,8 +2,7 @@ import { forwardRef, useId } from "react"; import { Check } from "lucide-react"; import { cn } from "../../utils/cn"; -interface CheckboxProps - extends Omit, "type"> { +interface CheckboxProps extends Omit, "type"> { label?: string; error?: string; } @@ -53,7 +52,8 @@ export const Checkbox = forwardRef( "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors", "border-surface-300 bg-surface-50", "dark:border-surface-600 dark:bg-surface-900", - checked && "border-primary-500 bg-primary-500 dark:border-primary-500 dark:bg-primary-500", + checked && + "border-primary-500 bg-primary-500 dark:border-primary-500 dark:bg-primary-500", !disabled && !checked && "group-hover:border-surface-400", error && "border-red-500", className @@ -62,11 +62,7 @@ export const Checkbox = forwardRef( {checked && } - {label && ( - - {label} - - )} + {label && {label}} {error &&

{error}

}
diff --git a/app/src/stores/appStore.ts b/app/src/stores/appStore.ts index c5a17a5..652cccc 100644 --- a/app/src/stores/appStore.ts +++ b/app/src/stores/appStore.ts @@ -33,7 +33,8 @@ export const useAppStore = create((set) => ({ setApiPort: (apiPort) => set({ apiPort }), setError: (error) => set({ error }), setWsConnected: (wsConnected) => set({ wsConnected }), - openSettings: (section) => set({ showSettings: true, ...(section && { settingsSection: section }) }), + openSettings: (section) => + set({ showSettings: true, ...(section && { settingsSection: section }) }), closeSettings: () => set({ showSettings: false }), setSettingsSection: (settingsSection) => set({ settingsSection }), })); From 9266a5560464a282148a175b9d87defff3554fcc Mon Sep 17 00:00:00 2001 From: Zack Kollar Date: Fri, 20 Mar 2026 20:35:03 -0400 Subject: [PATCH 4/4] :hammer: Fix headings sizes --- app/src/components/settings/SettingsPage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/components/settings/SettingsPage.tsx b/app/src/components/settings/SettingsPage.tsx index 4625074..81ffd11 100644 --- a/app/src/components/settings/SettingsPage.tsx +++ b/app/src/components/settings/SettingsPage.tsx @@ -45,10 +45,12 @@ export function SettingsPage() { {/* Spacer — clears macOS traffic lights */}
-
+

Settings

+ {/* Invisible spacer matching IconButton height so header aligns with Messages header */} +
@@ -84,7 +86,7 @@ export function SettingsPage() { {/* Content */}
{/* Top bar with close button */} -
+