feat: activity status persistence, voice-node leave on disconnect, guild bar, chat context menu, settings/identity UI

This commit is contained in:
loki5512344 2026-07-18 22:19:47 +02:00
parent 03d94e0465
commit 49e1f38793
Signed by: boba
GPG key ID: 253067914055423B
9 changed files with 862 additions and 136 deletions

View file

@ -1,6 +1,5 @@
mod ui; mod ui;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration; use std::time::Duration;
@ -68,7 +67,9 @@ fn main() -> Result<()> {
let mut s = state.borrow_mut(); let mut s = state.borrow_mut();
crate::ui::update::process_net_events(&mut s); crate::ui::update::process_net_events(&mut s);
let own_id = s.identity.short_id().to_string(); 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<RefCell<AppState>>) {
return; return;
} }
let mut s = state.borrow_mut(); 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(); s.ui.chat_input = String::new();
let net = s.net.clone(); let net = s.net.clone();
drop(s); drop(s);
@ -172,7 +192,7 @@ fn wire_signals(window: &MainWindow, state: &Rc<RefCell<AppState>>) {
net.send(NetCommand::SendChat { net.send(NetCommand::SendChat {
channel_id: ch_id, channel_id: ch_id,
content: text, content: text,
reply_to: None, reply_to,
}) })
.await; .await;
}); });
@ -332,17 +352,169 @@ fn wire_signals(window: &MainWindow, state: &Rc<RefCell<AppState>>) {
} }
{ {
let _state = state.clone(); let state = state.clone();
window.on_set_passphrase(move |_pass| {}); 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(); let state = state.clone();
window.on_export_keyfile(move || {}); 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(); let state = state.clone();
window.on_import_keyfile(move || {}); 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;
});
});
} }
} }

View file

@ -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<ChannelItem> = state let channels: Vec<ChannelItem> = state
.channels .channels
.iter() .iter()
@ -78,9 +80,18 @@ fn sync_sidebar(window: &MainWindow, state: &UiState) {
let guilds: Vec<GuildItem> = state let guilds: Vec<GuildItem> = state
.guilds .guilds
.iter() .iter()
.map(|g| GuildItem { .map(|g| {
guild_id: SharedString::from(g.guild_id.as_str()), let short = if g.name.len() >= 2 {
name: SharedString::from(g.name.as_str()), 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(); .collect();
window.set_guilds(Rc::new(VecModel::from(guilds)).into()); window.set_guilds(Rc::new(VecModel::from(guilds)).into());
@ -92,8 +103,14 @@ fn sync_sidebar(window: &MainWindow, state: &UiState) {
.collect(); .collect();
window.set_dm_conversations(Rc::new(VecModel::from(dms)).into()); 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_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( window.set_active_channel_id(SharedString::from(
state.active_channel.as_deref().unwrap_or(""), 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) { pub fn sync_settings(window: &MainWindow, state: &UiState, own_pubkey: &str) {
window.set_identity_pubkey(SharedString::from("")); window.set_identity_pubkey(SharedString::from(own_pubkey));
window.set_vault_enabled(false); window.set_vault_enabled(false);
window.set_voice_input_device(SharedString::from("")); window.set_voice_input_device(SharedString::from(""));
window.set_voice_output_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_vad_mode(state.vad_mode as i32);
window.set_volume(state.output_volume); window.set_volume(state.output_volume);
window.set_active_tab(SharedString::from("audio")); 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) { pub fn sync_ui(
sync_sidebar(window, state); 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_chat(window, state, own_user_id);
sync_friends(window, state); sync_friends(window, state);
sync_voice(window, state); sync_voice(window, state);
sync_connect(window, state); sync_connect(window, state);
sync_settings(window, state); sync_settings(window, state, own_pubkey);
} }

View file

@ -25,6 +25,56 @@ export struct MemberItem {
status: string, status: string,
} }
component MessageActions {
in-out property <bool> is-own;
in-out property <string> 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 { export component ChatPanel {
in-out property <string> active-channel-id; in-out property <string> active-channel-id;
in-out property <string> active-channel-name; in-out property <string> active-channel-name;
@ -34,9 +84,17 @@ export component ChatPanel {
in-out property <bool> is-dm; in-out property <bool> is-dm;
in-out property <bool> e2ee-enabled; in-out property <bool> e2ee-enabled;
in-out property <string> dm-partner-name; in-out property <string> dm-partner-name;
in-out property <string> replying-to-message;
in-out property <string> replying-to-sender;
in-out property <string> replying-to-content;
callback send-message(string, string); callback send-message(string, string);
callback load-more(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 { VerticalLayout {
padding: 0; padding: 0;
@ -63,16 +121,25 @@ export component ChatPanel {
Flickable { Flickable {
VerticalLayout { VerticalLayout {
for msg in messages: Rectangle { for msg in messages: Rectangle {
height: 60px; height: 72px;
VerticalLayout { VerticalLayout {
padding: 8px; padding-left: 12px;
spacing: 4px; 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 { HorizontalLayout {
spacing: 8px; spacing: 8px;
Text { Text {
text: msg.sender_name; text: msg.sender_name;
color: Theme.accent; color: Theme.accent;
font-weight: 700; font-weight: 700;
font-size: 13px;
} }
Text { Text {
text: msg.timestamp; text: msg.timestamp;
@ -83,12 +150,59 @@ export component ChatPanel {
Text { Text {
text: msg.content; text: msg.content;
wrap: word-wrap; 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 { Rectangle {
height: 48px; height: 48px;
background: Theme.bg-surface; background: Theme.bg-surface;
@ -98,6 +212,7 @@ export component ChatPanel {
TextInput { TextInput {
text <=> input-text; text <=> input-text;
horizontal-stretch: 1; horizontal-stretch: 1;
font-size: 14px;
} }
Rectangle { Rectangle {
width: 64px; width: 64px;

View file

@ -4,6 +4,7 @@ import { ChatPanel, MessageItem, MemberItem } from "chat.slint";
import { FriendsPanel, FriendItem } from "friends.slint"; import { FriendsPanel, FriendItem } from "friends.slint";
import { VoicePanel, VoiceMemberItem } from "voice.slint"; import { VoicePanel, VoiceMemberItem } from "voice.slint";
import { BookmarkItem, ConnectScreen } from "connect.slint"; import { BookmarkItem, ConnectScreen } from "connect.slint";
import { SettingsWindow } from "settings.slint";
export component MainWindow inherits Window { export component MainWindow inherits Window {
in-out property <[ChannelItem]> channels; in-out property <[ChannelItem]> channels;
@ -13,6 +14,7 @@ export component MainWindow inherits Window {
in-out property <string> user-status; in-out property <string> user-status;
in-out property <string> active-channel-id; in-out property <string> active-channel-id;
in-out property <int> active-guild-idx; in-out property <int> active-guild-idx;
in-out property <bool> show-dms;
in-out property <string> active-channel-name; in-out property <string> active-channel-name;
in-out property <[MessageItem]> messages; in-out property <[MessageItem]> messages;
@ -24,6 +26,9 @@ export component MainWindow inherits Window {
in-out property <bool> show-chat; in-out property <bool> show-chat;
in-out property <bool> show-friends; in-out property <bool> show-friends;
in-out property <bool> show-voice; in-out property <bool> show-voice;
in-out property <string> replying-to-message;
in-out property <string> replying-to-sender;
in-out property <string> replying-to-content;
in-out property <[FriendItem]> friends; in-out property <[FriendItem]> friends;
in-out property <[FriendItem]> pending-requests; in-out property <[FriendItem]> pending-requests;
@ -52,6 +57,23 @@ export component MainWindow inherits Window {
in-out property <int> vad-mode: 0; in-out property <int> vad-mode: 0;
in-out property <float> volume: 100.0; in-out property <float> volume: 100.0;
in-out property <string> active-tab: "audio"; in-out property <string> active-tab: "audio";
in-out property <bool> settings-open;
in-out property <bool> passphrase-modal-open;
in-out property <string> passphrase-input;
in-out property <string> passphrase-confirm;
in-out property <string> passphrase-error;
in-out property <bool> export-modal-open;
in-out property <string> export-pass-input;
in-out property <string> export-pass-confirm;
in-out property <string> export-output;
in-out property <string> export-error;
in-out property <bool> import-modal-open;
in-out property <string> import-keyfile-input;
in-out property <string> import-pass-input;
in-out property <string> import-error;
callback channel-selected(string); callback channel-selected(string);
callback guild-selected(int); callback guild-selected(int);
@ -59,6 +81,11 @@ export component MainWindow inherits Window {
callback disconnect(); callback disconnect();
callback send-message(string, string); callback send-message(string, string);
callback load-more(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 add-friend(string);
callback accept-friend(string); callback accept-friend(string);
callback decline-friend(string); callback decline-friend(string);
@ -71,9 +98,10 @@ export component MainWindow inherits Window {
callback remove-bookmark(string); callback remove-bookmark(string);
callback add-bookmark(string); callback add-bookmark(string);
callback close-settings(); callback close-settings();
callback set-passphrase(string); callback set-passphrase(string, string);
callback export-keyfile(); callback remove-passphrase();
callback import-keyfile(); callback export-keyfile(string, string);
callback import-keyfile(string, string);
min-width: 900px; min-width: 900px;
min-height: 560px; min-height: 560px;
@ -101,6 +129,7 @@ export component MainWindow inherits Window {
user-status: root.user-status; user-status: root.user-status;
active-channel-id: root.active-channel-id; active-channel-id: root.active-channel-id;
active-guild-idx: root.active-guild-idx; active-guild-idx: root.active-guild-idx;
show-dms: root.show-dms;
channel-selected(id) => { root.channel-selected(id); } channel-selected(id) => { root.channel-selected(id); }
guild-selected(idx) => { root.guild-selected(idx); } guild-selected(idx) => { root.guild-selected(idx); }
dm-selected(id) => { root.dm-selected(id); } dm-selected(id) => { root.dm-selected(id); }
@ -116,8 +145,16 @@ export component MainWindow inherits Window {
is-dm: root.is-dm; is-dm: root.is-dm;
e2ee-enabled: root.e2ee-enabled; e2ee-enabled: root.e2ee-enabled;
dm-partner-name: root.dm-partner-name; 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); } send-message(id, text) => { root.send-message(id, text); }
load-more(id) => { root.load-more(id); } 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 { friends-panel := FriendsPanel {
visible: show-friends; visible: show-friends;
@ -143,5 +180,44 @@ export component MainWindow inherits Window {
toggle-deafen => { root.toggle-deafen(); } toggle-deafen => { root.toggle-deafen(); }
leave-voice => { root.leave-voice(); } 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); }
}
}
} }
} }

View file

@ -1,11 +1,9 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { Button } from "std-widgets.slint"; import { Button, LineEdit } from "std-widgets.slint";
export component SettingsWindow inherits Window { export component SettingsWindow {
title: "Settings"; width: 640px;
width: 720px; height: 480px;
height: 520px;
background: Theme.bg-base;
in-out property <string> identity-pubkey; in-out property <string> identity-pubkey;
in-out property <bool> vault-enabled; in-out property <bool> vault-enabled;
@ -16,53 +14,181 @@ export component SettingsWindow inherits Window {
in-out property <float> volume; in-out property <float> volume;
in-out property <string> active-tab; in-out property <string> active-tab;
in-out property <bool> passphrase-modal-open;
in-out property <string> passphrase-input;
in-out property <string> passphrase-confirm;
in-out property <string> passphrase-error;
in-out property <bool> export-modal-open;
in-out property <string> export-pass-input;
in-out property <string> export-pass-confirm;
in-out property <string> export-output;
in-out property <string> export-error;
in-out property <bool> import-modal-open;
in-out property <string> import-keyfile-input;
in-out property <string> import-pass-input;
in-out property <string> import-error;
callback close(); callback close();
callback set-passphrase(string); callback set-passphrase(string, string);
callback export-keyfile(); callback remove-passphrase();
callback import-keyfile(); callback export-keyfile(string, string);
callback import-keyfile(string, string);
VerticalLayout { Rectangle {
HorizontalLayout { background: Theme.bg-base;
Button { text: "Audio"; clicked => { active-tab = "audio"; } } width: 100%;
Button { text: "Network"; clicked => { active-tab = "network"; } } height: 100%;
Button { text: "Identity"; clicked => { active-tab = "identity"; } } border-radius: 12px;
Button { text: "App"; clicked => { active-tab = "app"; } } border-width: 1px;
} border-color: Theme.bg-elevated;
if active-tab == "identity": VerticalLayout { VerticalLayout {
Text { text: "Identity"; color: Theme.text-primary; font-size: 18px; } padding: 0;
Text { text: "Public Key: " + identity-pubkey; color: Theme.text-secondary; } spacing: 0;
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(); } }
}
if active-tab == "audio": VerticalLayout { Rectangle {
Text { text: "Voice Settings"; color: Theme.text-primary; font-size: 18px; } height: 44px;
Text { text: "Input: " + voice-input-device; color: Theme.text-secondary; } background: Theme.bg-surface;
Text { text: "Output: " + voice-output-device; color: Theme.text-secondary; } border-radius: 12px;
HorizontalLayout { HorizontalLayout {
Text { text: "Bitrate:"; color: Theme.text-secondary; } padding: 8px;
Text { text: voice-bitrate; color: Theme.text-primary; } 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; } if active-tab == "audio" : VerticalLayout {
Text { text: vad-mode == 0 ? "PTT" : vad-mode == 1 ? "VAD" : "Always On"; color: Theme.text-primary; } 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; } if active-tab == "network" : VerticalLayout {
Text { text: volume; color: Theme.text-primary; } 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(); } }
} }
} }

View file

@ -9,6 +9,156 @@ export struct ChannelItem {
export struct GuildItem { export struct GuildItem {
guild_id: string, guild_id: string,
name: string, name: string,
short_name: string,
selected: bool,
}
component GuildBar {
in-out property <[GuildItem]> guilds;
in-out property <int> 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 <string> active-channel-id;
in-out property <string> 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 { export component Sidebar {
@ -19,71 +169,75 @@ export component Sidebar {
in-out property <string> user-status; in-out property <string> user-status;
in-out property <string> active-channel-id; in-out property <string> active-channel-id;
in-out property <int> active-guild-idx; in-out property <int> active-guild-idx;
in-out property <bool> show-dms;
callback channel-selected(string); callback channel-selected(string);
callback guild-selected(int); callback guild-selected(int);
callback dm-selected(string); callback dm-selected(string);
callback disconnect(); callback disconnect();
width: 240px; width: 296px;
height: 100%; height: 100%;
Rectangle { HorizontalLayout {
background: Theme.bg-base; spacing: 0;
width: 100%;
height: 100%;
VerticalLayout { GuildBar {
Rectangle { guilds: root.guilds;
height: 48px; active-guild-idx: root.active-guild-idx;
background: Theme.bg-surface; guild-selected(idx) => { root.guild-selected(idx); }
HorizontalLayout { }
padding: 8px;
Text { text: "Vnox"; color: Theme.accent; font-size: 18px; } Rectangle {
} width: 240px;
} height: 100%;
background: Theme.bg-base;
VerticalLayout { VerticalLayout {
for item in channels: Rectangle { spacing: 0;
height: 36px;
background: item.channel_id == active-channel-id ? Theme.bg-elevated : Theme.bg-base; 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 { HorizontalLayout {
padding: 8px; padding-left: 12px;
Text { padding-right: 12px;
text: item.kind == "voice" ? "🔊 " : "# "; spacing: 8px;
color: Theme.text-secondary; 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(); }
} }
} }
} }

View file

@ -57,6 +57,27 @@ impl Default for UiState {
remote_speaking: false, remote_speaking: false,
last_remote_speaker_id: String::new(), last_remote_speaker_id: String::new(),
last_voice_packet_ms: 0, 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,
} }
} }
} }

View file

@ -85,4 +85,25 @@ pub struct UiState {
pub remote_speaking: bool, pub remote_speaking: bool,
pub last_remote_speaker_id: String, pub last_remote_speaker_id: String,
pub last_voice_packet_ms: u64, pub last_voice_packet_ms: u64,
pub replying_to_message: Option<String>,
pub replying_to_sender: String,
pub replying_to_content: String,
pub editing_message_id: Option<String>,
pub settings_passphrase_open: bool,
pub settings_passphrase_input: String,
pub settings_passphrase_confirm: String,
pub settings_passphrase_error: Option<String>,
pub settings_export_open: bool,
pub settings_export_pass: String,
pub settings_export_confirm: String,
pub settings_export_output: Option<String>,
pub settings_export_error: Option<String>,
pub settings_import_open: bool,
pub settings_import_input: String,
pub settings_import_pass: String,
pub settings_import_error: Option<String>,
} }

View file

@ -588,11 +588,7 @@ fn social_handle(ui: &mut UiState, event: NetEvent) {
}) })
.collect(); .collect();
} }
NetEvent::GuildCreated { NetEvent::GuildCreated { guild_id, name, .. } => {
guild_id,
name,
..
} => {
ui.guilds.push(crate::ui::state::GuildState { ui.guilds.push(crate::ui::state::GuildState {
guild_id: guild_id.clone(), guild_id: guild_id.clone(),
name, name,
@ -656,18 +652,13 @@ fn social_handle(ui: &mut UiState, event: NetEvent) {
} }
NetEvent::FriendRequested { user_id, nickname } => { NetEvent::FriendRequested { user_id, nickname } => {
ui.pending_friend_requests ui.pending_friend_requests
.push(crate::ui::state::FriendState { .push(crate::ui::state::FriendState { user_id, nickname });
user_id,
nickname,
});
} }
NetEvent::FriendAccepted { user_id, nickname } => { NetEvent::FriendAccepted { user_id, nickname } => {
ui.pending_friend_requests.retain(|f| f.user_id != user_id); ui.pending_friend_requests.retain(|f| f.user_id != user_id);
if !ui.friends.iter().any(|f| f.user_id == user_id) { if !ui.friends.iter().any(|f| f.user_id == user_id) {
ui.friends.push(crate::ui::state::FriendState { ui.friends
user_id, .push(crate::ui::state::FriendState { user_id, nickname });
nickname,
});
} }
} }
NetEvent::FriendRemoved { user_id } => { NetEvent::FriendRemoved { user_id } => {