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 95d62782..3fc8b3ae 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -6,12 +6,13 @@ use send_wrapper::SendWrapper; use willow_client::{ClientConfig, ClientEvent, ClientHandle, DisplayMessage, VoiceSignalPayload}; use crate::components::{ - AddServerPanel, ChannelHeader, ChatInput, FileShareButton, MemberList, MessageList, - PinnedPanel, ServerList, ServerSettingsPanel, SettingsPanel, Sidebar, WelcomeScreen, + AddServerPanel, ChannelHeader, ChatInput, CommandPalette, FileShareButton, MemberList, + MessageList, PinnedPanel, ServerList, SettingsPanel, Sidebar, WelcomeScreen, }; use crate::event_processing::{extract_roles, process_event_batch, refresh_all_signals}; use crate::handlers; -use crate::state::{self, ChannelViewState}; +use crate::icons; +use crate::state::{self, ChannelViewState, SettingsTab}; use crate::voice::VoiceManager; // Notification sounds disabled for now. @@ -99,6 +100,30 @@ pub fn App() -> impl IntoView { ); } + // Register Ctrl+K / Cmd+K for command palette. + { + use wasm_bindgen::JsCast; + let write_for_palette = write; + let closure = wasm_bindgen::closure::Closure::::new( + move |ev: web_sys::KeyboardEvent| { + if (ev.ctrl_key() || ev.meta_key()) && ev.key() == "k" { + ev.prevent_default(); + write_for_palette + .ui + .set_show_palette + .update(|v| *v = !*v); + } + }, + ); + if let Some(window) = web_sys::window() { + let _ = window.add_event_listener_with_callback( + "keydown", + closure.as_ref().unchecked_ref(), + ); + } + closure.forget(); + } + // Populate initial state from the client. refresh_all_signals(&handle, &write); @@ -223,7 +248,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; @@ -239,6 +263,7 @@ pub fn App() -> impl IntoView { let replying_to = app_state.chat.replying_to; let editing = app_state.chat.editing; let channel_views = app_state.chat.channel_views; + let show_palette = app_state.ui.show_palette; // Pre-clone handle for use inside the view closure. let handle_for_voice_join = handle.clone(); @@ -259,6 +284,8 @@ pub fn App() -> impl IntoView { } else { let ch_click = on_channel_click.clone(); let srv_click = on_server_click.clone(); + let ch_click_for_palette = on_channel_click.clone(); + let srv_click_for_palette = on_server_click.clone(); let send = on_send.clone(); let edit_send = on_edit_send.clone(); let del_msg = on_delete_msg.clone(); @@ -280,9 +307,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={ @@ -376,7 +410,7 @@ pub fn App() -> impl IntoView {

"Add a Server"

@@ -388,12 +422,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(); @@ -420,6 +452,7 @@ pub fn App() -> impl IntoView { on_menu_click=move |_| write.ui.set_show_sidebar.update(|v| *v = !*v) on_members_click=move |_| write.ui.set_show_members.update(|v| *v = !*v) on_pinned_click=Callback::new(move |_| write.ui.set_show_pinned.update(|v| *v = !*v)) + on_search_click=Callback::new(move |_| write.ui.set_show_palette.set(true)) /> {move || { if show_pinned.get() { @@ -507,6 +540,31 @@ pub fn App() -> impl IntoView { peer_id=peer_id />
+ {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/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 1e9dfdc2..9271bc87 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] @@ -11,30 +12,29 @@ 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! {
- "# " {move || channel.get()} + {icons::icon_hash()} " " {move || channel.get()} + {on_search_click.map(|cb| view! { + + })} {on_pinned_click.map(|cb| view! { })} - - {move || { - let n = peer_count.get(); - if n == 1 { "1 peer".to_string() } else { format!("{n} peers") } - }} -
diff --git a/crates/web/src/components/command_palette.rs b/crates/web/src/components/command_palette.rs new file mode 100644 index 00000000..fb5e7ae6 --- /dev/null +++ b/crates/web/src/components/command_palette.rs @@ -0,0 +1,212 @@ +use leptos::prelude::*; + +use crate::app::WebClientHandle; +use crate::icons; + +/// Result item category for display grouping. +#[derive(Clone, PartialEq)] +enum PaletteCategory { + Channel, + Server, + Member, +} + +/// A single search result item. +#[derive(Clone)] +struct PaletteItem { + label: String, + /// Secondary identifier (channel name, server id, peer id). + id: String, + category: PaletteCategory, + /// Whether this is a voice channel (only relevant for Channel category). + is_voice: bool, +} + +/// Build the filtered result list from the current query and client state. +fn build_results(handle: &WebClientHandle, query: &str) -> Vec { + let q = query.to_lowercase(); + let mut items: Vec = Vec::new(); + + // Channels. + let channels = handle.channels(); + let kinds = handle.channel_kinds(); + for ch in &channels { + let is_voice = kinds.iter().any(|(n, k)| n == ch && k == "voice"); + if q.is_empty() || ch.to_lowercase().contains(&q) { + items.push(PaletteItem { + label: ch.clone(), + id: ch.clone(), + category: PaletteCategory::Channel, + is_voice, + }); + } + } + + // Servers. + let servers = handle.server_list(); + for (id, name) in &servers { + if q.is_empty() || name.to_lowercase().contains(&q) { + items.push(PaletteItem { + label: name.clone(), + id: id.clone(), + category: PaletteCategory::Server, + is_voice: false, + }); + } + } + + // Members. + let members = handle.server_members(); + for (pid, name, _online) in &members { + if q.is_empty() || name.to_lowercase().contains(&q) || pid.to_lowercase().contains(&q) { + items.push(PaletteItem { + label: name.clone(), + id: pid.clone(), + category: PaletteCategory::Member, + is_voice: false, + }); + } + } + + items +} + +/// Command palette overlay triggered by Ctrl+K / Cmd+K. +/// +/// Provides fuzzy search across channels, servers, and members. +/// Arrow keys navigate, Enter selects, Escape closes. +#[component] +pub fn CommandPalette( + on_close: Callback<()>, + on_switch_channel: Callback, + on_switch_server: Callback, + on_open_members: Callback<()>, +) -> impl IntoView { + let handle = use_context::().unwrap(); + + let (query, set_query) = signal(String::new()); + let (selected_index, set_selected_index) = signal(0usize); + + let handle_for_keydown = handle.clone(); + + let on_keydown = move |ev: web_sys::KeyboardEvent| { + let items = build_results(&handle_for_keydown, &query.get_untracked()); + let len = items.len(); + match ev.key().as_str() { + "Escape" => { + ev.prevent_default(); + on_close.run(()); + } + "ArrowDown" => { + ev.prevent_default(); + if len > 0 { + set_selected_index.update(|i| *i = (*i + 1) % len); + } + } + "ArrowUp" => { + ev.prevent_default(); + if len > 0 { + set_selected_index.update(|i| { + *i = if *i == 0 { len - 1 } else { *i - 1 }; + }); + } + } + "Enter" => { + ev.prevent_default(); + let idx = selected_index.get_untracked(); + if idx < items.len() { + let item = &items[idx]; + match item.category { + PaletteCategory::Channel => on_switch_channel.run(item.id.clone()), + PaletteCategory::Server => on_switch_server.run(item.id.clone()), + PaletteCategory::Member => on_open_members.run(()), + } + on_close.run(()); + } + } + _ => {} + } + }; + + view! { +
+
+ +
+ {move || { + let items = build_results(&handle, &query.get()); + if items.is_empty() { + return view! { +
"No results found"
+ }.into_any(); + } + let sel = selected_index.get(); + let views: Vec<_> = items.iter().enumerate().map(|(i, item)| { + let item_for_click = item.clone(); + let class = if i == sel { + "palette-item selected" + } else { + "palette-item" + }; + let icon_view = match item.category { + PaletteCategory::Channel => { + if item.is_voice { + icons::icon_volume_2().into_any() + } else { + icons::icon_hash().into_any() + } + } + PaletteCategory::Server => { + let initial = item.label.chars().next().unwrap_or('?').to_uppercase().to_string(); + view! { {initial} }.into_any() + } + PaletteCategory::Member => icons::icon_users().into_any(), + }; + let cat_label = match item.category { + PaletteCategory::Channel => "Channel", + PaletteCategory::Server => "Server", + PaletteCategory::Member => "Member", + }; + let label = item.label.clone(); + view! { +
on_switch_channel.run(item_for_click.id.clone()), + PaletteCategory::Server => on_switch_server.run(item_for_click.id.clone()), + PaletteCategory::Member => on_open_members.run(()), + } + on_close.run(()); + } + on:mouseenter=move |_| set_selected_index.set(i) + > + {icon_view} + {label} + {cat_label} +
+ } + }).collect(); + view! {
{views}
}.into_any() + }} +
+
+ "Enter"" to select" + "↑↓"" to navigate" + "Esc"" to close" +
+
+
+ } +} 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/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/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/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 38b84b06..578d8f70 100644 --- a/crates/web/src/components/message.rs +++ b/crates/web/src/components/message.rs @@ -3,6 +3,8 @@ 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. /// SAFETY: SVG is included but must ONLY be rendered via `` tags @@ -242,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(); @@ -496,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(); @@ -513,7 +516,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 { @@ -609,15 +612,11 @@ pub fn MessageView( }; let delete_view = if has_delete { - let cb = delete_cb; - let msg = delete_msg.clone(); Some(view! { }) } else { @@ -666,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(); @@ -755,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 }} @@ -794,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/mod.rs b/crates/web/src/components/mod.rs index 73c5798e..6a0cceef 100644 --- a/crates/web/src/components/mod.rs +++ b/crates/web/src/components/mod.rs @@ -1,5 +1,8 @@ mod add_server; mod chat; +mod command_palette; +mod confirm_dialog; +mod context_menu; mod file_share; mod input; mod member_list; @@ -7,7 +10,6 @@ mod message; mod pinned; mod roles; mod server_list; -mod server_settings; mod settings; mod sidebar; mod voice; @@ -15,6 +17,9 @@ mod welcome; pub use add_server::*; pub use chat::*; +pub use command_palette::*; +pub use confirm_dialog::*; +pub use context_menu::*; pub use file_share::*; pub use input::*; pub use member_list::*; @@ -22,7 +27,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/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"

- +
::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! {
} } 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/src/components/server_settings.rs b/crates/web/src/components/server_settings.rs index f0492a88..473ddc1d 100644 --- a/crates/web/src/components/server_settings.rs +++ b/crates/web/src/components/server_settings.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use crate::app::WebClientHandle; use crate::components::RoleManager; +use crate::icons; use crate::util::copy_to_clipboard; /// A single role entry: (role_id, role_name, list of granted permission strings). @@ -61,7 +62,7 @@ pub fn ServerSettingsPanel(

{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..42e32892 100644 --- a/crates/web/src/components/sidebar.rs +++ b/crates/web/src/components/sidebar.rs @@ -3,7 +3,8 @@ use std::collections::HashMap; use leptos::prelude::*; use crate::app::WebClientHandle; -use crate::components::VoiceControls; +use crate::components::{ConfirmDialog, VoiceControls}; +use crate::icons; /// Left sidebar showing the server name, channel list, and user info. #[component] @@ -48,6 +49,11 @@ pub fn Sidebar( let (new_name, set_new_name) = signal(String::new()); let (create_voice, set_create_voice) = signal(false); + // Channel delete confirmation state. + let (show_del_confirm, set_show_del_confirm) = signal(false); + let (pending_del_channel, set_pending_del_channel) = signal(Option::::None); + let handle_del_confirm = handle.clone(); + let handle_create = handle.clone(); let on_create_submit = move || { let name = new_name.get_untracked(); @@ -81,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! {
@@ -101,7 +111,7 @@ pub fn Sidebar( title="Create channel" on:click=move |_| set_creating.set(true) > - "+" + {icons::icon_plus()}
{move || { @@ -122,7 +132,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(); @@ -200,14 +209,14 @@ pub fn Sidebar( } { let ch_d = ch_delete.clone(); - let hd = handle_del.clone(); view! { + {move || show_copied.get().then(|| view! { + "Copied!" + })} diff --git a/crates/web/src/icons.rs b/crates/web/src/icons.rs new file mode 100644 index 00000000..9c8efad3 --- /dev/null +++ b/crates/web/src/icons.rs @@ -0,0 +1,300 @@ +//! # 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", + ) +} + +/// Search / magnifying glass icon. +pub fn icon_search() -> impl IntoView { + icon( + &format!( + r#""# + ), + "icon-search", + ) +} + +/// 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 1f4fee1b..85134d46 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 { @@ -2118,24 +2139,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 */ @@ -3003,6 +3009,117 @@ 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; } + +/* ── Command Palette ─────────────────────────────────────────────── */ + +.palette-overlay { position: fixed; inset: 0; background: var(--overlay-bg); backdrop-filter: var(--backdrop-blur); display: flex; justify-content: center; padding-top: 15vh; z-index: 1000; animation: fade-in 0.1s ease; } +.palette { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow-lg); width: 90%; max-width: 520px; max-height: 400px; display: flex; flex-direction: column; overflow: hidden; animation: scale-in 0.1s ease; align-self: flex-start; } +.palette-input { padding: 14px 16px; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text-primary); font-size: 15px; font-family: inherit; outline: none; width: 100%; box-sizing: border-box; } +.palette-input::placeholder { color: var(--text-placeholder); } +.palette-results { overflow-y: auto; flex: 1; padding: 4px; } +.palette-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 6px; cursor: pointer; color: var(--text-primary); transition: background var(--transition-fast); } +.palette-item:hover, .palette-item.selected { background: var(--bg-message-hover); } +.palette-item .icon { color: var(--text-muted); } +.palette-item-label { flex: 1; } +.palette-item-category { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } +.palette-empty { padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px; } +.palette-hint { padding: 8px 16px; font-size: 11px; color: var(--text-muted); border-top: 1px solid var(--border); display: flex; gap: 12px; } +.palette-hint kbd { background: var(--bg-input); padding: 1px 5px; border-radius: 3px; font-size: 10px; } + +.search-toggle { background: transparent; border: none; color: var(--text-muted); cursor: pointer; padding: 4px 8px; transition: color var(--transition-fast); } +.search-toggle:hover { color: var(--text-primary); } + +/* ── Peer ID Copy ───────────────────────────────────────────────── */ + +.copy-pid-btn { background: transparent; border: none; color: var(--text-muted); cursor: pointer; padding: 4px; font-size: 14px; transition: color var(--transition-fast); } +.copy-pid-btn:hover { color: var(--text-primary); } +.copied-tooltip { position: absolute; background: var(--accent); color: white; font-size: 11px; padding: 3px 8px; border-radius: 4px; white-space: nowrap; animation: tooltip-fade 1.5s ease forwards; pointer-events: none; } +@keyframes tooltip-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } + +/* ── 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) { 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'); 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');