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:
commit
bf0b237afd
112 changed files with 19113 additions and 0 deletions
142
src/ui/sidebar/channel_list/items.rs
Normal file
142
src/ui/sidebar/channel_list/items.rs
Normal 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()]
|
||||
}
|
||||
327
src/ui/sidebar/channel_list/mod.rs
Normal file
327
src/ui/sidebar/channel_list/mod.rs
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
82
src/ui/sidebar/guild_bar/icons.rs
Normal file
82
src/ui/sidebar/guild_bar/icons.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
685
src/ui/sidebar/guild_bar/mod.rs
Normal file
685
src/ui/sidebar/guild_bar/mod.rs
Normal 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
94
src/ui/sidebar/mod.rs
Normal 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);
|
||||
}
|
||||
170
src/ui/sidebar/sections/dm.rs
Normal file
170
src/ui/sidebar/sections/dm.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
95
src/ui/sidebar/sections/header.rs
Normal file
95
src/ui/sidebar/sections/header.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
3
src/ui/sidebar/sections/mod.rs
Normal file
3
src/ui/sidebar/sections/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod dm;
|
||||
pub mod header;
|
||||
pub mod userbar;
|
||||
281
src/ui/sidebar/sections/userbar.rs
Normal file
281
src/ui/sidebar/sections/userbar.rs
Normal 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()]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue