Initial commit: split VNOX monorepo into VNOX-Client

Desktop client with egui + wgpu UI, audio pipeline (cpal/opus/rodio),
networking (LNEx protocol), and identity management with encrypted vault.

Jitter buffer module extracted from voice-node for standalone operation.
This commit is contained in:
loki5512344 2026-07-09 11:52:57 +02:00
commit bf0b237afd
112 changed files with 19113 additions and 0 deletions

View file

@ -0,0 +1,142 @@
use eframe::egui::{self, Frame};
use crate::theme as t;
use vnox_client::net::{NetCommand, NetHandle};
/// Channel row with optional context menu for deletion.
/// When `net` is provided and the channel is not a default one, a "Delete channel"
/// entry appears in the right-click context menu.
pub(super) fn ch_row_with_menu(
ui: &mut egui::Ui,
icon: &str,
name: &str,
active: bool,
badge: Option<&str>,
live: bool,
net: Option<&NetHandle>,
) -> bool {
let bg = if active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
let text_c = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let resp = Frame::NONE
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(8, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(icon)
.color(if active { t::ACCENT } else { t::TEXT_MUTED })
.size(13.0),
);
ui.label(egui::RichText::new(name).color(text_c).size(13.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if live {
let (r, _) =
ui.allocate_exact_size(egui::vec2(6.0, 6.0), egui::Sense::hover());
ui.painter().circle_filled(r.center(), 3.0, t::SUCCESS);
} else if let Some(b) = badge {
ui.label(
egui::RichText::new(b)
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
}
});
});
})
.response
.interact(egui::Sense::click());
// Right-click context menu — offer deletion for non-default channels.
if let Some(net) = net
&& name != "general"
&& name != "voice"
{
resp.context_menu(|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("Delete channel")
.font(egui::FontId::proportional(12.0))
.color(t::ERROR),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let net = net.clone();
let ch_id = name.to_string();
tokio::spawn(async move {
net.send(NetCommand::ChannelDelete { channel_id: ch_id })
.await;
});
ui.close();
}
});
}
resp.clicked()
}
pub(super) fn member_row(ui: &mut egui::Ui, nick: &str) {
Frame::NONE
.inner_margin(egui::Margin {
left: 28,
right: 8,
top: 4,
bottom: 4,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let init = nick
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover());
ui.painter()
.circle_filled(r.center(), 8.0, nick_color(nick));
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&init,
egui::FontId::proportional(8.0),
egui::Color32::from_rgb(15, 15, 15),
);
ui.label(
egui::RichText::new(nick)
.font(egui::FontId::proportional(11.5))
.color(t::TEXT_MUTED),
);
});
});
}
fn nick_color(nick: &str) -> egui::Color32 {
let colors = [
t::ACCENT,
t::SUCCESS,
t::WARNING,
t::INFO,
egui::Color32::from_rgb(200, 100, 180),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}

View file

@ -0,0 +1,327 @@
mod items;
use eframe::egui::{self, Frame};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::{Channel, UiState, VoiceUiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
if matches!(s.conn, crate::state::ConnState::Connected { .. }) && s.channels.is_empty() {
s.seed_default_channels();
if s.active_channel.is_none() {
s.active_channel = Some("general".into());
}
}
if let Some(guild_id) = &s.active_guild_id.clone()
&& let Some(g) = s.guilds.iter().find(|g| g.guild_id == *guild_id)
{
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&g.name)
.font(egui::FontId::proportional(14.0))
.color(t::TEXT_PRIMARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
Frame::NONE
.inner_margin(egui::Margin::symmetric(4, 2))
.show(ui, |ui| {
ui.label(
egui::RichText::new(format!("{} members", g.member_count))
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
});
// Create channel button ("+").
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(20.0, 18.0)),
)
.on_hover_text("create a channel")
.clicked()
{
s.create_channel_open = true;
s.create_channel_input.clear();
}
let guild_id = guild_id.clone();
if ui
.add(
egui::Button::new(
egui::RichText::new("Leave")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM),
)
.on_hover_text("leave this guild")
.clicked()
{
s.active_guild_id = None;
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildLeave { guild_id }).await;
});
}
});
});
ui.add_space(2.0);
ui.separator();
ui.add_space(4.0);
} else {
// Home (no guild) — show a "Create Channel" button in the channel header.
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("CHANNELS")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(20.0, 18.0)),
)
.on_hover_text("create a channel")
.clicked()
{
s.create_channel_open = true;
s.create_channel_input.clear();
}
});
});
ui.add_space(2.0);
ui.separator();
ui.add_space(4.0);
}
if s.create_channel_open {
show_create_channel_popup(ui.ctx(), s, net);
}
let text_chs: Vec<Channel> = s
.channels
.iter()
.filter(|c| c.kind == "text")
.cloned()
.collect();
let voice_chs: Vec<Channel> = s
.channels
.iter()
.filter(|c| c.kind == "voice")
.cloned()
.collect();
if !text_chs.is_empty() {
cat_label(ui, s, "text");
if !s.collapsed_categories.contains("text") {
for ch in &text_chs {
let active = s.active_channel.as_deref() == Some(&ch.id);
if items::ch_row_with_menu(ui, "#", &ch.name, active, None, false, Some(net)) {
join(s, net, &ch.id);
}
}
}
}
if !voice_chs.is_empty() {
ui.add_space(4.0);
cat_label(ui, s, "voice");
if !s.collapsed_categories.contains("voice") {
for ch in &voice_chs {
let active = s.active_channel.as_deref() == Some(&ch.id);
let live =
matches!(&s.voice, VoiceUiState::Active { channel } if channel == &ch.id);
let badge = (!ch.members.is_empty()).then(|| ch.members.len().to_string());
if items::ch_row_with_menu(
ui,
"~",
&ch.name,
active,
badge.as_deref(),
live,
Some(net),
) {
join(s, net, &ch.id);
}
if live || ch.name == "lobby" {
for member in &ch.members {
items::member_row(ui, member);
}
}
}
}
}
if s.channels.is_empty() {
ui.add_space(8.0);
ui.label(
egui::RichText::new("waiting for channels…")
.font(egui::FontId::proportional(11.0))
.color(t::TEXT_DISABLED),
);
}
}
fn cat_label(ui: &mut egui::Ui, s: &mut UiState, text: &str) {
ui.add_space(2.0);
let collapsed = s.collapsed_categories.contains(text);
let arrow = if collapsed { "▶" } else { "▼" };
let resp = Frame::NONE
.inner_margin(egui::Margin {
left: 12,
right: 8,
top: 2,
bottom: 2,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(arrow)
.font(egui::FontId::proportional(8.0))
.color(t::TEXT_MUTED),
);
ui.label(
egui::RichText::new(text.to_uppercase())
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
});
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
if collapsed {
s.collapsed_categories.remove(text);
} else {
s.collapsed_categories.insert(text.to_string());
}
}
}
fn join(s: &mut UiState, net: &NetHandle, channel_id: &str) {
s.active_channel = Some(channel_id.to_string());
s.server_channel = Some(channel_id.to_string());
let net = net.clone();
let id = channel_id.to_string();
tokio::spawn(async move {
net.send(NetCommand::JoinChannel { channel_id: id }).await;
});
}
fn show_create_channel_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Create a Channel")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(300.0);
ui.label(
egui::RichText::new("Channel Name")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.create_channel_input)
.font(egui::FontId::proportional(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(egui::RichText::new("e.g. dev-talk").color(t::TEXT_DISABLED))
.desired_width(260.0),
);
ui.add_space(8.0);
ui.label(
egui::RichText::new("Type")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(4.0);
let kind_text = match s.create_channel_kind.as_str() {
"voice" => "Voice",
_ => "Text",
};
egui::ComboBox::from_id_salt("channel_kind")
.selected_text(kind_text)
.show_ui(ui, |ui| {
ui.selectable_value(&mut s.create_channel_kind, "text".into(), "Text");
ui.selectable_value(&mut s.create_channel_kind, "voice".into(), "Voice");
});
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("Cancel")
.font(egui::FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.create_channel_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let create_btn = egui::Button::new(
egui::RichText::new("Create")
.font(egui::FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(egui::Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(create_btn).clicked() && !s.create_channel_input.trim().is_empty() {
let name = s.create_channel_input.trim().to_string();
let kind = s.create_channel_kind.clone();
// Send server-side ChannelCreate — gateway will broadcast
// back a ChannelCreated event that updates the sidebar.
let net_clone = net.clone();
let ch_id = name.clone();
let ch_name = name.clone();
let ch_kind = kind.clone();
tokio::spawn(async move {
net_clone
.send(NetCommand::ChannelCreate {
channel_id: ch_id,
channel_name: ch_name,
kind: ch_kind,
})
.await;
});
s.create_channel_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.create_channel_open = false;
}
});
}

View file

@ -0,0 +1,82 @@
use eframe::egui::{self, FontId, Rect, RichText, Stroke};
use crate::theme as t;
pub const GUILD_ICON_SIZE: f32 = 40.0;
pub fn guild_icon(
ui: &mut egui::Ui,
label: &str,
tooltip: &str,
active: bool,
onclick: impl FnOnce(),
) {
let (bg, stroke) = if active {
(t::ACCENT_10, Stroke::new(1.5, t::ACCENT))
} else {
(t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT))
};
let text_color = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let btn = egui::Button::new(
RichText::new(label)
.font(FontId::proportional(14.0))
.color(text_color),
)
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_FULL)
.min_size(egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE));
if ui.add(btn).on_hover_text(tooltip).clicked() {
onclick();
}
}
/// Guild icon that renders a vector-drawn glyph instead of a text label.
pub fn guild_icon_drawn(
ui: &mut egui::Ui,
draw_fn: fn(&egui::Painter, Rect, eframe::egui::Color32),
tooltip: &str,
active: bool,
onclick: impl FnOnce(),
) {
let (bg, stroke) = if active {
(t::ACCENT_10, Stroke::new(1.5, t::ACCENT))
} else {
(t::BG_ELEVATED, Stroke::new(1.0, t::BORDER_DEFAULT))
};
let color = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(GUILD_ICON_SIZE, GUILD_ICON_SIZE),
egui::Sense::click(),
);
if ui.is_rect_visible(rect) {
let painter = ui.painter();
painter.rect_filled(rect, t::R_FULL, bg);
painter.rect_stroke(rect, t::R_FULL, stroke, egui::StrokeKind::Middle);
// Inset for the icon.
let pad = 8.0;
let inner = Rect::from_min_max(
(rect.min.x + pad, rect.min.y + pad).into(),
(rect.max.x - pad, rect.max.y - pad).into(),
);
draw_fn(painter, inner, color);
}
if resp.clicked() {
onclick();
}
if !tooltip.is_empty() {
resp.on_hover_text(tooltip);
}
}

View file

@ -0,0 +1,685 @@
use crate::theme::icons as vec_icons;
use crate::{state::UiState, theme as t};
use eframe::egui::{self, FontId, Frame, RichText, Stroke};
use vnox_client::net::{NetCommand, NetHandle};
mod icons;
const BAR_WIDTH: f32 = 56.0;
pub fn show(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::SidePanel::left("guild_bar")
.exact_width(BAR_WIDTH)
.resizable(false)
.frame(Frame::NONE.fill(t::BG_STRIP))
.show_separator_line(false)
.show(ctx, |ui| {
let r = ui.max_rect();
ui.painter()
.vline(r.right(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE));
ui.with_layout(
egui::Layout::top_down_justified(egui::Align::Center),
|ui| {
ui.add_space(8.0);
let home_active = s.active_guild_id.is_none();
icons::guild_icon_drawn(ui, vec_icons::home, "Home", home_active, || {
s.active_guild_id = None;
s.active_dm_id = s.dm_conversations.first().map(|c| c.dm_id.clone());
});
ui.add_space(4.0);
ui.painter().hline(
egui::Rangef::new(r.left() + 12.0, r.right() - 12.0),
ui.cursor().top() + 2.0,
Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.add_space(6.0);
for guild in &s.guilds {
let active = s.active_guild_id.as_deref() == Some(&guild.guild_id);
let initial = guild
.name
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
icons::guild_icon(ui, &initial, &guild.name, active, || {
s.active_guild_id = Some(guild.guild_id.clone());
let net = net.clone();
let gid = guild.guild_id.clone();
tokio::spawn(async move {
net.send(vnox_client::net::NetCommand::GuildList).await;
let _ = gid;
});
});
ui.add_space(2.0);
}
ui.add_space(4.0);
if t::icon_btn_drawn(
ui,
vec_icons::plus,
"create a guild",
None,
icons::GUILD_ICON_SIZE,
)
.clicked()
{
s.create_guild_open = true;
s.create_guild_input.clear();
}
ui.add_space(4.0);
if t::icon_btn_drawn(
ui,
vec_icons::link,
"join with invite code",
None,
icons::GUILD_ICON_SIZE,
)
.clicked()
{
s.invite_accept_open = true;
s.invite_accept_code.clear();
}
ui.add_space(4.0);
let has_guild = s.active_guild_id.is_some();
let audit_resp = t::icon_btn_drawn(
ui,
vec_icons::clipboard,
"view audit log (admins only)",
None,
icons::GUILD_ICON_SIZE,
);
if has_guild
&& audit_resp.clicked()
&& let Some(gid) = s.active_guild_id.clone()
{
s.audit_log_open = true;
s.audit_log_guild_id = Some(gid.clone());
s.audit_log_entries.clear();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildAuditLogFetch {
guild_id: gid,
limit: 50,
})
.await;
});
}
ui.add_space(4.0);
let members_resp = t::icon_btn_drawn(
ui,
vec_icons::people,
"guild members",
None,
icons::GUILD_ICON_SIZE,
);
let has_guild = s.active_guild_id.is_some();
if has_guild
&& members_resp.clicked()
&& let Some(gid) = s.active_guild_id.clone()
{
s.guild_members_open = true;
s.guild_member_list_guild_id = Some(gid.clone());
s.guild_member_list.clear();
s.guild_role_list.clear();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildMemberListFetch {
guild_id: gid.clone(),
})
.await;
net.send(NetCommand::GuildRoleListFetch { guild_id: gid })
.await;
});
}
},
);
if s.create_guild_open {
show_create_guild_popup(ctx, s, net);
}
if s.invite_accept_open {
show_invite_accept_popup(ctx, s, net);
}
if s.audit_log_open {
show_audit_log_popup(ctx, s, net);
}
if s.guild_members_open {
show_members_popup(ctx, s, net);
}
});
}
fn show_create_guild_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Create a Guild")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(280.0);
ui.label(
RichText::new("Guild Name")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.create_guild_input)
.font(FontId::proportional(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("e.g. My Awesome Guild").color(t::TEXT_DISABLED))
.desired_width(240.0),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.create_guild_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let create_btn = egui::Button::new(
RichText::new("Create")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(create_btn).clicked() && !s.create_guild_input.trim().is_empty() {
let net = net.clone();
let name = s.create_guild_input.trim().to_string();
tokio::spawn(async move {
net.send(vnox_client::net::NetCommand::GuildCreate { name })
.await;
});
s.create_guild_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.create_guild_open = false;
}
});
}
fn show_invite_accept_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Join a Guild")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(280.0);
ui.label(
RichText::new("Invite Code")
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY),
);
ui.add_space(8.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut s.invite_accept_code)
.font(FontId::monospace(14.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(RichText::new("e.g. a1b2c3d4").color(t::TEXT_DISABLED))
.desired_width(240.0),
);
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui
.add(
egui::Button::new(
RichText::new("Cancel")
.font(FontId::proportional(12.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.invite_accept_open = false;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let join_btn = egui::Button::new(
RichText::new("Join")
.font(FontId::proportional(12.0))
.color(egui::Color32::WHITE),
)
.fill(t::ACCENT)
.stroke(Stroke::NONE)
.corner_radius(t::R_SM)
.min_size(egui::vec2(80.0, 28.0));
if ui.add(join_btn).clicked() && !s.invite_accept_code.trim().is_empty() {
let net = net.clone();
let code = s.invite_accept_code.trim().to_string();
tokio::spawn(async move {
net.send(NetCommand::InviteAccept { code }).await;
});
s.invite_accept_open = false;
}
});
});
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
s.invite_accept_open = false;
}
});
}
fn show_audit_log_popup(ctx: &egui::Context, s: &mut UiState, _net: &NetHandle) {
egui::Window::new("Audit Log")
.collapsible(false)
.resizable(true)
.default_size([520.0, 420.0])
.min_size([400.0, 300.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(500.0);
ui.horizontal(|ui| {
ui.label(
RichText::new("Last 50 admin actions for this guild")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() {
s.audit_log_open = false;
}
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(4.0);
if s.audit_log_entries.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.label(
RichText::new("no audit log entries")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
ui.add_space(4.0);
ui.label(
RichText::new("(loading or empty — admin actions like kick, role change, invite create will appear here)")
.font(FontId::proportional(10.0))
.color(t::TEXT_DISABLED),
);
});
return;
}
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for entry in s.audit_log_entries.clone() {
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_SM)
.stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE))
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (icon, color) = action_icon(&entry.action);
ui.label(
RichText::new(icon)
.font(FontId::proportional(14.0))
.color(color),
);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(&entry.action)
.font(FontId::proportional(12.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
ui.label(
RichText::new(format!("· by {}…", &entry.actor_id[..8.min(entry.actor_id.len())]))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
if let Some(t_id) = &entry.target_id {
ui.label(
RichText::new(format!("target: {}…", &t_id[..8.min(t_id.len())]))
.font(FontId::monospace(10.0))
.color(t::TEXT_MUTED),
);
}
if let Some(reason) = &entry.reason {
ui.label(
RichText::new(format!("reason: {reason}"))
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED)
.italics(),
);
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(fmt_audit_ts(entry.created_at))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
});
});
ui.add_space(2.0);
}
});
});
}
fn action_icon(action: &str) -> (&'static str, egui::Color32) {
match action {
"guild_create" | "GUILD_CREATE" => ("[+]", t::SUCCESS),
"guild_delete" | "GUILD_DELETE" => ("[-]", t::ERROR),
"member_kick" | "MEMBER_KICK" => ("[kick]", t::ERROR),
"member_leave" | "MEMBER_LEAVE" => ("[leave]", t::WARNING),
"role_create" | "ROLE_CREATE" => ("[role+]", t::INFO),
"role_delete" | "ROLE_DELETE" => ("[role-]", t::ERROR),
"role_assign" | "ROLE_ASSIGN" => ("[role=]", t::INFO),
"role_unassign" | "ROLE_UNASSIGN" => ("[role!=]", t::WARNING),
"invite_create" | "INVITE_CREATE" => ("[invite+]", t::SUCCESS),
"invite_delete" | "INVITE_DELETE" => ("[invite-]", t::ERROR),
"invite_accept" | "INVITE_ACCEPT" => ("[invite=]", t::SUCCESS),
_ => ("[*]", t::TEXT_MUTED),
}
}
fn fmt_audit_ts(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let diff = (now - ts / 1000).max(0);
if diff < 60 {
format!("{diff}s ago")
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
fn show_members_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
egui::Window::new("Guild Members")
.collapsible(false)
.resizable(true)
.default_size([520.0, 460.0])
.min_size([400.0, 320.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.frame(
Frame::window(&ctx.style())
.fill(t::BG_ELEVATED)
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
.corner_radius(t::R_LG),
)
.show(ctx, |ui| {
ui.set_width(500.0);
ui.horizontal(|ui| {
let count = s.guild_member_list.len();
ui.label(
RichText::new(format!("{count} members"))
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if t::icon_btn_drawn(ui, vec_icons::refresh, "refresh", None, 18.0).clicked()
&& let Some(gid) = s.guild_member_list_guild_id.clone()
{
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::GuildMemberListFetch {
guild_id: gid.clone(),
})
.await;
net.send(NetCommand::GuildRoleListFetch { guild_id: gid })
.await;
});
}
if t::icon_btn_drawn(ui, vec_icons::close, "close", None, 18.0).clicked() {
s.guild_members_open = false;
}
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(4.0);
if s.guild_member_list.is_empty() {
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.label(
RichText::new("no members")
.font(FontId::proportional(12.0))
.color(t::TEXT_DISABLED),
);
});
return;
}
// Determine if current user is owner (kick allowed).
// For simplicity, we allow kick UI for everyone; gateway enforces permission.
let roles_clone = s.guild_role_list.clone();
let guild_id = s.guild_member_list_guild_id.clone().unwrap_or_default();
egui::ScrollArea::vertical()
.auto_shrink([false, true])
.show(ui, |ui| {
for member in s.guild_member_list.clone() {
let role_color =
parse_hex_color(&member.role_color).unwrap_or(t::TEXT_PRIMARY);
Frame::NONE
.fill(t::BG_SURFACE)
.corner_radius(t::R_SM)
.stroke(egui::Stroke::new(1.0, t::BORDER_SUBTLE))
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
// Avatar with role-colored ring
let init = member
.nickname
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string();
let (r, _) = ui.allocate_exact_size(
egui::vec2(28.0, 28.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(r.center(), 14.0, role_color);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&init,
egui::FontId::proportional(12.0),
egui::Color32::from_rgb(15, 15, 15),
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(&member.nickname)
.font(FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
if member.is_owner {
let (cr, _) = ui.allocate_exact_size(
egui::vec2(14.0, 14.0),
egui::Sense::hover(),
);
vec_icons::crown(ui.painter(), cr, t::WARNING);
}
});
ui.horizontal(|ui| {
let (dot_r, _) = ui.allocate_exact_size(
egui::vec2(8.0, 8.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(
dot_r.center(),
4.0,
role_color,
);
ui.label(
RichText::new(&member.role_name)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
ui.label(
RichText::new(format!(
"· {}…",
&member.user_id[..8.min(member.user_id.len())]
))
.font(FontId::monospace(10.0))
.color(t::TEXT_DISABLED),
);
});
});
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
// Role-assign combo (only shows if user has roles defined).
if !roles_clone.is_empty() && !member.is_owner {
let combo_text = "+ role".to_string();
egui::ComboBox::from_id_salt(format!(
"role_combo_{}",
member.user_id
))
.selected_text(combo_text)
.width(120.0)
.show_ui(ui, |ui| {
for role in &roles_clone {
if ui
.add(
egui::Button::new(
RichText::new(&role.name)
.font(FontId::proportional(
11.0,
))
.color(
parse_hex_color(
&role.color,
)
.unwrap_or(
t::TEXT_PRIMARY,
),
),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
let net = net.clone();
let gid = guild_id.clone();
let uid = member.user_id.clone();
let rid = role.id.clone();
tokio::spawn(async move {
net.send(
NetCommand::GuildRoleAssign {
guild_id: gid,
user_id: uid,
role_id: rid,
},
)
.await;
});
}
}
});
}
// Kick button (hidden for owner).
if !member.is_owner
&& t::icon_btn_drawn(
ui,
vec_icons::boot,
"kick (admins only)",
Some(t::ERROR),
18.0,
)
.clicked()
{
let net_kick = net.clone();
let net_refresh = net.clone();
let gid = guild_id.clone();
let uid = member.user_id.clone();
tokio::spawn(async move {
net_kick
.send(NetCommand::GuildMemberKick {
guild_id: gid.clone(),
user_id: uid,
})
.await;
tokio::time::sleep(
std::time::Duration::from_millis(200),
)
.await;
net_refresh
.send(NetCommand::GuildMemberListFetch {
guild_id: gid,
})
.await;
});
}
},
);
});
});
ui.add_space(2.0);
}
});
});
}
fn parse_hex_color(hex: &str) -> Option<egui::Color32> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(egui::Color32::from_rgb(r, g, b))
}

94
src/ui/sidebar/mod.rs Normal file
View file

@ -0,0 +1,94 @@
//! Unified left sidebar (design from aiupdate).
mod channel_list;
mod guild_bar;
mod sections;
pub use guild_bar::show as guild_bar;
use eframe::egui::{self, Context, Frame, SidePanel};
use vnox_client::{identity::Identity, net::NetHandle};
use crate::{
connect,
state::{ConnState, UiState},
theme as t,
};
pub fn show(ctx: &Context, s: &mut UiState, identity: &Identity, net: &NetHandle) {
SidePanel::left("sidebar")
.exact_width(220.0)
.resizable(false)
.frame(Frame::NONE.fill(t::BG_SURFACE))
.show_separator_line(false)
.show(ctx, |ui| {
let r = ui.max_rect();
ui.painter().vline(
r.right(),
r.y_range(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
sections::header::show(ui, s);
bookmark_row(ui, s, net);
let userbar_h = 52.0;
let avail = (ui.available_height() - userbar_h).max(0.0);
egui::ScrollArea::vertical()
.max_height(avail)
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(6.0);
channel_list::show(ui, s, net);
ui.add_space(4.0);
sections::dm::show(ui, s, net);
ui.add_space(6.0);
});
sections::userbar::show(ui, s, identity, net);
});
});
}
fn bookmark_row(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
let busy = matches!(
s.conn,
ConnState::Connecting | ConnState::Reconnecting { .. }
);
if s.bookmarks.is_empty() {
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
ui.add_space(8.0);
for (i, bm) in s.bookmarks.clone().into_iter().enumerate() {
let active = s.selected_bookmark == i;
let label = bm.label.chars().take(8).collect::<String>();
let btn = egui::Button::new(
egui::RichText::new(label)
.font(egui::FontId::proportional(10.0))
.color(if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
}),
)
.fill(if active { t::ACCENT_10 } else { t::BG_ELEVATED })
.stroke(egui::Stroke::new(
1.0,
if active {
t::BORDER_ACCENT
} else {
t::BORDER_DEFAULT
},
))
.corner_radius(t::R_SM);
if ui.add(btn).on_hover_text(&bm.address).clicked() && !busy {
s.selected_bookmark = i;
connect::connect_to(s, net, &bm.address);
}
}
});
ui.add_space(4.0);
}

View file

@ -0,0 +1,170 @@
use eframe::egui::{self, Frame};
use vnox_client::net::{NetCommand, NetHandle};
use crate::state::{ConnState, UiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
if !matches!(s.conn, ConnState::Connected { .. }) {
return;
}
Frame::NONE
.inner_margin(egui::Margin {
left: 12,
right: 8,
top: 2,
bottom: 2,
})
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("DIRECT MESSAGES")
.font(egui::FontId::proportional(10.0))
.color(t::TEXT_MUTED),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
egui::RichText::new("+")
.font(egui::FontId::proportional(12.0))
.color(t::ACCENT),
)
.frame(false)
.min_size(egui::vec2(16.0, 16.0)),
)
.on_hover_text("new DM")
.clicked()
{
s.new_dm_input = Some(String::new());
}
});
});
});
new_dm_input(ui, s, net);
for conv in s.dm_conversations.clone() {
let active = s.active_dm_id.as_deref() == Some(&conv.dm_id);
let bg = if active {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
};
let stroke = if active {
egui::Stroke::new(1.0, t::BORDER_ACCENT)
} else {
egui::Stroke::NONE
};
let text_c = if active {
t::ACCENT_SOFT
} else {
t::TEXT_MUTED
};
let resp = Frame::NONE
.fill(bg)
.stroke(stroke)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(8, 3))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("@").color(text_c).size(13.0));
ui.label(
egui::RichText::new(&conv.other_nickname)
.color(text_c)
.size(13.0),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if conv.unread_count > 0 {
let text = if conv.unread_count > 99 {
"99+".to_string()
} else {
conv.unread_count.to_string()
};
let (r, _) = ui
.allocate_exact_size(egui::vec2(18.0, 14.0), egui::Sense::hover());
ui.painter()
.rect_filled(r, egui::CornerRadius::same(4), t::ACCENT);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
&text,
egui::FontId::monospace(10.0),
egui::Color32::WHITE,
);
}
});
});
})
.response
.interact(egui::Sense::click());
if resp.clicked() {
let clicked_dm_id = conv.dm_id.clone();
s.active_dm_id = Some(clicked_dm_id.clone());
if let Some(c) = s
.dm_conversations
.iter_mut()
.find(|c| c.dm_id == conv.dm_id)
{
c.unread_count = 0;
}
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::DmReadAck {
dm_id: clicked_dm_id,
})
.await;
});
}
}
}
pub fn new_dm_input(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
let mut input_buf = s.new_dm_input.clone().unwrap_or_default();
let mut should_start = false;
if s.new_dm_input.is_some() {
Frame::NONE
.inner_margin(egui::Margin::symmetric(12, 2))
.show(ui, |ui| {
ui.horizontal(|ui| {
let resp = ui.add_sized(
egui::vec2(120.0, 20.0),
egui::TextEdit::singleline(&mut input_buf)
.font(egui::FontId::proportional(11.0))
.text_color(t::TEXT_PRIMARY)
.hint_text(egui::RichText::new("user id").color(t::TEXT_DISABLED))
.frame(true),
);
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|| ui
.add(
egui::Button::new(
egui::RichText::new("Start")
.font(egui::FontId::proportional(10.0))
.color(t::ACCENT),
)
.fill(t::ACCENT_10)
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
.corner_radius(t::R_SM)
.min_size(egui::vec2(40.0, 18.0)),
)
.clicked()
&& !input_buf.trim().is_empty()
{
should_start = true;
}
});
});
}
if should_start {
let target = input_buf.trim().to_string();
let net = net.clone();
tokio::spawn(async move {
net.send(NetCommand::DmStart {
target_user_id: target,
})
.await;
});
s.new_dm_input = None;
}
}

View file

@ -0,0 +1,95 @@
use eframe::egui::{self, Frame};
use crate::state::{ConnState, UiState};
use crate::theme as t;
pub fn show(ui: &mut egui::Ui, s: &UiState) {
let title = if let Some(gid) = &s.active_guild_id
&& let Some(g) = s.guilds.iter().find(|g| &g.guild_id == gid)
{
g.name.clone()
} else {
match &s.conn {
ConnState::Connected { node_name, .. } => node_name.clone(),
ConnState::Reconnecting { attempt } => format!("reconnect #{attempt}"),
ConnState::Connecting => "connecting…".into(),
ConnState::Disconnected => "not connected".into(),
}
};
let is_guild = s.active_guild_id.is_some();
Frame::NONE
.fill(t::BG_SURFACE)
.inner_margin(egui::Margin::symmetric(12, 0))
.show(ui, |ui| {
ui.set_height(38.0);
ui.horizontal_centered(|ui| {
if is_guild {
let (r, _) =
ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(r, t::R_FULL, t::ACCENT);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
title
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string(),
egui::FontId::monospace(11.0),
egui::Color32::WHITE,
);
} else {
let (r, _) =
ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(r, t::R_SM, t::ACCENT_20);
ui.painter().rect_stroke(
r,
t::R_SM,
egui::Stroke::new(1.0, t::BORDER_ACCENT),
egui::StrokeKind::Inside,
);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
"V",
egui::FontId::monospace(11.0),
t::TEXT_PRIMARY,
);
}
ui.add_space(8.0);
ui.label(
egui::RichText::new(title)
.font(egui::FontId::proportional(13.0))
.color(t::TEXT_PRIMARY)
.strong(),
);
if s.private_mode {
ui.add_space(6.0);
Frame::NONE
.fill(t::WARNING)
.corner_radius(t::R_SM)
.inner_margin(egui::Margin::symmetric(4, 1))
.show(ui, |ui| {
ui.label(
egui::RichText::new("PRIVATE")
.font(egui::FontId::monospace(8.0))
.color(egui::Color32::from_rgb(15, 15, 15))
.strong(),
);
});
}
});
});
let sep = ui.max_rect();
ui.painter().hline(
sep.x_range(),
sep.top() + 38.0,
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
}

View file

@ -0,0 +1,3 @@
pub mod dm;
pub mod header;
pub mod userbar;

View file

@ -0,0 +1,281 @@
use eframe::egui::{self, FontId, Frame, RichText};
use vnox_client::{
identity::Identity,
net::{NetCommand, NetHandle},
};
use crate::state::UiState;
use crate::theme as t;
use crate::theme::icons;
pub fn show(ui: &mut egui::Ui, s: &mut UiState, identity: &Identity, net: &NetHandle) {
let r = ui.available_rect_before_wrap();
ui.painter().hline(
r.x_range(),
r.top(),
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
);
Frame::NONE
.fill(t::BG_ELEVATED)
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.horizontal(|ui| {
let init = identity
.nickname
.chars()
.next()
.unwrap_or('U')
.to_uppercase()
.to_string();
t::avatar(
ui,
&init,
avatar_color_for(&identity.nickname, s.local_speaking),
30.0,
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(&identity.nickname)
.font(egui::FontId::proportional(12.5))
.color(if s.local_speaking {
t::SUCCESS
} else {
t::TEXT_PRIMARY
})
.strong(),
);
if s.local_speaking {
let (r, _) = ui
.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
icons::speaker(ui.painter(), r, t::SUCCESS);
}
});
// Status text — shows voice state, custom status, or activity.
let status_text = if let Some(ch) = s.active_voice_channel() {
if s.remote_speaking {
format!("in voice · {} · talking", ch.name)
} else {
format!("in voice · {}", ch.name)
}
} else if let Some(ref custom) = s.custom_status {
custom.clone()
} else if let (Some(_kind), Some(text)) = (&s.activity_type, &s.activity_text) {
text.clone()
} else if s.overlay_latency {
match s.rtt_ms {
Some(ms) => format!("online · {ms}ms"),
None => "online".into(),
}
} else {
"online".into()
};
let status_label = ui.add(
egui::Button::new(
RichText::new(status_text)
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
);
status_label.context_menu(|ui| {
status_picker(ui, s, net);
ui.separator();
custom_status_editor(ui, s, net);
ui.separator();
activity_editor(ui, s, net);
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 1.0;
if t::icon_btn_drawn(ui, icons::gear, "settings", None, 22.0).clicked() {
s.settings_open = true;
}
let deaf_c = if s.deafen { Some(t::ERROR) } else { None };
let deaf_icon = if s.deafen {
icons::headphones_off
} else {
icons::headphones
};
if t::icon_btn_drawn(ui, deaf_icon, "deafen", deaf_c, 22.0).clicked() {
s.deafen = !s.deafen;
}
let mic_icon = if s.mic_enabled {
icons::microphone
} else {
icons::microphone_off
};
let mic_c = if !s.mic_enabled {
Some(t::ERROR)
} else if s.local_speaking {
Some(t::SUCCESS)
} else {
None
};
if t::icon_btn_drawn(ui, mic_icon, "microphone", mic_c, 22.0).clicked() {
s.mic_enabled = !s.mic_enabled;
}
});
});
});
}
fn status_picker(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Status")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let options = [
("online", "Online"),
("idle", "Idle"),
("dnd", "Do Not Disturb"),
("invisible", "Invisible"),
];
for (key, label) in options {
let selected = s.presence_status.as_deref() == Some(key);
let btn = egui::Button::new(RichText::new(label).font(FontId::proportional(12.0)))
.fill(if selected {
t::ACCENT_10
} else {
egui::Color32::TRANSPARENT
})
.frame(false);
if ui.add(btn).clicked() {
s.presence_status = Some(key.to_string());
send_presence(s, net);
ui.close();
}
}
}
fn custom_status_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Custom status")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let mut buf = s.custom_status.clone().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.font(FontId::proportional(12.0))
.hint_text("what's on your mind?")
.desired_width(180.0)
.text_color(t::TEXT_PRIMARY),
);
if resp.changed() {
s.custom_status = if buf.trim().is_empty() {
None
} else {
Some(buf)
};
}
if resp.lost_focus() {
send_presence(s, net);
}
if ui
.add(
egui::Button::new(
RichText::new("Clear")
.font(FontId::proportional(11.0))
.color(t::TEXT_MUTED),
)
.fill(egui::Color32::TRANSPARENT)
.frame(false),
)
.clicked()
{
s.custom_status = None;
send_presence(s, net);
ui.close();
}
}
fn activity_editor(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
ui.label(
RichText::new("Activity")
.font(FontId::proportional(10.0))
.color(t::TEXT_MUTED)
.strong(),
);
let kinds = [
("playing", "Playing"),
("listening", "Listening"),
("watching", "Watching"),
("streaming", "Streaming"),
];
let mut current_kind = s.activity_type.clone();
let combo = egui::ComboBox::from_id_salt("activity_kind")
.selected_text(current_kind.clone().unwrap_or_else(|| "— none —".into()))
.width(160.0);
combo.show_ui(ui, |ui| {
for (k, label) in kinds {
ui.selectable_value(&mut current_kind, Some(k.to_string()), label);
}
ui.selectable_value(&mut current_kind, None, "— none —");
});
let mut buf = s.activity_text.clone().unwrap_or_default();
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.font(FontId::proportional(12.0))
.hint_text("doing what?")
.desired_width(180.0)
.text_color(t::TEXT_PRIMARY),
);
let mut changed = false;
if resp.changed() {
s.activity_text = if buf.trim().is_empty() {
None
} else {
Some(buf)
};
changed = true;
}
if current_kind != s.activity_type {
s.activity_type = current_kind;
changed = true;
}
if changed && (resp.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter))) {
send_presence(s, net);
}
}
fn send_presence(s: &UiState, net: &NetHandle) {
let net = net.clone();
let status = s.presence_status.clone().unwrap_or_else(|| "online".into());
let custom = s.custom_status.clone();
let activity = s.activity_type.clone();
let activity_text = s.activity_text.clone();
tokio::spawn(async move {
net.send(NetCommand::PresenceUpdate {
status,
custom_status: custom,
activity,
activity_text,
})
.await;
});
}
fn avatar_color_for(nick: &str, speaking: bool) -> egui::Color32 {
if speaking {
return t::SUCCESS;
}
let colors = [
t::ACCENT_10,
egui::Color32::from_rgb(50, 70, 100),
egui::Color32::from_rgb(60, 80, 50),
egui::Color32::from_rgb(80, 60, 80),
];
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
}