diff --git a/src/main.rs b/src/main.rs index ae07f25..8cd77d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ mod ui; - use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; @@ -68,7 +67,9 @@ fn main() -> Result<()> { let mut s = state.borrow_mut(); crate::ui::update::process_net_events(&mut s); let own_id = s.identity.short_id().to_string(); - crate::ui::bridge::sync_ui(&win, &s.ui, &own_id); + let own_nick = s.identity.nickname.clone(); + let own_pubkey = s.identity.pubkey_hex.clone(); + crate::ui::bridge::sync_ui(&win, &s.ui, &own_id, &own_nick, &own_pubkey); } }, ); @@ -163,6 +164,25 @@ fn wire_signals(window: &MainWindow, state: &Rc>) { return; } let mut s = state.borrow_mut(); + + if let Some(edit_id) = s.ui.editing_message_id.take() { + let net = s.net.clone(); + s.ui.chat_input = String::new(); + drop(s); + tokio::spawn(async move { + net.send(NetCommand::MessageEdit { + channel_id: ch_id, + message_id: edit_id, + content: text, + }) + .await; + }); + return; + } + + let reply_to = s.ui.replying_to_message.take(); + s.ui.replying_to_sender.clear(); + s.ui.replying_to_content.clear(); s.ui.chat_input = String::new(); let net = s.net.clone(); drop(s); @@ -172,7 +192,7 @@ fn wire_signals(window: &MainWindow, state: &Rc>) { net.send(NetCommand::SendChat { channel_id: ch_id, content: text, - reply_to: None, + reply_to, }) .await; }); @@ -332,17 +352,169 @@ fn wire_signals(window: &MainWindow, state: &Rc>) { } { - let _state = state.clone(); - window.on_set_passphrase(move |_pass| {}); + let state = state.clone(); + window.on_set_passphrase(move |pass, confirm| { + let mut s = state.borrow_mut(); + if pass != confirm { + s.ui.settings_passphrase_error = + Some("Passphrases do not match".to_string()); + return; + } + if pass.len() < 8 { + s.ui.settings_passphrase_error = + Some("Passphrase must be at least 8 characters".to_string()); + return; + } + s.ui.settings_passphrase_error = None; + let identity = s.identity.clone(); + drop(s); + if let Err(e) = identity::save(&identity, Some(pass.as_str())) { + let mut s = state.borrow_mut(); + s.ui.settings_passphrase_error = Some(format!("{e}")); + } + }); } { - let _state = state.clone(); - window.on_export_keyfile(move || {}); + let state = state.clone(); + window.on_remove_passphrase(move || { + let s = state.borrow(); + let identity = s.identity.clone(); + drop(s); + if let Err(e) = identity::save(&identity, None) { + let mut s = state.borrow_mut(); + s.ui.settings_passphrase_error = Some(format!("{e}")); + } + }); } { - let _state = state.clone(); - window.on_import_keyfile(move || {}); + let state = state.clone(); + window.on_export_keyfile(move |pass, confirm| { + let mut s = state.borrow_mut(); + if pass != confirm { + s.ui.settings_export_error = Some("Passphrases do not match".to_string()); + return; + } + let passphrase = if pass.is_empty() { None } else { Some(pass.as_str()) }; + let identity = s.identity.clone(); + drop(s); + match identity::export_keyfile(&identity, passphrase) { + Ok(json) => { + let mut s = state.borrow_mut(); + s.ui.settings_export_output = Some(json); + s.ui.settings_export_error = None; + } + Err(e) => { + let mut s = state.borrow_mut(); + s.ui.settings_export_error = Some(format!("{e}")); + } + } + }); + } + + { + let state = state.clone(); + window.on_import_keyfile(move |input, pass| { + let input = input.to_string(); + let passphrase = if pass.is_empty() { None } else { Some(pass.as_str()) }; + let mut s = state.borrow_mut(); + match identity::import_keyfile(&input, passphrase) { + Ok(identity) => { + if let Err(e) = identity::save(&identity, None) { + s.ui.settings_import_error = Some(format!("{e}")); + } else { + s.ui.settings_import_error = None; + // Will need restart to take full effect + } + } + Err(e) => { + s.ui.settings_import_error = Some(format!("{e}")); + } + } + }); + } + + { + let state = state.clone(); + window.on_react(move |msg_id, emoji| { + let msg_id = msg_id.to_string(); + let emoji = emoji.to_string(); + let s = state.borrow(); + let ch_id = s.ui.active_channel.clone().unwrap_or_default(); + let net = s.net.clone(); + drop(s); + tokio::spawn(async move { + net.send(NetCommand::ReactionAdd { + channel_id: ch_id, + message_id: msg_id, + emoji, + }) + .await; + }); + }); + } + + { + let state = state.clone(); + window.on_reply_to_msg(move |msg_id| { + let msg_id = msg_id.to_string(); + let mut s = state.borrow_mut(); + s.ui.replying_to_message = Some(msg_id.clone()); + if let Some(ch_id) = s.ui.active_channel.clone() { + if let Some(msgs) = s.ui.messages.get(&ch_id) { + if let Some(m) = msgs.iter().find(|m| m.message_id == msg_id) { + let sender = m.sender_id.clone(); + let content = m.content.clone(); + s.ui.replying_to_sender = sender; + s.ui.replying_to_content = content; + } + } + } + }); + } + + { + let state = state.clone(); + window.on_cancel_reply(move || { + let mut s = state.borrow_mut(); + s.ui.replying_to_message = None; + s.ui.replying_to_sender.clear(); + s.ui.replying_to_content.clear(); + }); + } + + { + let state = state.clone(); + window.on_edit_msg(move |msg_id| { + let msg_id = msg_id.to_string(); + let mut s = state.borrow_mut(); + if let Some(ch_id) = s.ui.active_channel.clone() { + if let Some(msgs) = s.ui.messages.get(&ch_id) { + if let Some(m) = msgs.iter().find(|m| m.message_id == msg_id) { + s.ui.chat_input = m.content.clone(); + s.ui.editing_message_id = Some(msg_id); + } + } + } + }); + } + + { + let state = state.clone(); + window.on_delete_msg(move |msg_id| { + let msg_id = msg_id.to_string(); + let s = state.borrow(); + let ch_id = s.ui.active_channel.clone().unwrap_or_default(); + let net = s.net.clone(); + drop(s); + tokio::spawn(async move { + net.send(NetCommand::MessageDelete { + channel_id: ch_id, + message_id: msg_id, + }) + .await; + }); + }); } } diff --git a/src/ui/bridge.rs b/src/ui/bridge.rs index 69981ce..7e98c67 100644 --- a/src/ui/bridge.rs +++ b/src/ui/bridge.rs @@ -63,7 +63,9 @@ fn set_members_state(window: &MainWindow, state: &UiState, ch_id: &str) { } } -fn sync_sidebar(window: &MainWindow, state: &UiState) { +fn sync_sidebar(window: &MainWindow, state: &UiState, own_nickname: &str) { + let guild_id = state.active_guild_id.as_deref(); + let channels: Vec = state .channels .iter() @@ -78,9 +80,18 @@ fn sync_sidebar(window: &MainWindow, state: &UiState) { let guilds: Vec = state .guilds .iter() - .map(|g| GuildItem { - guild_id: SharedString::from(g.guild_id.as_str()), - name: SharedString::from(g.name.as_str()), + .map(|g| { + let short = if g.name.len() >= 2 { + g.name[..2].to_string() + } else { + g.name.clone() + }; + GuildItem { + guild_id: SharedString::from(g.guild_id.as_str()), + name: SharedString::from(g.name.as_str()), + short_name: SharedString::from(short), + selected: Some(g.guild_id.as_str()) == guild_id, + } }) .collect(); window.set_guilds(Rc::new(VecModel::from(guilds)).into()); @@ -92,8 +103,14 @@ fn sync_sidebar(window: &MainWindow, state: &UiState) { .collect(); window.set_dm_conversations(Rc::new(VecModel::from(dms)).into()); - window.set_username(SharedString::from("user")); + window.set_username(SharedString::from(own_nickname)); window.set_user_status(SharedString::from("online")); + window.set_show_dms(guild_id.is_none() && !state.dm_conversations.is_empty()); + window.set_replying_to_message(SharedString::from( + state.replying_to_message.as_deref().unwrap_or(""), + )); + window.set_replying_to_sender(SharedString::from(state.replying_to_sender.as_str())); + window.set_replying_to_content(SharedString::from(state.replying_to_content.as_str())); window.set_active_channel_id(SharedString::from( state.active_channel.as_deref().unwrap_or(""), )); @@ -246,8 +263,8 @@ pub fn sync_connect(window: &MainWindow, state: &UiState) { } } -pub fn sync_settings(window: &MainWindow, state: &UiState) { - window.set_identity_pubkey(SharedString::from("")); +pub fn sync_settings(window: &MainWindow, state: &UiState, own_pubkey: &str) { + window.set_identity_pubkey(SharedString::from(own_pubkey)); window.set_vault_enabled(false); window.set_voice_input_device(SharedString::from("")); window.set_voice_output_device(SharedString::from("")); @@ -255,13 +272,46 @@ pub fn sync_settings(window: &MainWindow, state: &UiState) { window.set_vad_mode(state.vad_mode as i32); window.set_volume(state.output_volume); window.set_active_tab(SharedString::from("audio")); + window.set_settings_open(state.settings_open); + + window.set_passphrase_modal_open(state.settings_passphrase_open); + window.set_passphrase_input(SharedString::from(state.settings_passphrase_input.as_str())); + window.set_passphrase_confirm(SharedString::from( + state.settings_passphrase_confirm.as_str(), + )); + window.set_passphrase_error(SharedString::from( + state.settings_passphrase_error.as_deref().unwrap_or(""), + )); + + window.set_export_modal_open(state.settings_export_open); + window.set_export_pass_input(SharedString::from(state.settings_export_pass.as_str())); + window.set_export_pass_confirm(SharedString::from(state.settings_export_confirm.as_str())); + window.set_export_output(SharedString::from( + state.settings_export_output.as_deref().unwrap_or(""), + )); + window.set_export_error(SharedString::from( + state.settings_export_error.as_deref().unwrap_or(""), + )); + + window.set_import_modal_open(state.settings_import_open); + window.set_import_keyfile_input(SharedString::from(state.settings_import_input.as_str())); + window.set_import_pass_input(SharedString::from(state.settings_import_pass.as_str())); + window.set_import_error(SharedString::from( + state.settings_import_error.as_deref().unwrap_or(""), + )); } -pub fn sync_ui(window: &MainWindow, state: &UiState, own_user_id: &str) { - sync_sidebar(window, state); +pub fn sync_ui( + window: &MainWindow, + state: &UiState, + own_user_id: &str, + own_nickname: &str, + own_pubkey: &str, +) { + sync_sidebar(window, state, own_nickname); sync_chat(window, state, own_user_id); sync_friends(window, state); sync_voice(window, state); sync_connect(window, state); - sync_settings(window, state); + sync_settings(window, state, own_pubkey); } diff --git a/src/ui/slint/chat.slint b/src/ui/slint/chat.slint index 02ac3a6..5768d7d 100644 --- a/src/ui/slint/chat.slint +++ b/src/ui/slint/chat.slint @@ -25,6 +25,56 @@ export struct MemberItem { status: string, } +component MessageActions { + in-out property is-own; + in-out property msg-id; + + callback react(string, string); + callback reply-to-msg(string); + callback edit-msg(string); + callback delete-msg(string); + + HorizontalLayout { + spacing: 4px; + Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { react(msg-id, "👍"); } } + Text { text: "👍"; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { react(msg-id, "❤️"); } } + Text { text: "❤️"; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { react(msg-id, "😂"); } } + Text { text: "😂"; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { reply-to-msg(msg-id); } } + Text { text: "↩"; color: Theme.text-secondary; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + if is-own : Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { edit-msg(msg-id); } } + Text { text: "✏"; color: Theme.text-secondary; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + if is-own : Rectangle { + width: 24px; height: 24px; border-radius: 4px; + background: Theme.danger; + TouchArea { clicked => { delete-msg(msg-id); } } + Text { text: "✕"; color: Theme.text-primary; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + } +} + export component ChatPanel { in-out property active-channel-id; in-out property active-channel-name; @@ -34,9 +84,17 @@ export component ChatPanel { in-out property is-dm; in-out property e2ee-enabled; in-out property dm-partner-name; + in-out property replying-to-message; + in-out property replying-to-sender; + in-out property replying-to-content; callback send-message(string, string); callback load-more(string); + callback react(string, string); + callback reply-to-msg(string); + callback edit-msg(string); + callback delete-msg(string); + callback cancel-reply(); VerticalLayout { padding: 0; @@ -63,16 +121,25 @@ export component ChatPanel { Flickable { VerticalLayout { for msg in messages: Rectangle { - height: 60px; + height: 72px; VerticalLayout { - padding: 8px; - spacing: 4px; + padding-left: 12px; + padding-right: 12px; + padding-top: 6px; + padding-bottom: 6px; + spacing: 2px; + if msg.reply_to != "" : Text { + text: "↳ " + msg.reply_to; + color: Theme.text-muted; + font-size: 11px; + } HorizontalLayout { spacing: 8px; Text { text: msg.sender_name; color: Theme.accent; font-weight: 700; + font-size: 13px; } Text { text: msg.timestamp; @@ -83,12 +150,59 @@ export component ChatPanel { Text { text: msg.content; wrap: word-wrap; + color: Theme.text-primary; + font-size: 14px; + } + HorizontalLayout { + height: 22px; + MessageActions { + is-own: msg.is_own; + msg-id: msg.message_id; + react(id, emoji) => { root.react(id, emoji); } + reply-to-msg(id) => { root.reply-to-msg(id); } + edit-msg(id) => { root.edit-msg(id); } + delete-msg(id) => { root.delete-msg(id); } + } } } } } } + if replying-to-message != "" : Rectangle { + height: 32px; + background: Theme.bg-elevated; + HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + spacing: 8px; + Rectangle { + width: 3px; + height: 20px; + background: Theme.accent; + border-radius: 2px; + } + Text { + text: "Replying to " + replying-to-sender; + color: Theme.text-secondary; + font-size: 12px; + } + Text { + text: replying-to-content; + color: Theme.text-muted; + font-size: 12px; + horizontal-stretch: 1; + overflow: elide; + } + Rectangle { + width: 20px; height: 20px; border-radius: 4px; + background: Theme.bg-interactive; + TouchArea { clicked => { cancel-reply(); } } + Text { text: "✕"; color: Theme.text-muted; font-size: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + } + } + Rectangle { height: 48px; background: Theme.bg-surface; @@ -98,6 +212,7 @@ export component ChatPanel { TextInput { text <=> input-text; horizontal-stretch: 1; + font-size: 14px; } Rectangle { width: 64px; diff --git a/src/ui/slint/main.slint b/src/ui/slint/main.slint index 05688c7..0a65082 100644 --- a/src/ui/slint/main.slint +++ b/src/ui/slint/main.slint @@ -4,6 +4,7 @@ import { ChatPanel, MessageItem, MemberItem } from "chat.slint"; import { FriendsPanel, FriendItem } from "friends.slint"; import { VoicePanel, VoiceMemberItem } from "voice.slint"; import { BookmarkItem, ConnectScreen } from "connect.slint"; +import { SettingsWindow } from "settings.slint"; export component MainWindow inherits Window { in-out property <[ChannelItem]> channels; @@ -13,6 +14,7 @@ export component MainWindow inherits Window { in-out property user-status; in-out property active-channel-id; in-out property active-guild-idx; + in-out property show-dms; in-out property active-channel-name; in-out property <[MessageItem]> messages; @@ -24,6 +26,9 @@ export component MainWindow inherits Window { in-out property show-chat; in-out property show-friends; in-out property show-voice; + in-out property replying-to-message; + in-out property replying-to-sender; + in-out property replying-to-content; in-out property <[FriendItem]> friends; in-out property <[FriendItem]> pending-requests; @@ -52,6 +57,23 @@ export component MainWindow inherits Window { in-out property vad-mode: 0; in-out property volume: 100.0; in-out property active-tab: "audio"; + in-out property settings-open; + + in-out property passphrase-modal-open; + in-out property passphrase-input; + in-out property passphrase-confirm; + in-out property passphrase-error; + + in-out property export-modal-open; + in-out property export-pass-input; + in-out property export-pass-confirm; + in-out property export-output; + in-out property export-error; + + in-out property import-modal-open; + in-out property import-keyfile-input; + in-out property import-pass-input; + in-out property import-error; callback channel-selected(string); callback guild-selected(int); @@ -59,6 +81,11 @@ export component MainWindow inherits Window { callback disconnect(); callback send-message(string, string); callback load-more(string); + callback react(string, string); + callback reply-to-msg(string); + callback edit-msg(string); + callback delete-msg(string); + callback cancel-reply(); callback add-friend(string); callback accept-friend(string); callback decline-friend(string); @@ -71,9 +98,10 @@ export component MainWindow inherits Window { callback remove-bookmark(string); callback add-bookmark(string); callback close-settings(); - callback set-passphrase(string); - callback export-keyfile(); - callback import-keyfile(); + callback set-passphrase(string, string); + callback remove-passphrase(); + callback export-keyfile(string, string); + callback import-keyfile(string, string); min-width: 900px; min-height: 560px; @@ -101,6 +129,7 @@ export component MainWindow inherits Window { user-status: root.user-status; active-channel-id: root.active-channel-id; active-guild-idx: root.active-guild-idx; + show-dms: root.show-dms; channel-selected(id) => { root.channel-selected(id); } guild-selected(idx) => { root.guild-selected(idx); } dm-selected(id) => { root.dm-selected(id); } @@ -116,8 +145,16 @@ export component MainWindow inherits Window { is-dm: root.is-dm; e2ee-enabled: root.e2ee-enabled; dm-partner-name: root.dm-partner-name; + replying-to-message: root.replying-to-message; + replying-to-sender: root.replying-to-sender; + replying-to-content: root.replying-to-content; send-message(id, text) => { root.send-message(id, text); } load-more(id) => { root.load-more(id); } + react(id, emoji) => { root.react(id, emoji); } + reply-to-msg(id) => { root.reply-to-msg(id); } + edit-msg(id) => { root.edit-msg(id); } + delete-msg(id) => { root.delete-msg(id); } + cancel-reply => { root.cancel-reply(); } } friends-panel := FriendsPanel { visible: show-friends; @@ -143,5 +180,44 @@ export component MainWindow inherits Window { toggle-deafen => { root.toggle-deafen(); } leave-voice => { root.leave-voice(); } } + + if root.settings-open : Rectangle { + background: #00000080; + TouchArea { } + SettingsWindow { + y: (parent.height - 480px) / 2; + x: (parent.width - 640px) / 2; + identity-pubkey: root.identity-pubkey; + vault-enabled: root.vault-enabled; + voice-input-device: root.voice-input-device; + voice-output-device: root.voice-output-device; + voice-bitrate: root.voice-bitrate; + vad-mode: root.vad-mode; + volume: root.volume; + active-tab: root.active-tab; + + passphrase-modal-open: root.passphrase-modal-open; + passphrase-input: root.passphrase-input; + passphrase-confirm: root.passphrase-confirm; + passphrase-error: root.passphrase-error; + + export-modal-open: root.export-modal-open; + export-pass-input: root.export-pass-input; + export-pass-confirm: root.export-pass-confirm; + export-output: root.export-output; + export-error: root.export-error; + + import-modal-open: root.import-modal-open; + import-keyfile-input: root.import-keyfile-input; + import-pass-input: root.import-pass-input; + import-error: root.import-error; + + close => { root.close-settings(); } + set-passphrase(pass, confirm) => { root.set-passphrase(pass, confirm); } + remove-passphrase => { root.remove-passphrase(); } + export-keyfile(pass, confirm) => { root.export-keyfile(pass, confirm); } + import-keyfile(input, pass) => { root.import-keyfile(input, pass); } + } + } } } diff --git a/src/ui/slint/settings.slint b/src/ui/slint/settings.slint index c92b4fb..a692d67 100644 --- a/src/ui/slint/settings.slint +++ b/src/ui/slint/settings.slint @@ -1,11 +1,9 @@ import { Theme } from "theme.slint"; -import { Button } from "std-widgets.slint"; +import { Button, LineEdit } from "std-widgets.slint"; -export component SettingsWindow inherits Window { - title: "Settings"; - width: 720px; - height: 520px; - background: Theme.bg-base; +export component SettingsWindow { + width: 640px; + height: 480px; in-out property identity-pubkey; in-out property vault-enabled; @@ -16,53 +14,181 @@ export component SettingsWindow inherits Window { in-out property volume; in-out property active-tab; + in-out property passphrase-modal-open; + in-out property passphrase-input; + in-out property passphrase-confirm; + in-out property passphrase-error; + + in-out property export-modal-open; + in-out property export-pass-input; + in-out property export-pass-confirm; + in-out property export-output; + in-out property export-error; + + in-out property import-modal-open; + in-out property import-keyfile-input; + in-out property import-pass-input; + in-out property import-error; + callback close(); - callback set-passphrase(string); - callback export-keyfile(); - callback import-keyfile(); + callback set-passphrase(string, string); + callback remove-passphrase(); + callback export-keyfile(string, string); + callback import-keyfile(string, string); - VerticalLayout { - HorizontalLayout { - Button { text: "Audio"; clicked => { active-tab = "audio"; } } - Button { text: "Network"; clicked => { active-tab = "network"; } } - Button { text: "Identity"; clicked => { active-tab = "identity"; } } - Button { text: "App"; clicked => { active-tab = "app"; } } - } + Rectangle { + background: Theme.bg-base; + width: 100%; + height: 100%; + border-radius: 12px; + border-width: 1px; + border-color: Theme.bg-elevated; - if active-tab == "identity": VerticalLayout { - Text { text: "Identity"; color: Theme.text-primary; font-size: 18px; } - Text { text: "Public Key: " + identity-pubkey; color: Theme.text-secondary; } - Button { text: vault-enabled ? "Remove Passphrase" : "Set Passphrase"; clicked => { set-passphrase(""); } } - Button { text: "Export Keyfile"; clicked => { export-keyfile(); } } - Button { text: "Import Keyfile"; clicked => { import-keyfile(); } } - } + VerticalLayout { + padding: 0; + spacing: 0; - if active-tab == "audio": VerticalLayout { - Text { text: "Voice Settings"; color: Theme.text-primary; font-size: 18px; } - Text { text: "Input: " + voice-input-device; color: Theme.text-secondary; } - Text { text: "Output: " + voice-output-device; color: Theme.text-secondary; } - HorizontalLayout { - Text { text: "Bitrate:"; color: Theme.text-secondary; } - Text { text: voice-bitrate; color: Theme.text-primary; } + Rectangle { + height: 44px; + background: Theme.bg-surface; + border-radius: 12px; + HorizontalLayout { + padding: 8px; + spacing: 4px; + Rectangle { + height: 28px; + background: active-tab == "audio" ? Theme.accent : Theme.bg-interactive; + border-radius: Theme.radius; + TouchArea { clicked => { active-tab = "audio"; } } + Text { text: "Audio"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + height: 28px; + background: active-tab == "network" ? Theme.accent : Theme.bg-interactive; + border-radius: Theme.radius; + TouchArea { clicked => { active-tab = "network"; } } + Text { text: "Network"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + Rectangle { + height: 28px; + background: active-tab == "identity" ? Theme.accent : Theme.bg-interactive; + border-radius: Theme.radius; + TouchArea { clicked => { active-tab = "identity"; } } + Text { text: "Identity"; color: Theme.text-primary; font-size: 13px; padding-left: 12px; padding-right: 12px; horizontal-alignment: center; vertical-alignment: center; } + } + HorizontalLayout { horizontal-stretch: 1; } + Rectangle { + height: 28px; + width: 28px; + border-radius: Theme.radius; + background: Theme.bg-interactive; + TouchArea { clicked => { close(); } } + Text { text: "✕"; color: Theme.text-muted; font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } + } + } } - HorizontalLayout { - Text { text: "VAD Mode:"; color: Theme.text-secondary; } - Text { text: vad-mode == 0 ? "PTT" : vad-mode == 1 ? "VAD" : "Always On"; color: Theme.text-primary; } + + if active-tab == "audio" : VerticalLayout { + padding: 16px; + spacing: 12px; + Text { text: "Voice Settings"; color: Theme.text-primary; font-size: 16px; font-weight: 600; } + HorizontalLayout { + spacing: 8px; + Text { text: "Input Device:"; color: Theme.text-secondary; } + Text { text: voice-input-device; color: Theme.text-primary; } + } + HorizontalLayout { + spacing: 8px; + Text { text: "Output Device:"; color: Theme.text-secondary; } + Text { text: voice-output-device; color: Theme.text-primary; } + } + HorizontalLayout { + spacing: 8px; + Text { text: "Bitrate:"; color: Theme.text-secondary; } + Text { text: voice-bitrate; color: Theme.text-primary; } + } + HorizontalLayout { + spacing: 8px; + Text { text: "Mode:"; color: Theme.text-secondary; } + Text { text: vad-mode == 0 ? "Push to Talk" : vad-mode == 1 ? "Voice Activity" : "Always On"; color: Theme.text-primary; } + } + HorizontalLayout { + spacing: 8px; + Text { text: "Volume:"; color: Theme.text-secondary; } + Text { text: volume; color: Theme.text-primary; } + } } - HorizontalLayout { - Text { text: "Volume:"; color: Theme.text-secondary; } - Text { text: volume; color: Theme.text-primary; } + + if active-tab == "network" : VerticalLayout { + padding: 16px; + spacing: 12px; + Text { text: "Network"; color: Theme.text-primary; font-size: 16px; font-weight: 600; } + Text { text: "Connection settings will appear here."; color: Theme.text-muted; } + } + + if active-tab == "identity" : VerticalLayout { + padding: 16px; + spacing: 12px; + Text { text: "Identity"; color: Theme.text-primary; font-size: 16px; font-weight: 600; } + HorizontalLayout { + spacing: 8px; + Text { text: "Public Key:"; color: Theme.text-secondary; } + Text { text: identity-pubkey; color: Theme.text-primary; font-family: "monospace"; } + } + Text { text: vault-enabled ? "Vault is encrypted" : "Vault is plaintext"; color: Theme.text-secondary; } + + if !passphrase-modal-open && !export-modal-open && !import-modal-open : VerticalLayout { + spacing: 8px; + Rectangle { height: 28px; background: Theme.accent; border-radius: Theme.radius; TouchArea { clicked => { passphrase-modal-open = true; } } Text { text: vault-enabled ? "Change Passphrase" : "Set Passphrase"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; } } + if vault-enabled : Rectangle { height: 28px; background: Theme.danger; border-radius: Theme.radius; TouchArea { clicked => { remove-passphrase(); } } Text { text: "Remove Passphrase"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; } } + Rectangle { height: 28px; background: Theme.accent; border-radius: Theme.radius; TouchArea { clicked => { export-modal-open = true; } } Text { text: "Export Keyfile"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; } } + Rectangle { height: 28px; background: Theme.accent; border-radius: Theme.radius; TouchArea { clicked => { import-modal-open = true; } } Text { text: "Import Keyfile"; color: Theme.text-primary; horizontal-alignment: center; vertical-alignment: center; } } + } + + if passphrase-modal-open : VerticalLayout { + spacing: 8px; + Rectangle { height: 1px; background: Theme.bg-elevated; } + Text { text: vault-enabled ? "Change Passphrase" : "Set Passphrase"; color: Theme.text-primary; font-weight: 600; } + LineEdit { text <=> passphrase-input; placeholder-text: "New passphrase"; } + LineEdit { text <=> passphrase-confirm; placeholder-text: "Confirm passphrase"; } + if passphrase-error != "" : Text { text: passphrase-error; color: Theme.danger; font-size: 12px; } + HorizontalLayout { + spacing: 8px; + Button { text: "Cancel"; clicked => { passphrase-modal-open = false; } } + Button { text: "Save"; clicked => { set-passphrase(passphrase-input, passphrase-confirm); } } + } + } + + if export-modal-open : VerticalLayout { + spacing: 8px; + Rectangle { height: 1px; background: Theme.bg-elevated; } + Text { text: "Export Keyfile"; color: Theme.text-primary; font-weight: 600; } + LineEdit { text <=> export-pass-input; placeholder-text: "Optional passphrase"; } + LineEdit { text <=> export-pass-confirm; placeholder-text: "Confirm passphrase"; } + if export-error != "" : Text { text: export-error; color: Theme.danger; font-size: 12px; } + if export-output != "" : Text { text: export-output; color: Theme.text-muted; font-size: 11px; } + HorizontalLayout { + spacing: 8px; + Button { text: "Cancel"; clicked => { export-modal-open = false; } } + Button { text: "Generate"; clicked => { export-keyfile(export-pass-input, export-pass-confirm); } } + } + } + + if import-modal-open : VerticalLayout { + spacing: 8px; + Rectangle { height: 1px; background: Theme.bg-elevated; } + Text { text: "Import Keyfile"; color: Theme.text-primary; font-weight: 600; } + Text { text: "Paste your keyfile JSON below:"; color: Theme.text-secondary; font-size: 12px; } + TextInput { text <=> import-keyfile-input; height: 80px; } + LineEdit { text <=> import-pass-input; placeholder-text: "Passphrase (if encrypted)"; } + if import-error != "" : Text { text: import-error; color: Theme.danger; font-size: 12px; } + HorizontalLayout { + spacing: 8px; + Button { text: "Cancel"; clicked => { import-modal-open = false; } } + Button { text: "Import & Replace"; clicked => { import-keyfile(import-keyfile-input, import-pass-input); } } + } + } } } - - if active-tab == "network": VerticalLayout { - Text { text: "Network"; color: Theme.text-primary; font-size: 18px; } - } - - if active-tab == "app": VerticalLayout { - Text { text: "Appearance"; color: Theme.text-primary; font-size: 18px; } - } - - Button { text: "Close"; horizontal-alignment: center; clicked => { close(); } } } } diff --git a/src/ui/slint/sidebar.slint b/src/ui/slint/sidebar.slint index a282529..ea0559c 100644 --- a/src/ui/slint/sidebar.slint +++ b/src/ui/slint/sidebar.slint @@ -9,6 +9,156 @@ export struct ChannelItem { export struct GuildItem { guild_id: string, name: string, + short_name: string, + selected: bool, +} + +component GuildBar { + in-out property <[GuildItem]> guilds; + in-out property active-guild-idx; + + callback guild-selected(int); + + width: 56px; + height: 100%; + + Rectangle { + background: #111118; + width: 100%; + height: 100%; + + VerticalLayout { + padding: 8px; + spacing: 4px; + alignment: start; + + Rectangle { + width: 40px; + height: 40px; + border-radius: active-guild-idx == -1 ? 12px : 20px; + background: active-guild-idx == -1 ? Theme.accent : Theme.bg-surface; + TouchArea { + clicked => { guild-selected(-1); } + } + Text { + text: "@"; + color: Theme.text-primary; + font-size: 18px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + Rectangle { + height: 2px; + width: 32px; + background: Theme.bg-elevated; + border-radius: 1px; + x: 12px; + } + + for g[idx] in guilds: Rectangle { + width: 40px; + height: 40px; + border-radius: g.selected ? 12px : 20px; + background: g.selected ? Theme.accent : Theme.bg-elevated; + TouchArea { + clicked => { guild-selected(idx); } + } + Text { + text: g.short_name; + color: Theme.text-primary; + font-size: 14px; + font-weight: 700; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } +} + +component ChannelList { + in-out property <[ChannelItem]> channels; + in-out property active-channel-id; + in-out property section-title; + + callback channel-selected(string); + + VerticalLayout { + Rectangle { + height: 48px; + background: Theme.bg-surface; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Text { text: section-title; color: Theme.text-primary; font-size: 15px; font-weight: 600; } + } + } + + Flickable { + VerticalLayout { + for item in channels: Rectangle { + height: 36px; + background: item.channel_id == active-channel-id ? Theme.bg-elevated : Theme.bg-base; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 6px; + Text { + text: item.kind == "voice" ? "🔊 " : "# "; + color: Theme.text-secondary; + font-size: 14px; + } + Text { + text: item.name; + color: item.channel_id == active-channel-id ? Theme.text-primary : Theme.text-secondary; + font-size: 14px; + } + } + TouchArea { + clicked => { channel-selected(item.channel_id); } + } + } + } + } + } +} + +component DmList { + in-out property <[string]> dm-conversations; + + callback dm-selected(string); + + VerticalLayout { + Rectangle { + height: 48px; + background: Theme.bg-surface; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Text { text: "Direct Messages"; color: Theme.text-primary; font-size: 15px; font-weight: 600; } + } + } + + Flickable { + VerticalLayout { + for dm in dm-conversations: Rectangle { + height: 36px; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 6px; + Text { text: "@"; color: Theme.text-secondary; font-size: 14px; } + Text { text: dm; color: Theme.text-secondary; font-size: 14px; } + } + TouchArea { + clicked => { dm-selected(dm); } + } + } + } + } + } } export component Sidebar { @@ -19,71 +169,75 @@ export component Sidebar { in-out property user-status; in-out property active-channel-id; in-out property active-guild-idx; + in-out property show-dms; callback channel-selected(string); callback guild-selected(int); callback dm-selected(string); callback disconnect(); - width: 240px; + width: 296px; height: 100%; - Rectangle { - background: Theme.bg-base; - width: 100%; - height: 100%; + HorizontalLayout { + spacing: 0; - VerticalLayout { - Rectangle { - height: 48px; - background: Theme.bg-surface; - HorizontalLayout { - padding: 8px; - Text { text: "Vnox"; color: Theme.accent; font-size: 18px; } - } - } + GuildBar { + guilds: root.guilds; + active-guild-idx: root.active-guild-idx; + guild-selected(idx) => { root.guild-selected(idx); } + } + + Rectangle { + width: 240px; + height: 100%; + background: Theme.bg-base; VerticalLayout { - for item in channels: Rectangle { - height: 36px; - background: item.channel_id == active-channel-id ? Theme.bg-elevated : Theme.bg-base; + spacing: 0; + + if root.show-dms : DmList { + dm-conversations: root.dm-conversations; + dm-selected(id) => { root.dm-selected(id); } + } + if !root.show-dms : ChannelList { + channels: root.channels; + active-channel-id: root.active-channel-id; + section-title: root.active-guild-idx >= 0 && root.active-guild-idx < root.guilds.length ? root.guilds[root.active-guild-idx].name : "Channels"; + channel-selected(id) => { root.channel-selected(id); } + } + + Rectangle { + height: 52px; + background: Theme.bg-surface; HorizontalLayout { - padding: 8px; - Text { - text: item.kind == "voice" ? "🔊 " : "# "; - color: Theme.text-secondary; + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + Rectangle { + width: 32px; + height: 32px; + border-radius: 16px; + background: Theme.bg-elevated; + } + VerticalLayout { + spacing: 2px; + Text { text: root.username; color: Theme.text-primary; font-size: 14px; font-weight: 600; } + Text { text: root.user-status; color: Theme.text-muted; font-size: 11px; } + } + HorizontalLayout { + horizontal-stretch: 1; + } + Rectangle { + width: 28px; + height: 28px; + border-radius: 6px; + background: Theme.bg-interactive; + TouchArea { + clicked => { root.disconnect(); } + } + Text { text: "⏻"; color: Theme.text-muted; font-size: 14px; horizontal-alignment: center; vertical-alignment: center; } } - Text { text: item.name; color: Theme.text-primary; } - } - TouchArea { - clicked => { channel-selected(item.channel_id); } - } - } - } - - VerticalLayout { - for dm in dm-conversations: Rectangle { - height: 36px; - HorizontalLayout { - padding: 8px; - Text { text: "@"; color: Theme.text-secondary; } - Text { text: dm; color: Theme.text-primary; } - } - TouchArea { - clicked => { dm-selected(dm); } - } - } - } - - Rectangle { - height: 52px; - background: Theme.bg-surface; - HorizontalLayout { - padding: 8px; - Text { text: username; color: Theme.text-primary; } - Text { text: user-status; color: Theme.text-muted; } - TouchArea { - clicked => { disconnect(); } } } } diff --git a/src/ui/state/default.rs b/src/ui/state/default.rs index 8c44ddc..e6eab4a 100644 --- a/src/ui/state/default.rs +++ b/src/ui/state/default.rs @@ -57,6 +57,27 @@ impl Default for UiState { remote_speaking: false, last_remote_speaker_id: String::new(), last_voice_packet_ms: 0, + + replying_to_message: None, + replying_to_sender: String::new(), + replying_to_content: String::new(), + editing_message_id: None, + + settings_passphrase_open: false, + settings_passphrase_input: String::new(), + settings_passphrase_confirm: String::new(), + settings_passphrase_error: None, + + settings_export_open: false, + settings_export_pass: String::new(), + settings_export_confirm: String::new(), + settings_export_output: None, + settings_export_error: None, + + settings_import_open: false, + settings_import_input: String::new(), + settings_import_pass: String::new(), + settings_import_error: None, } } } diff --git a/src/ui/state/types.rs b/src/ui/state/types.rs index 7471e08..0532142 100644 --- a/src/ui/state/types.rs +++ b/src/ui/state/types.rs @@ -85,4 +85,25 @@ pub struct UiState { pub remote_speaking: bool, pub last_remote_speaker_id: String, pub last_voice_packet_ms: u64, + + pub replying_to_message: Option, + pub replying_to_sender: String, + pub replying_to_content: String, + pub editing_message_id: Option, + + pub settings_passphrase_open: bool, + pub settings_passphrase_input: String, + pub settings_passphrase_confirm: String, + pub settings_passphrase_error: Option, + + pub settings_export_open: bool, + pub settings_export_pass: String, + pub settings_export_confirm: String, + pub settings_export_output: Option, + pub settings_export_error: Option, + + pub settings_import_open: bool, + pub settings_import_input: String, + pub settings_import_pass: String, + pub settings_import_error: Option, } diff --git a/src/ui/update.rs b/src/ui/update.rs index 0a4305e..a31409b 100644 --- a/src/ui/update.rs +++ b/src/ui/update.rs @@ -588,11 +588,7 @@ fn social_handle(ui: &mut UiState, event: NetEvent) { }) .collect(); } - NetEvent::GuildCreated { - guild_id, - name, - .. - } => { + NetEvent::GuildCreated { guild_id, name, .. } => { ui.guilds.push(crate::ui::state::GuildState { guild_id: guild_id.clone(), name, @@ -656,18 +652,13 @@ fn social_handle(ui: &mut UiState, event: NetEvent) { } NetEvent::FriendRequested { user_id, nickname } => { ui.pending_friend_requests - .push(crate::ui::state::FriendState { - user_id, - nickname, - }); + .push(crate::ui::state::FriendState { user_id, nickname }); } NetEvent::FriendAccepted { user_id, nickname } => { ui.pending_friend_requests.retain(|f| f.user_id != user_id); if !ui.friends.iter().any(|f| f.user_id == user_id) { - ui.friends.push(crate::ui::state::FriendState { - user_id, - nickname, - }); + ui.friends + .push(crate::ui::state::FriendState { user_id, nickname }); } } NetEvent::FriendRemoved { user_id } => {