From 74cf13937a4a7ced60bf23e0873de7daa13308ec Mon Sep 17 00:00:00 2001 From: Noah Date: Wed, 25 Mar 2026 21:35:19 -0700 Subject: [PATCH 1/8] fix: hide action trigger on mobile, add swipe-dismiss + hidden-trigger E2E tests - Hide .message-actions entirely on mobile via CSS (was showing due to conflicting display:flex override). Long-press action sheet is the only way to access message actions on mobile. - Add E2E test: action trigger hidden on mobile (hover doesn't show dots) - Swipe-down-to-dismiss was already implemented; E2E test added by user Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/web/style.css | 21 +++------------------ e2e/mobile-actions.spec.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/web/style.css b/crates/web/style.css index 1f4fee1b..f06bd5a3 100644 --- a/crates/web/style.css +++ b/crates/web/style.css @@ -2118,24 +2118,9 @@ select:focus-visible { word-break: break-word; } - /* On mobile, hide the trigger button — long-press shows dropdown directly */ - .action-trigger { - display: none; - } - - /* But show the actions container when dropdown is open */ - .message-actions:has(.message-dropdown) { - display: flex; - } - - .action-trigger { - padding: 6px 10px; - font-size: 18px; - min-width: 36px; - min-height: 36px; - display: flex; - align-items: center; - justify-content: center; + /* On mobile, hide the entire action bar — long-press opens the action sheet instead. */ + .message-actions { + display: none !important; } /* Dropdown: prevent overflow on mobile */ diff --git a/e2e/mobile-actions.spec.ts b/e2e/mobile-actions.spec.ts index 24b65db1..10597780 100644 --- a/e2e/mobile-actions.spec.ts +++ b/e2e/mobile-actions.spec.ts @@ -139,6 +139,21 @@ test.describe('Mobile action sheet', () => { await expect(page.locator('.mobile-action-sheet.open')).toBeHidden(); }); + test('action trigger (three-dot menu) is hidden on mobile', async ({ page }) => { + await freshStart(page); + await createServer(page, 'NoTrigger'); + await sendMessage(page, 'no dots'); + await page.waitForTimeout(500); + + // Hover the message (simulated) — the .message-actions should stay hidden on mobile. + const msg = page.locator('.message').first(); + await msg.hover(); + await page.waitForTimeout(300); + + await expect(page.locator('.action-trigger')).toBeHidden(); + await expect(page.locator('.message-actions')).toBeHidden(); + }); + test('quick tap does NOT open sheet', async ({ page }) => { await freshStart(page); await createServer(page, 'QuickTap2'); From c8ced2fb9ccb8cf6bc28746361a763ed56c81062 Mon Sep 17 00:00:00 2001 From: Noah Date: Wed, 25 Mar 2026 21:37:03 -0700 Subject: [PATCH 2/8] chore: remove redundant mobile member list test Already covered by multi-peer-sync.spec.ts which runs on all browsers. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/multi-peer-mobile.spec.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/e2e/multi-peer-mobile.spec.ts b/e2e/multi-peer-mobile.spec.ts index 5a675859..66de6fac 100644 --- a/e2e/multi-peer-mobile.spec.ts +++ b/e2e/multi-peer-mobile.spec.ts @@ -67,22 +67,6 @@ test.describe('Multi-peer mobile', () => { } }); - test.fixme('member list accessible via toggle — shows peers', async ({ browser }) => { - // Member count assertion can be flaky due to P2P discovery timing. - const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser, 'Mobile Members', 'Alice', 'Bob'); - try { - // Open member list on peer 1. - await openMemberList(page1); - // Member list should include at least our 2 peers (may also include relay). - const memberCount = await page1.locator('.member-item').count(); - expect(memberCount).toBeGreaterThanOrEqual(2); - await closeMemberList(page1); - } finally { - await ctx1.close(); - await ctx2.close(); - } - }); - test.fixme('channel switch during active sync — messages in new channel', async ({ browser }) => { // Channel sync + message sync can exceed test timeframes on mobile. const { ctx1, ctx2, page1, page2 } = await setupTwoPeers(browser, 'Mobile Switch', 'Alice', 'Bob'); From 84eeb8e7bcd25e8d43f0722e97e4f425d2bac9c2 Mon Sep 17 00:00:00 2001 From: Noah Date: Wed, 25 Mar 2026 21:38:46 -0700 Subject: [PATCH 3/8] fix: remove duplicate peer count from channel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The members toggle button (👥 N) already shows the count. The separate "N peers" text was redundant. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/web/src/components/chat.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/web/src/components/chat.rs b/crates/web/src/components/chat.rs index 1e9dfdc2..9d59a4df 100644 --- a/crates/web/src/components/chat.rs +++ b/crates/web/src/components/chat.rs @@ -24,12 +24,6 @@ pub fn ChannelHeader( "\u{1F4CC}" })} - - {move || { - let n = peer_count.get(); - if n == 1 { "1 peer".to_string() } else { format!("{n} peers") } - }} -

"Add a Server"

diff --git a/crates/web/src/components/add_server.rs b/crates/web/src/components/add_server.rs index 656a1c91..e19e6778 100644 --- a/crates/web/src/components/add_server.rs +++ b/crates/web/src/components/add_server.rs @@ -4,6 +4,7 @@ use leptos::prelude::*; use send_wrapper::SendWrapper; use crate::app::WebClientHandle; +use crate::icons; /// Panel for creating a new server or joining an existing one via invite code. /// Shown when the user clicks the "+" button in the server rail. @@ -129,7 +130,7 @@ pub fn AddServerPanel(on_done: impl Fn(()) + Send + Clone + 'static) -> impl Int />
}.into_any() diff --git a/crates/web/src/components/chat.rs b/crates/web/src/components/chat.rs index 9d59a4df..fe1d4a2b 100644 --- a/crates/web/src/components/chat.rs +++ b/crates/web/src/components/chat.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use willow_client::DisplayMessage; use super::MessageView; +use crate::icons; /// Header bar showing the current channel name and connected peer count. #[component] @@ -15,20 +16,17 @@ pub fn ChannelHeader( view! {
- "# " {move || channel.get()} + {icons::icon_hash()} " " {move || channel.get()} {on_pinned_click.map(|cb| view! { })}
diff --git a/crates/web/src/components/file_share.rs b/crates/web/src/components/file_share.rs index 13b77758..91070a18 100644 --- a/crates/web/src/components/file_share.rs +++ b/crates/web/src/components/file_share.rs @@ -3,6 +3,7 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use crate::app::WebClientHandle; +use crate::icons; /// Maximum inline file size (256 KB). const MAX_FILE_SIZE: u64 = 256 * 1024; @@ -76,7 +77,7 @@ pub fn FileShareButton(channel: ReadSignal) -> impl IntoView { view! { ) -> impl IntoView { view! {
- "\u{1F4C4}" + {icons::icon_file()}
{fname_display} {size_str}
} diff --git a/crates/web/src/components/message.rs b/crates/web/src/components/message.rs index 38b84b06..1edf68d9 100644 --- a/crates/web/src/components/message.rs +++ b/crates/web/src/components/message.rs @@ -3,6 +3,7 @@ use wasm_bindgen::JsCast; use willow_client::DisplayMessage; use super::file_share::{parse_inline_file, FileCard}; +use crate::icons; /// Image file extensions for URL and upload embedding. /// SAFETY: SVG is included but must ONLY be rendered via `` tags @@ -513,7 +514,7 @@ pub fn MessageView( ev.stop_propagation(); set_show_dropdown.update(|v| *v = !*v); set_show_react_row.set(false); - }>"\u{22EF}" + }>{icons::icon_more_horizontal()} {move || { if show_dropdown.get() { let reply_view = if has_reply { diff --git a/crates/web/src/components/pinned.rs b/crates/web/src/components/pinned.rs index d4e1d8cc..1cf6bce6 100644 --- a/crates/web/src/components/pinned.rs +++ b/crates/web/src/components/pinned.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use willow_client::DisplayMessage; use super::message::extract_urls; +use crate::icons; /// Render a message body with clickable URL links. fn render_body_with_links(body: &str) -> impl IntoView { @@ -35,7 +36,7 @@ pub fn PinnedPanel(

"Pinned Messages"

- +

{server_name}

diff --git a/crates/web/src/components/settings.rs b/crates/web/src/components/settings.rs index 9a707811..d72a00bc 100644 --- a/crates/web/src/components/settings.rs +++ b/crates/web/src/components/settings.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; +use crate::icons; use crate::util::copy_to_clipboard; /// Profile settings panel -- display name, relay address, peer ID. @@ -81,7 +82,7 @@ pub fn SettingsPanel( // Link to server settings.
diff --git a/crates/web/src/components/sidebar.rs b/crates/web/src/components/sidebar.rs index ba43fb40..0a332eeb 100644 --- a/crates/web/src/components/sidebar.rs +++ b/crates/web/src/components/sidebar.rs @@ -4,6 +4,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; use crate::components::VoiceControls; +use crate::icons; /// Left sidebar showing the server name, channel list, and user info. #[component] @@ -90,7 +91,7 @@ pub fn Sidebar( title="Server Settings" on:click=move |_| on_server_settings_click(()) > - "\u{2699}\u{fe0f}" + {icons::icon_settings()}
@@ -101,7 +102,7 @@ pub fn Sidebar( title="Create channel" on:click=move |_| set_creating.set(true) > - "+" + {icons::icon_plus()}
{move || { @@ -122,7 +123,7 @@ pub fn Sidebar( ev.prevent_default(); set_create_voice.set(true); } - >"\u{1F50A} Voice" + >{icons::icon_volume_2()} " Voice" - {move || if is_voice_for_prefix() { "\u{1F50A} " } else { "# " }} {channel.clone()} + {move || if is_voice_for_prefix() { icons::icon_volume_2().into_any() } else { icons::icon_hash().into_any() }} " " {channel.clone()} { let ch_u = ch_unread.clone(); @@ -288,7 +289,7 @@ pub fn Sidebar( let is_dark = js_sys::eval( "document.documentElement.getAttribute('data-theme') !== 'light'" ).ok().and_then(|v| v.as_bool()).unwrap_or(true); - if is_dark { "\u{2600}\u{fe0f}" } else { "\u{1f319}" } + if is_dark { icons::icon_sun().into_any() } else { icons::icon_moon().into_any() } }} diff --git a/crates/web/src/icons.rs b/crates/web/src/icons.rs new file mode 100644 index 00000000..70d9a9b8 --- /dev/null +++ b/crates/web/src/icons.rs @@ -0,0 +1,290 @@ +//! # SVG Icon Module +//! +//! Inline Lucide-style SVG icons rendered via `` elements with +//! `inner_html`. Each icon uses `currentColor` for stroke so it inherits +//! the surrounding text color, and `width="1em" height="1em"` so it scales +//! with font-size. + +use leptos::prelude::*; + +/// Shared SVG attributes for all icons. +const SVG_ATTRS: &str = r#"xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round""#; + +/// Render an inline SVG icon wrapped in a ``. +/// Size is controlled by the parent's font-size. +fn icon(svg: &str, class: &str) -> impl IntoView { + view! { + + } +} + +/// Hamburger menu icon (three horizontal lines). +pub fn icon_menu() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-menu", + ) +} + +/// Hash / number sign icon (channel indicator). +pub fn icon_hash() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-hash", + ) +} + +/// Speaker / volume icon (voice channel indicator). +pub fn icon_volume_2() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-volume", + ) +} + +/// Settings cog icon. +pub fn icon_settings() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-settings", + ) +} + +/// Pin icon (for pinned messages). +pub fn icon_pin() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-pin", + ) +} + +/// Users / group icon (for member count). +pub fn icon_users() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-users", + ) +} + +/// Microphone icon (unmuted). +pub fn icon_mic() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-mic", + ) +} + +/// Microphone off icon (muted). +pub fn icon_mic_off() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-mic-off", + ) +} + +/// Headphones icon (audio on). +pub fn icon_headphones() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-headphones", + ) +} + +/// Headphones off icon (deafened). Uses a diagonal strike-through over the +/// headphones shape. +pub fn icon_headphones_off() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-headphones-off", + ) +} + +/// Phone off / disconnect icon. +pub fn icon_phone_off() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-phone-off", + ) +} + +/// Paperclip / attachment icon. +pub fn icon_paperclip() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-paperclip", + ) +} + +/// File / document icon. +pub fn icon_file() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-file", + ) +} + +/// Download arrow icon. +pub fn icon_download() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-download", + ) +} + +/// Left arrow icon. +pub fn icon_arrow_left() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-arrow-left", + ) +} + +/// Right arrow icon. +pub fn icon_arrow_right() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-arrow-right", + ) +} + +/// X / close icon. +pub fn icon_x() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-x", + ) +} + +/// Plus icon (for add actions). +pub fn icon_plus() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-plus", + ) +} + +/// Horizontal three-dot / ellipsis icon (more actions). +pub fn icon_more_horizontal() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-more", + ) +} + +/// Sun icon (light theme indicator). +pub fn icon_sun() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-sun", + ) +} + +/// Moon / crescent icon (dark theme indicator). +pub fn icon_moon() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-moon", + ) +} + +/// Send / paper-plane icon. +pub fn icon_send() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-send", + ) +} + +/// Trash / delete icon. +pub fn icon_trash() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-trash", + ) +} + +/// Edit / pencil icon. +pub fn icon_edit() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-edit", + ) +} + +/// Reply / corner-up-left icon. +pub fn icon_reply() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-reply", + ) +} + +/// Smiley face icon (for reaction picker). +pub fn icon_smile() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-smile", + ) +} + +/// Copy / clipboard icon. +pub fn icon_copy() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-copy", + ) +} diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 5762bbb1..0855dc64 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -2,6 +2,8 @@ mod app; mod components; mod event_processing; mod handlers; +#[allow(dead_code)] +pub(crate) mod icons; mod state; pub(crate) mod util; pub mod voice; diff --git a/crates/web/style.css b/crates/web/style.css index f06bd5a3..d212379c 100644 --- a/crates/web/style.css +++ b/crates/web/style.css @@ -106,6 +106,27 @@ code, pre, .peer-id-text, .invite-code-display textarea, .welcome-invite-input { font-family: 'IBM Plex Mono', 'SF Mono', 'Fira Code', monospace; } +/* ── Icons ──────────────────────────────────────────────────────── */ + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + flex-shrink: 0; +} + +.icon svg { + display: block; + width: 1em; + height: 1em; +} + +/* Size variants */ +.icon-sm svg { width: 14px; height: 14px; } +.icon-md svg { width: 18px; height: 18px; } +.icon-lg svg { width: 22px; height: 22px; } + /* ── Focus Visible (accessibility) ───────────────────────────────── */ :focus-visible { From 0ffb33a2b13248f0016d290a8254accac186dc6f Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 26 Mar 2026 01:21:00 -0700 Subject: [PATCH 5/8] feat: add confirmation dialogs for channel delete, kick, message delete, role delete Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/web/src/components/confirm_dialog.rs | 70 +++++++++++++++++++++ crates/web/src/components/member_list.rs | 49 ++++++++++++--- crates/web/src/components/message.rs | 42 +++++++++---- crates/web/src/components/roles.rs | 37 +++++++++-- crates/web/src/components/sidebar.rs | 34 ++++++++-- 5 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 crates/web/src/components/confirm_dialog.rs diff --git a/crates/web/src/components/confirm_dialog.rs b/crates/web/src/components/confirm_dialog.rs new file mode 100644 index 00000000..6eb89e7b --- /dev/null +++ b/crates/web/src/components/confirm_dialog.rs @@ -0,0 +1,70 @@ +use leptos::prelude::*; + +/// Reusable modal confirmation dialog with Cancel / Confirm buttons. +/// +/// Shows an overlay with backdrop blur and a centered card. The confirm +/// button turns red when `danger` is `true`. Pressing Escape closes the +/// dialog via a keydown handler on the overlay. +#[allow(dead_code)] +#[component] +pub fn ConfirmDialog( + /// Whether the dialog is visible. + visible: ReadSignal, + /// Dialog title. + #[prop(into)] + title: String, + /// Descriptive message body. + #[prop(into)] + message: Signal, + /// Label for the confirm button (e.g. "Delete", "Leave"). + #[prop(into)] + confirm_text: String, + /// When true the confirm button uses the danger (red) style. + #[prop(default = false)] + danger: bool, + /// Called when the user confirms. + on_confirm: Callback<()>, + /// Called when the user cancels (or presses Escape). + on_cancel: Callback<()>, +) -> impl IntoView { + let confirm_class = if danger { + "btn btn-danger" + } else { + "btn btn-primary" + }; + + view! { + {move || { + if !visible.get() { + return None; + } + let title = title.clone(); + let msg = message.get(); + let confirm_text = confirm_text.clone(); + Some(view! { +
+
+

{title}

+

{msg}

+
+ + +
+
+
+ }) + }} + } +} diff --git a/crates/web/src/components/member_list.rs b/crates/web/src/components/member_list.rs index 5af25af1..019cc477 100644 --- a/crates/web/src/components/member_list.rs +++ b/crates/web/src/components/member_list.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; +use crate::components::ConfirmDialog; /// Right sidebar showing connected peers with trust/kick actions. /// Accepts `(peer_id, display_name)` tuples so names update reactively. @@ -11,6 +12,11 @@ pub fn MemberList( ) -> impl IntoView { let handle = use_context::().unwrap(); + // Kick confirmation state. + let (show_kick_confirm, set_show_kick_confirm) = signal(false); + let (pending_kick_peer, set_pending_kick_peer) = signal(Option::<(String, String)>::None); + let handle_kick_confirm = handle.clone(); + view! {

"Members"

@@ -21,6 +27,7 @@ pub fn MemberList( > { let (pid, name, is_online) = peer; + let name_for_kick = name.clone(); let pid_badge = pid.clone(); let pid_trust = pid.clone(); let pid_untrust = pid.clone(); @@ -29,7 +36,6 @@ pub fn MemberList( let handle_badge = handle.clone(); let handle_trust = handle.clone(); let handle_untrust = handle.clone(); - let handle_kick = handle.clone(); view! {
@@ -65,7 +71,6 @@ pub fn MemberList( let pu = pid_untrust.clone(); let hu = handle_untrust.clone(); let pk = pid_kick.clone(); - let hk = handle_kick.clone(); let hb2 = handle_badge.clone(); move || { let is_owner = hb2.server_owner() == peer_id.get_untracked(); @@ -77,12 +82,18 @@ pub fn MemberList( let pu = pu.clone(); let hu = hu.clone(); let pk = pk.clone(); - let hk = hk.clone(); - Some(view! { - - - - }) + { + let kick_name = name_for_kick.clone(); + let kick_pid = pk.clone(); + Some(view! { + + + + }) + } } } } @@ -98,6 +109,28 @@ pub fn MemberList( None } }} +
} } diff --git a/crates/web/src/components/message.rs b/crates/web/src/components/message.rs index 1edf68d9..578d8f70 100644 --- a/crates/web/src/components/message.rs +++ b/crates/web/src/components/message.rs @@ -3,6 +3,7 @@ use wasm_bindgen::JsCast; use willow_client::DisplayMessage; use super::file_share::{parse_inline_file, FileCard}; +use crate::components::ConfirmDialog; use crate::icons; /// Image file extensions for URL and upload embedding. @@ -243,6 +244,9 @@ pub fn MessageView( let (show_dropdown, set_show_dropdown) = signal(false); let (show_react_row, set_show_react_row) = signal(false); + // Delete confirmation state. + let (show_del_confirm, set_show_del_confirm) = signal(false); + // Determine whether to show any action buttons at all. let has_reply = on_click.is_some(); let has_react = on_react.is_some(); @@ -497,8 +501,6 @@ pub fn MessageView( {if show_actions { let edit_cb = on_edit; let edit_msg = msg_for_edit.clone(); - let delete_cb = on_delete; - let delete_msg = msg_for_delete.clone(); let react_cb = on_react; let pin_cb = on_pin; let pin_msg = msg_for_pin.clone(); @@ -610,15 +612,11 @@ pub fn MessageView( }; let delete_view = if has_delete { - let cb = delete_cb; - let msg = delete_msg.clone(); Some(view! { }) } else { @@ -667,8 +665,6 @@ pub fn MessageView( let pin_label2 = pin_label.clone(); let edit_cb2 = on_edit; let edit_msg2 = message.clone(); - let delete_cb2 = on_delete; - let delete_msg2 = message.clone(); let react_cb2 = on_react; let react_msg2 = message.clone(); @@ -756,14 +752,12 @@ pub fn MessageView( }) } else { None }} {if has_delete { - let cb = delete_cb2; - let msg = delete_msg2.clone(); let close = close_sheet; Some(view! { }) } else { None }} @@ -795,6 +789,30 @@ pub fn MessageView( } else { None }} + {if has_delete { + let del_cb = on_delete; + let del_msg = msg_for_delete.clone(); + Some(view! { + + }) + } else { + None + }}
} } diff --git a/crates/web/src/components/roles.rs b/crates/web/src/components/roles.rs index 0ec14e2f..a093fa28 100644 --- a/crates/web/src/components/roles.rs +++ b/crates/web/src/components/roles.rs @@ -1,6 +1,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; +use crate::components::ConfirmDialog; /// List of all permission names that can be toggled on a role. const PERMISSION_NAMES: &[&str] = &[ @@ -32,6 +33,11 @@ pub fn RoleManager( let (new_name, set_new_name) = signal(String::new()); let (assign_peer, set_assign_peer) = signal(String::new()); + // Role delete confirmation state. + let (show_del_confirm, set_show_del_confirm) = signal(false); + let (pending_del_role, set_pending_del_role) = signal(Option::<(String, String)>::None); + let handle_del_confirm = handle.clone(); + // Determine if the local user is the server owner. let handle_owner = handle.clone(); let is_owner = move || { @@ -124,9 +130,9 @@ pub fn RoleManager( { let (role_id, role_name, permissions) = role; let role_id_delete = role_id.clone(); + let role_name_delete = role_name.clone(); let role_id_perms = role_id.clone(); let role_id_assign = role_id.clone(); - let handle_delete = handle.clone(); let handle_perm = handle.clone(); let handle_assign = handle.clone(); let owner_check = is_owner.clone(); @@ -136,18 +142,19 @@ pub fn RoleManager( {role_name} { let oc = owner_check.clone(); - let hd = handle_delete.clone(); let rid = role_id_delete.clone(); + let rname = role_name_delete.clone(); move || { if oc() { - let hd = hd.clone(); let rid = rid.clone(); + let rname = rname.clone(); Some(view! { + } } From 8a8bbe50f2f6cb1bbb18bb8ba1ff1da0396feda4 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 26 Mar 2026 01:23:23 -0700 Subject: [PATCH 6/8] feat: add server context menu with leave-server support Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/client/src/lib.rs | 15 +++ crates/web/src/app.rs | 28 +++-- crates/web/src/components/context_menu.rs | 42 +++++++ crates/web/src/components/mod.rs | 6 +- crates/web/src/components/server_list.rs | 145 +++++++++++++++++++++- crates/web/style.css | 85 +++++++++++++ 6 files changed, 306 insertions(+), 15 deletions(-) create mode 100644 crates/web/src/components/context_menu.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index d2d804fb..91fc2e8c 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -658,6 +658,21 @@ impl ClientHandle { !self.shared.borrow().state.servers.is_empty() } + /// Remove a server from the local state and persist the change. + /// + /// If the removed server was active, switches to the first remaining + /// server (or clears the active server if none remain). + pub fn leave_server(&self, server_id: &str) { + let mut shared = self.shared.borrow_mut(); + shared.state.servers.remove(server_id); + if shared.state.active_server.as_deref() == Some(server_id) { + shared.state.active_server = shared.state.servers.keys().next().cloned(); + } + // Persist updated server list. + let ids: Vec = shared.state.servers.keys().cloned().collect(); + storage::save_server_list(&ids); + } + /// Create a brand-new server with the local user as owner. /// /// Automatically creates a "general" text channel, initializes the diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index dd920dfd..3eeddaaf 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -7,12 +7,12 @@ use willow_client::{ClientConfig, ClientEvent, ClientHandle, DisplayMessage, Voi use crate::components::{ AddServerPanel, ChannelHeader, ChatInput, FileShareButton, MemberList, MessageList, - PinnedPanel, ServerList, ServerSettingsPanel, SettingsPanel, Sidebar, WelcomeScreen, + PinnedPanel, ServerList, SettingsPanel, Sidebar, WelcomeScreen, }; use crate::event_processing::{extract_roles, process_event_batch, refresh_all_signals}; use crate::handlers; use crate::icons; -use crate::state::{self, ChannelViewState}; +use crate::state::{self, ChannelViewState, SettingsTab}; use crate::voice::VoiceManager; // Notification sounds disabled for now. @@ -224,7 +224,6 @@ pub fn App() -> impl IntoView { let servers = app_state.server.servers; let show_sidebar = app_state.ui.show_sidebar; let show_add_server = app_state.ui.show_add_server; - let show_server_settings = app_state.ui.show_server_settings; let show_settings = app_state.ui.show_settings; let show_pinned = app_state.ui.show_pinned; let show_members = app_state.ui.show_members; @@ -281,9 +280,14 @@ pub fn App() -> impl IntoView { on_add_server_click=move |_| { write.ui.set_show_add_server.update(|v| *v = !*v); write.ui.set_show_settings.set(false); - write.ui.set_show_server_settings.set(false); write.ui.set_show_sidebar.set(false); } + on_open_settings=Callback::new(move |_| { + write.ui.set_settings_tab.set(SettingsTab::Server); + write.ui.set_show_settings.set(true); + write.ui.set_show_add_server.set(false); + write.ui.set_show_sidebar.set(false); + }) /> // Overlay to close sidebar on mobile tap
impl IntoView { server_name=app_state.server.active_server_name on_channel_click=ch_click on_settings_click=move |_| { - write.ui.set_show_settings.update(|v| *v = !*v); - write.ui.set_show_server_settings.set(false); + write.ui.set_settings_tab.set(SettingsTab::Profile); + write.ui.set_show_settings.set(true); + write.ui.set_show_add_server.set(false); write.ui.set_show_sidebar.set(false); } on_server_settings_click=move |_| { - write.ui.set_show_server_settings.update(|v| *v = !*v); - write.ui.set_show_settings.set(false); + write.ui.set_settings_tab.set(SettingsTab::Server); + write.ui.set_show_settings.set(true); + write.ui.set_show_add_server.set(false); write.ui.set_show_sidebar.set(false); } on_voice_join={ @@ -389,12 +395,10 @@ pub fn App() -> impl IntoView { />
}.into_any() - } else if show_server_settings.get() { - view! { }.into_any() } else if show_settings.get() { - view! { }.into_any() } else { let send2 = send.clone(); diff --git a/crates/web/src/components/context_menu.rs b/crates/web/src/components/context_menu.rs new file mode 100644 index 00000000..c4ff76f9 --- /dev/null +++ b/crates/web/src/components/context_menu.rs @@ -0,0 +1,42 @@ +use leptos::prelude::*; + +/// A positioned popup menu that appears at (x, y) when `visible` is true. +/// +/// Click outside or press Escape to close. Children are rendered as the +/// menu items (use `.context-menu-item` buttons). +#[component] +pub fn ContextMenu( + visible: ReadSignal, + x: ReadSignal, + y: ReadSignal, + on_close: Callback<()>, + children: Children, +) -> impl IntoView { + // Close on outside click via a transparent overlay. + let on_keydown = move |ev: web_sys::KeyboardEvent| { + if ev.key() == "Escape" { + on_close.run(()); + } + }; + + let items = children(); + + view! { +
+
+ {items} +
+ } +} diff --git a/crates/web/src/components/mod.rs b/crates/web/src/components/mod.rs index 73c5798e..b63958c3 100644 --- a/crates/web/src/components/mod.rs +++ b/crates/web/src/components/mod.rs @@ -1,5 +1,7 @@ mod add_server; mod chat; +mod confirm_dialog; +mod context_menu; mod file_share; mod input; mod member_list; @@ -7,7 +9,6 @@ mod message; mod pinned; mod roles; mod server_list; -mod server_settings; mod settings; mod sidebar; mod voice; @@ -15,6 +16,8 @@ mod welcome; pub use add_server::*; pub use chat::*; +pub use confirm_dialog::*; +pub use context_menu::*; pub use file_share::*; pub use input::*; pub use member_list::*; @@ -22,7 +25,6 @@ pub use message::*; pub use pinned::*; pub use roles::*; pub use server_list::*; -pub use server_settings::*; pub use settings::*; pub use sidebar::*; pub use voice::*; diff --git a/crates/web/src/components/server_list.rs b/crates/web/src/components/server_list.rs index c55b76b1..2239d7fd 100644 --- a/crates/web/src/components/server_list.rs +++ b/crates/web/src/components/server_list.rs @@ -1,4 +1,8 @@ use leptos::prelude::*; +use wasm_bindgen::JsCast; + +use crate::app::WebClientHandle; +use crate::components::{ConfirmDialog, ContextMenu}; /// Discord-style vertical server icon rail on the far left. #[component] @@ -7,7 +11,27 @@ pub fn ServerList( active_server_id: ReadSignal, on_server_click: impl Fn(String) + Send + Clone + 'static, on_add_server_click: impl Fn(()) + Send + Clone + 'static, + /// Called when the user picks "Server Settings" from the context menu. + #[prop(optional, into)] + on_open_settings: Option>, ) -> impl IntoView { + let handle = use_context::().unwrap(); + + // Context menu state. + let (show_menu, set_show_menu) = signal(false); + let (menu_x, set_menu_x) = signal(0.0f64); + let (menu_y, set_menu_y) = signal(0.0f64); + let (menu_server_id, set_menu_server_id) = signal(Option::::None); + + // Leave-server confirmation dialog state. + let (show_leave_confirm, set_show_leave_confirm) = signal(false); + let (leave_server_id, set_leave_server_id) = signal(Option::::None); + let handle_leave = handle.clone(); + + // Long-press timer for mobile. + let long_press_timer = + send_wrapper::SendWrapper::new(std::rc::Rc::new(std::cell::Cell::new(0i32))); + view! {
{initial}
@@ -55,5 +147,56 @@ pub fn ServerList( "+" + + { + let settings_cb = on_open_settings; + view! { + + + } + } + + } } diff --git a/crates/web/style.css b/crates/web/style.css index d212379c..ff73cd66 100644 --- a/crates/web/style.css +++ b/crates/web/style.css @@ -3009,6 +3009,91 @@ select:focus-visible { color: var(--text-primary); } +/* ── Confirm Dialog ─────────────────────────────────────────────── */ +.confirm-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + backdrop-filter: var(--backdrop-blur); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fade-in 0.15s ease; +} +.confirm-dialog { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: var(--shadow-lg); + animation: scale-in 0.15s ease; +} +.confirm-dialog h3 { margin-bottom: 8px; font-size: 16px; font-weight: 600; } +.confirm-dialog p { color: var(--text-secondary); font-size: 14px; margin-bottom: 20px; line-height: 1.5; } +.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; } +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes scale-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } + +/* ── Settings Tabs ──────────────────────────────────────────────── */ +.settings-tabs { + display: flex; + gap: 2px; + padding: 0 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} +.settings-tab { + padding: 10px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 500; + transition: color var(--transition-fast), border-color var(--transition-fast); +} +.settings-tab:hover { color: var(--text-secondary); } +.settings-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); } + +.settings-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; + color: var(--text-muted); +} +.settings-breadcrumb .back-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + transition: color var(--transition-fast); +} +.settings-breadcrumb .back-btn:hover { color: var(--text-primary); } +.settings-breadcrumb .separator { color: var(--text-muted); } +.settings-breadcrumb .current { color: var(--text-primary); font-weight: 500; } + +/* ── Context Menu ────────────────────────────────────────────────── */ + +.context-menu-overlay { position: fixed; inset: 0; z-index: 998; display: none; } +.context-menu-overlay.open { display: block; } +.context-menu { position: fixed; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow-lg); padding: 4px; min-width: 160px; z-index: 999; display: none; animation: scale-in 0.1s ease; } +.context-menu.open { display: block; } +.context-menu-item { display: block; width: 100%; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary); cursor: pointer; font-family: inherit; font-size: 13px; text-align: left; border-radius: 4px; transition: background var(--transition-fast); } +.context-menu-item:hover { background: var(--bg-message-hover); } +.context-menu-item.danger { color: var(--danger); } +.context-menu-item.danger:hover { background: var(--danger-glow); } + /* ── Reduced Motion ──────────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { From d82c0037b78bf9209127503d04f6b896b8effd21 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 26 Mar 2026 01:23:55 -0700 Subject: [PATCH 7/8] feat: add quick peer ID copy button in sidebar user area Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/web/src/components/sidebar.rs | 15 +++++++++++++++ crates/web/style.css | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/crates/web/src/components/sidebar.rs b/crates/web/src/components/sidebar.rs index 0dee9805..42e32892 100644 --- a/crates/web/src/components/sidebar.rs +++ b/crates/web/src/components/sidebar.rs @@ -87,6 +87,10 @@ pub fn Sidebar( let handle_user = handle.clone(); + // Peer ID copy state. + let (show_copied, set_show_copied) = signal(false); + let handle_copy = handle.clone(); + view! {
+ {move || { + if show_palette.get() { + let ch_click_palette = ch_click_for_palette.clone(); + let srv_click_palette = srv_click_for_palette.clone(); + Some(view! { + + }) + } else { + None + } + }}
}.into_any() } diff --git a/crates/web/src/components/chat.rs b/crates/web/src/components/chat.rs index fe1d4a2b..9271bc87 100644 --- a/crates/web/src/components/chat.rs +++ b/crates/web/src/components/chat.rs @@ -12,6 +12,9 @@ pub fn ChannelHeader( on_menu_click: impl Fn(()) + Send + Clone + 'static, on_members_click: impl Fn(()) + Send + Clone + 'static, #[prop(optional, into)] on_pinned_click: Option>, + /// Called when the user clicks the search icon (opens command palette). + #[prop(optional, into)] + on_search_click: Option>, ) -> impl IntoView { view! {
@@ -20,6 +23,11 @@ pub fn ChannelHeader( {icons::icon_hash()} " " {move || channel.get()} + {on_search_click.map(|cb| view! { + + })} {on_pinned_click.map(|cb| view! {