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
29
.cargo/config.toml
Normal file
29
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[env]
|
||||
# audiopus_sys builds opus from source via CMake.
|
||||
# CMake 4.x removed compatibility with cmake_minimum_required < 3.5.
|
||||
# This flag tells CMake to accept the old policy version.
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
# Cap linker+rustc memory pressure so builds don't OOM-kill the host.
|
||||
RUST_MIN_STACK = "8388608"
|
||||
|
||||
[build]
|
||||
# Limit parallelism so GPUI's giant dep tree doesn't saturate all cores.
|
||||
# 4 jobs is a safe-ish ceiling for a 15GB / 24-thread box.
|
||||
jobs = 6
|
||||
|
||||
[profile.dev]
|
||||
# Less debug info = less RAM per codegen unit, faster cold builds.
|
||||
debug = 1
|
||||
# More codegen units = smaller per-unit memory footprint.
|
||||
codegen-units = 16
|
||||
# Don't keep intermediate artifacts for every dep.
|
||||
split-debuginfo = "unpacked"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
# Deps don't need debuginfo at all → big savings on big trees like GPUI.
|
||||
debug = 0
|
||||
codegen-units = 16
|
||||
|
||||
[profile.release]
|
||||
debug = 0
|
||||
codegen-units = 16
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
target/
|
||||
.DS_Store
|
||||
5587
Cargo.lock
generated
Normal file
5587
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
52
Cargo.toml
Normal file
52
Cargo.toml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "vnox-client"
|
||||
description = "VNOX desktop client — egui + wgpu"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0"
|
||||
authors = ["VNOX Contributors"]
|
||||
repository = "https://github.com/loki5512344/Vnox"
|
||||
rust-version = "1.85"
|
||||
default-run = "vnox-client"
|
||||
|
||||
[lib]
|
||||
name = "vnox_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "vnox-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "vnox-e2e-voice"
|
||||
path = "src/bin/e2e_voice.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.52", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
hkdf = "0.13"
|
||||
sha2 = "0.11"
|
||||
rand = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
anyhow = "1"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
argon2 = "0.5"
|
||||
opus = "0.3"
|
||||
cpal = "0.18"
|
||||
rodio = "0.22"
|
||||
dirs = "6"
|
||||
eframe = { version = "0.33", features = ["wgpu"] }
|
||||
nnnoiseless = { version = "0.5.2", default-features = false, optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
rnnoise = ["dep:nnnoiseless"]
|
||||
141
src/audio/config.rs
Normal file
141
src/audio/config.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::{SampleFormat, StreamConfig};
|
||||
use tracing::info;
|
||||
|
||||
pub const OPUS_RATE: u32 = 48_000;
|
||||
pub const FRAME_MS: u32 = 20;
|
||||
|
||||
/// Maps settings `packet_interval` index to frame duration in ms.
|
||||
pub fn frame_ms_from_interval(interval: usize) -> u32 {
|
||||
match interval {
|
||||
0 => 10,
|
||||
1 => 20,
|
||||
_ => 40,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps settings `jitter_size` index to playout delay in ms.
|
||||
pub fn jitter_ms_from_setting(jitter_size: usize) -> u32 {
|
||||
match jitter_size {
|
||||
0 => 20,
|
||||
1 => 40,
|
||||
_ => 80,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bitrate_bps_from_kbps(kbps: f32) -> u32 {
|
||||
(kbps.clamp(8.0, 512.0) * 1000.0) as u32
|
||||
}
|
||||
|
||||
pub struct AudioConfig {
|
||||
pub stream: StreamConfig,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
pub fn find(device: &cpal::Device, is_input: bool) -> Result<Self> {
|
||||
let ranges: Vec<_> = if is_input {
|
||||
device.supported_input_configs()?.collect()
|
||||
} else {
|
||||
device.supported_output_configs()?.collect()
|
||||
};
|
||||
|
||||
// Prefer 48 kHz mono F32 — matches Opus native rate.
|
||||
for r in &ranges {
|
||||
if r.channels() == 1
|
||||
&& r.sample_format() == SampleFormat::F32
|
||||
&& r.min_sample_rate() <= OPUS_RATE
|
||||
&& r.max_sample_rate() >= OPUS_RATE
|
||||
{
|
||||
let stream = r.with_sample_rate(OPUS_RATE).config();
|
||||
info!(
|
||||
"audio {}: {} Hz mono",
|
||||
if is_input { "in" } else { "out" },
|
||||
OPUS_RATE
|
||||
);
|
||||
return Ok(Self {
|
||||
stream,
|
||||
sample_rate: OPUS_RATE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: device native rate, resample to/from Opus in capture/playback.
|
||||
let default = if is_input {
|
||||
device.default_input_config()
|
||||
} else {
|
||||
device.default_output_config()
|
||||
}
|
||||
.map_err(|e| anyhow!("no default audio config: {e}"))?;
|
||||
|
||||
let sample_rate = default.sample_rate();
|
||||
let stream: StreamConfig = default.config();
|
||||
info!(
|
||||
"audio {}: {} Hz {}ch (will resample for Opus)",
|
||||
if is_input { "in" } else { "out" },
|
||||
sample_rate,
|
||||
stream.channels
|
||||
);
|
||||
Ok(Self {
|
||||
stream,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_samples(sample_rate: u32) -> usize {
|
||||
(sample_rate as usize * FRAME_MS as usize) / 1000
|
||||
}
|
||||
|
||||
/// Linear resample mono F32 buffer to target sample count.
|
||||
pub fn resample_linear(input: &[f32], out_len: usize) -> Vec<f32> {
|
||||
if input.is_empty() {
|
||||
return vec![0.0; out_len];
|
||||
}
|
||||
if input.len() == out_len {
|
||||
return input.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for i in 0..out_len {
|
||||
let src = i as f64 * (input.len() - 1) as f64 / (out_len - 1).max(1) as f64;
|
||||
let lo = src.floor() as usize;
|
||||
let hi = (lo + 1).min(input.len() - 1);
|
||||
let t = src - lo as f64;
|
||||
out.push(input[lo] * (1.0 - t) as f32 + input[hi] * t as f32);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resample between two sample rates (mono).
|
||||
pub fn resample_rate(input: &[f32], from_hz: u32, to_hz: u32) -> Vec<f32> {
|
||||
if from_hz == to_hz {
|
||||
return input.to_vec();
|
||||
}
|
||||
let out_len = ((input.len() as u64 * to_hz as u64) / from_hz as u64) as usize;
|
||||
resample_linear(input, out_len.max(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{bitrate_bps_from_kbps, frame_ms_from_interval, jitter_ms_from_setting};
|
||||
|
||||
#[test]
|
||||
fn jitter_size_maps_to_ms() {
|
||||
assert_eq!(jitter_ms_from_setting(0), 20);
|
||||
assert_eq!(jitter_ms_from_setting(1), 40);
|
||||
assert_eq!(jitter_ms_from_setting(2), 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_interval_maps_to_frame_ms() {
|
||||
assert_eq!(frame_ms_from_interval(0), 10);
|
||||
assert_eq!(frame_ms_from_interval(1), 20);
|
||||
assert_eq!(frame_ms_from_interval(2), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_kbps_to_bps() {
|
||||
assert_eq!(bitrate_bps_from_kbps(64.0), 64_000);
|
||||
}
|
||||
}
|
||||
42
src/audio/devices/input.rs
Normal file
42
src/audio/devices/input.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use cpal::Device;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
pub fn input_at(index: usize) -> Result<Device> {
|
||||
let host = cpal::default_host();
|
||||
if index == 0 {
|
||||
return host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| anyhow!("no default input device"));
|
||||
}
|
||||
|
||||
let devices: Vec<Device> = host.input_devices()?.collect();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for dev in &devices {
|
||||
if let Ok(name) = dev.description().map(|d| d.name().to_string())
|
||||
&& !name.is_empty()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
let name = names
|
||||
.get(index.saturating_sub(1))
|
||||
.ok_or_else(|| anyhow!("input device index {index} out of range"))?;
|
||||
|
||||
for dev in devices {
|
||||
if dev
|
||||
.description()
|
||||
.map(|d| d.name().to_string())
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(name.as_str())
|
||||
{
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("input device not found: {name}"))
|
||||
}
|
||||
120
src/audio/devices/mod.rs
Normal file
120
src/audio/devices/mod.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
mod input;
|
||||
mod output;
|
||||
|
||||
pub use input::input_at;
|
||||
pub use output::output_at;
|
||||
|
||||
use anyhow::Result;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
const DEFAULT_LABEL: &str = "Default";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceLabel {
|
||||
pub display: String,
|
||||
pub raw_name: String,
|
||||
pub host_api: String,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceLists {
|
||||
pub input_labels: Vec<String>,
|
||||
pub output_labels: Vec<String>,
|
||||
pub input_devices: Vec<DeviceLabel>,
|
||||
pub output_devices: Vec<DeviceLabel>,
|
||||
}
|
||||
|
||||
pub fn enumerate() -> Result<DeviceLists> {
|
||||
let host = cpal::default_host();
|
||||
let host_api = host.id().name().to_string();
|
||||
let (input_labels, input_devices) = list_labels(host.input_devices()?, &host_api);
|
||||
let (output_labels, output_devices) = list_labels(host.output_devices()?, &host_api);
|
||||
Ok(DeviceLists {
|
||||
input_labels,
|
||||
output_labels,
|
||||
input_devices,
|
||||
output_devices,
|
||||
})
|
||||
}
|
||||
|
||||
fn list_labels(
|
||||
devices: impl Iterator<Item = cpal::Device>,
|
||||
host_api: &str,
|
||||
) -> (Vec<String>, Vec<DeviceLabel>) {
|
||||
let mut labels = vec![DEFAULT_LABEL.into()];
|
||||
let mut metadatas = vec![DeviceLabel {
|
||||
display: DEFAULT_LABEL.into(),
|
||||
raw_name: String::new(),
|
||||
host_api: host_api.into(),
|
||||
channels: 0,
|
||||
}];
|
||||
for dev in devices {
|
||||
let Ok(name) = dev.description().map(|d| d.name().to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let channels = supported_channel_count(&dev);
|
||||
let display = format!("{} ({})", name, host_api);
|
||||
if metadatas.iter().any(|m| m.raw_name == name) {
|
||||
continue;
|
||||
}
|
||||
labels.push(display.clone());
|
||||
metadatas.push(DeviceLabel {
|
||||
display,
|
||||
raw_name: name,
|
||||
host_api: host_api.into(),
|
||||
channels,
|
||||
});
|
||||
}
|
||||
(labels, metadatas)
|
||||
}
|
||||
|
||||
fn supported_channel_count(dev: &cpal::Device) -> u16 {
|
||||
if let Ok(cfg) = dev.default_input_config() {
|
||||
return cfg.channels();
|
||||
}
|
||||
if let Ok(cfg) = dev.default_output_config() {
|
||||
return cfg.channels();
|
||||
}
|
||||
if let Ok(configs) = dev.supported_input_configs()
|
||||
&& let Some(cfg) = configs.into_iter().next()
|
||||
{
|
||||
return cfg.channels();
|
||||
}
|
||||
if let Ok(configs) = dev.supported_output_configs()
|
||||
&& let Some(cfg) = configs.into_iter().next()
|
||||
{
|
||||
return cfg.channels();
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn default_is_first_label() {
|
||||
let lists = enumerate().unwrap_or_default();
|
||||
if !lists.input_labels.is_empty() {
|
||||
assert_eq!(lists.input_labels[0], "Default");
|
||||
}
|
||||
if !lists.output_labels.is_empty() {
|
||||
assert_eq!(lists.output_labels[0], "Default");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn default_meta_is_sentinel() {
|
||||
let lists = enumerate().unwrap_or_default();
|
||||
assert_eq!(
|
||||
lists.input_devices.first().map(|m| m.raw_name.as_str()),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
lists.output_devices.first().map(|m| m.raw_name.as_str()),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/audio/devices/output.rs
Normal file
42
src/audio/devices/output.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use cpal::Device;
|
||||
use cpal::traits::{DeviceTrait, HostTrait};
|
||||
|
||||
pub fn output_at(index: usize) -> Result<Device> {
|
||||
let host = cpal::default_host();
|
||||
if index == 0 {
|
||||
return host
|
||||
.default_output_device()
|
||||
.ok_or_else(|| anyhow!("no default output device"));
|
||||
}
|
||||
|
||||
let devices: Vec<Device> = host.output_devices()?.collect();
|
||||
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for dev in &devices {
|
||||
if let Ok(name) = dev.description().map(|d| d.name().to_string())
|
||||
&& !name.is_empty()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
let name = names
|
||||
.get(index.saturating_sub(1))
|
||||
.ok_or_else(|| anyhow!("output device index {index} out of range"))?;
|
||||
|
||||
for dev in devices {
|
||||
if dev
|
||||
.description()
|
||||
.map(|d| d.name().to_string())
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(name.as_str())
|
||||
{
|
||||
return Ok(dev);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("output device not found: {name}"))
|
||||
}
|
||||
60
src/audio/mod.rs
Normal file
60
src/audio/mod.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
pub mod config;
|
||||
pub mod devices;
|
||||
mod pipeline;
|
||||
mod processing;
|
||||
|
||||
pub use devices::{DeviceLabel, DeviceLists};
|
||||
pub use pipeline::AudioPipeline;
|
||||
pub use pipeline::JitterConfig;
|
||||
pub use processing::{DenoiseState, TransmitGate};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AudioSync {
|
||||
pub mic_enabled: bool,
|
||||
pub vad_mode: usize,
|
||||
pub vad_threshold: f32,
|
||||
pub input_volume: f32,
|
||||
pub ptt_held: bool,
|
||||
pub output_volume: f32,
|
||||
pub jitter_size: usize,
|
||||
pub adaptive_buffer: bool,
|
||||
pub voice_bitrate_kbps: f32,
|
||||
pub noise_suppress: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PipelineConfig {
|
||||
pub channel_key: u64,
|
||||
pub mic_device_index: usize,
|
||||
pub output_device_index: usize,
|
||||
pub voice_bitrate_kbps: f32,
|
||||
pub packet_interval: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for PipelineConfig {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.channel_key == other.channel_key
|
||||
&& self.mic_device_index == other.mic_device_index
|
||||
&& self.output_device_index == other.output_device_index
|
||||
&& self.packet_interval == other.packet_interval
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingVoice {
|
||||
pub voice_seq: u32,
|
||||
pub timestamp: u32,
|
||||
pub channel_id: u64,
|
||||
pub opus_data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct EncodedFrame {
|
||||
pub channel_id: u64,
|
||||
pub voice_seq: u32,
|
||||
pub timestamp: u32,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn start(cfg: PipelineConfig) -> anyhow::Result<AudioPipeline> {
|
||||
pipeline::start(cfg)
|
||||
}
|
||||
148
src/audio/pipeline/capture.rs
Normal file
148
src/audio/pipeline/capture.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use anyhow::Result;
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::audio::{
|
||||
EncodedFrame,
|
||||
config::{self, AudioConfig, OPUS_RATE},
|
||||
processing::{DenoiseState, TransmitGate},
|
||||
};
|
||||
|
||||
const MAX_OPUS: usize = 4000;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
device: cpal::Device,
|
||||
cfg: AudioConfig,
|
||||
channel_id: u64,
|
||||
tx: std::sync::mpsc::Sender<EncodedFrame>,
|
||||
gate: Arc<Mutex<TransmitGate>>,
|
||||
frame_ms: u32,
|
||||
bitrate_bps: Arc<AtomicU32>,
|
||||
local_speaking: Arc<AtomicBool>,
|
||||
) -> Result<()> {
|
||||
let mut encoder = opus::Encoder::new(OPUS_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
||||
let mut last_bitrate_bps: u32 = 0;
|
||||
apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?;
|
||||
|
||||
let mut denoise = DenoiseState::new()?;
|
||||
|
||||
let capture_frame = frame_samples_at(cfg.sample_rate, frame_ms);
|
||||
let opus_frame = frame_samples_at(OPUS_RATE, frame_ms);
|
||||
let needs_resample = cfg.sample_rate != OPUS_RATE;
|
||||
let in_channels = cfg.stream.channels as usize;
|
||||
|
||||
let buf: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let buf2 = buf.clone();
|
||||
let mut voice_seq: u32 = 0;
|
||||
let mut timestamp: u32 = 0;
|
||||
|
||||
let stream = device.build_input_stream(
|
||||
cfg.stream,
|
||||
move |data: &[f32], _| {
|
||||
let mut b = buf2.lock().unwrap();
|
||||
if in_channels <= 1 {
|
||||
b.extend_from_slice(data);
|
||||
} else {
|
||||
for frame in data.chunks(in_channels) {
|
||||
let sum: f32 = frame.iter().sum();
|
||||
b.push(sum / in_channels as f32);
|
||||
}
|
||||
}
|
||||
},
|
||||
|e| tracing::error!("input: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
info!("capture started ({} Hz)", cfg.sample_rate);
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let mut b = buf.lock().unwrap();
|
||||
while b.len() >= capture_frame {
|
||||
let raw: Vec<f32> = b.drain(..capture_frame).collect();
|
||||
drop(b);
|
||||
|
||||
let frame = if needs_resample {
|
||||
config::resample_rate(&raw, cfg.sample_rate, OPUS_RATE)
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let mut pcm = if frame.len() >= opus_frame {
|
||||
frame[..opus_frame].to_vec()
|
||||
} else {
|
||||
let mut padded = frame;
|
||||
padded.resize(opus_frame, 0.0);
|
||||
padded
|
||||
};
|
||||
|
||||
let gate_snapshot = gate.lock().unwrap().clone();
|
||||
TransmitGate::apply_input_volume(&mut pcm, gate_snapshot.input_volume);
|
||||
if gate_snapshot.noise_suppress {
|
||||
denoise.process(&mut pcm);
|
||||
}
|
||||
let speaking = TransmitGate::should_transmit(&gate_snapshot, &pcm);
|
||||
local_speaking.store(speaking, Ordering::Relaxed);
|
||||
if !speaking {
|
||||
b = buf.lock().unwrap();
|
||||
continue;
|
||||
}
|
||||
|
||||
apply_bitrate(&mut encoder, &bitrate_bps, &mut last_bitrate_bps)?;
|
||||
|
||||
let mut out = vec![0u8; MAX_OPUS];
|
||||
match encoder.encode_float(&pcm, &mut out) {
|
||||
Ok(n) => {
|
||||
out.truncate(n);
|
||||
if tx
|
||||
.send(EncodedFrame {
|
||||
channel_id,
|
||||
voice_seq,
|
||||
timestamp,
|
||||
data: out,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
voice_seq = voice_seq.wrapping_add(1);
|
||||
timestamp = timestamp.wrapping_add(opus_frame as u32);
|
||||
}
|
||||
Err(e) => warn!("encode: {e}"),
|
||||
}
|
||||
b = buf.lock().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_samples_at(sample_rate: u32, frame_ms: u32) -> usize {
|
||||
(sample_rate as usize * frame_ms as usize) / 1000
|
||||
}
|
||||
|
||||
fn apply_bitrate(
|
||||
encoder: &mut opus::Encoder,
|
||||
bitrate_bps: &AtomicU32,
|
||||
last: &mut u32,
|
||||
) -> Result<()> {
|
||||
let bps = bitrate_bps.load(Ordering::Relaxed).clamp(8_000, 512_000);
|
||||
if bps == *last {
|
||||
return Ok(());
|
||||
}
|
||||
*last = bps;
|
||||
encoder.set_bitrate(opus::Bitrate::Bits(bps as i32))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::frame_samples_at;
|
||||
|
||||
#[test]
|
||||
fn frame_sizes_scale_with_ms() {
|
||||
assert_eq!(frame_samples_at(48_000, 10), 480);
|
||||
assert_eq!(frame_samples_at(48_000, 20), 960);
|
||||
assert_eq!(frame_samples_at(48_000, 40), 1920);
|
||||
}
|
||||
}
|
||||
168
src/audio/pipeline/mod.rs
Normal file
168
src/audio/pipeline/mod.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
mod capture;
|
||||
mod playback;
|
||||
|
||||
use cpal::traits::DeviceTrait;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
|
||||
mpsc::{self, Receiver, Sender},
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use crate::audio::{
|
||||
AudioSync, EncodedFrame, IncomingVoice, PipelineConfig, config, devices,
|
||||
processing::TransmitGate,
|
||||
};
|
||||
|
||||
pub use playback::JitterConfig;
|
||||
|
||||
pub struct AudioPipeline {
|
||||
encoded_rx: Receiver<EncodedFrame>,
|
||||
voice_tx: Sender<IncomingVoice>,
|
||||
_encoded_tx: Sender<EncodedFrame>,
|
||||
gate: Arc<Mutex<TransmitGate>>,
|
||||
output_volume: Arc<Mutex<f32>>,
|
||||
jitter_cfg: Arc<Mutex<JitterConfig>>,
|
||||
bitrate_bps: Arc<AtomicU32>,
|
||||
/// Local user is currently transmitting (PTT held or VAD above threshold and mic enabled).
|
||||
local_speaking: Arc<AtomicBool>,
|
||||
/// UNIX millisecond timestamp of the last voice packet received from a remote peer.
|
||||
last_remote_voice_ms: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl AudioPipeline {
|
||||
pub fn poll_encoded(&self) -> Option<EncodedFrame> {
|
||||
self.encoded_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
pub fn push_voice(&self, packet: IncomingVoice) {
|
||||
let _ = self.voice_tx.send(packet);
|
||||
self.last_remote_voice_ms.store(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns true if the local user is currently speaking / transmitting.
|
||||
pub fn local_speaking(&self) -> bool {
|
||||
self.local_speaking.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Returns true if we received a voice packet from a remote peer within the
|
||||
/// last `within_ms` milliseconds.
|
||||
pub fn remote_speaking(&self, within_ms: u64) -> bool {
|
||||
let last = self.last_remote_voice_ms.load(Ordering::Relaxed);
|
||||
if last == 0 {
|
||||
return false;
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last) <= within_ms
|
||||
}
|
||||
|
||||
pub fn sync_controls(&self, sync: AudioSync) {
|
||||
let mut g = self.gate.lock().unwrap();
|
||||
g.mic_enabled = sync.mic_enabled;
|
||||
g.vad_mode = sync.vad_mode.min(2) as u8;
|
||||
g.vad_threshold = sync.vad_threshold;
|
||||
g.input_volume = sync.input_volume;
|
||||
g.ptt_held = sync.ptt_held;
|
||||
g.noise_suppress = sync.noise_suppress;
|
||||
*self.output_volume.lock().unwrap() = sync.output_volume;
|
||||
*self.jitter_cfg.lock().unwrap() = JitterConfig {
|
||||
target_ms: config::jitter_ms_from_setting(sync.jitter_size),
|
||||
adaptive: sync.adaptive_buffer,
|
||||
};
|
||||
self.bitrate_bps.store(
|
||||
config::bitrate_bps_from_kbps(sync.voice_bitrate_kbps),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(cfg: PipelineConfig) -> anyhow::Result<AudioPipeline> {
|
||||
let input = devices::input_at(cfg.mic_device_index)?;
|
||||
let output = devices::output_at(cfg.output_device_index)?;
|
||||
info!(
|
||||
"audio in: {} out: {}",
|
||||
input
|
||||
.description()
|
||||
.map(|d| d.name().to_owned())
|
||||
.unwrap_or_default(),
|
||||
output
|
||||
.description()
|
||||
.map(|d| d.name().to_owned())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
let in_cfg = config::AudioConfig::find(&input, true)?;
|
||||
let out_cfg = config::AudioConfig::find(&output, false)?;
|
||||
let frame_ms = config::frame_ms_from_interval(cfg.packet_interval);
|
||||
|
||||
let (encoded_tx, encoded_rx) = mpsc::channel::<EncodedFrame>();
|
||||
let (voice_tx, voice_rx) = mpsc::channel::<IncomingVoice>();
|
||||
let gate = Arc::new(Mutex::new(TransmitGate::default()));
|
||||
let gate_cap = gate.clone();
|
||||
let output_volume = Arc::new(Mutex::new(90.0f32));
|
||||
let vol_play = output_volume.clone();
|
||||
let jitter_cfg = Arc::new(Mutex::new(JitterConfig {
|
||||
target_ms: 40,
|
||||
adaptive: true,
|
||||
}));
|
||||
let jitter_play = jitter_cfg.clone();
|
||||
let bitrate_bps = Arc::new(AtomicU32::new(config::bitrate_bps_from_kbps(
|
||||
cfg.voice_bitrate_kbps,
|
||||
)));
|
||||
let bitrate_cap = bitrate_bps.clone();
|
||||
|
||||
let local_speaking = Arc::new(AtomicBool::new(false));
|
||||
let local_speaking_cap = local_speaking.clone();
|
||||
let last_remote_voice_ms = Arc::new(AtomicU64::new(0));
|
||||
let last_remote_voice_play = last_remote_voice_ms.clone();
|
||||
|
||||
let channel_id = cfg.channel_key;
|
||||
let enc_tx = encoded_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = capture::run(
|
||||
input,
|
||||
in_cfg,
|
||||
channel_id,
|
||||
enc_tx,
|
||||
gate_cap,
|
||||
frame_ms,
|
||||
bitrate_cap,
|
||||
local_speaking_cap,
|
||||
) {
|
||||
tracing::error!("capture: {e}");
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = playback::run(
|
||||
output,
|
||||
out_cfg,
|
||||
voice_rx,
|
||||
vol_play,
|
||||
jitter_play,
|
||||
last_remote_voice_play,
|
||||
) {
|
||||
tracing::error!("playback: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AudioPipeline {
|
||||
encoded_rx,
|
||||
voice_tx,
|
||||
_encoded_tx: encoded_tx,
|
||||
gate,
|
||||
output_volume,
|
||||
jitter_cfg,
|
||||
bitrate_bps,
|
||||
local_speaking,
|
||||
last_remote_voice_ms,
|
||||
})
|
||||
}
|
||||
149
src/audio/pipeline/playback.rs
Normal file
149
src/audio/pipeline/playback.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use crate::jitter::{BufferedPacket, JitterBuffer};
|
||||
use anyhow::Result;
|
||||
use cpal::traits::{DeviceTrait, StreamTrait};
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
mpsc::TryRecvError,
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::audio::config::{self, AudioConfig, OPUS_RATE};
|
||||
use crate::audio::IncomingVoice;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JitterConfig {
|
||||
pub target_ms: u32,
|
||||
pub adaptive: bool,
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
device: cpal::Device,
|
||||
cfg: AudioConfig,
|
||||
opus_rx: std::sync::mpsc::Receiver<IncomingVoice>,
|
||||
output_volume: Arc<Mutex<f32>>,
|
||||
jitter_cfg: Arc<Mutex<JitterConfig>>,
|
||||
last_remote_voice_ms: Arc<AtomicU64>,
|
||||
) -> Result<()> {
|
||||
let mut decoder = opus::Decoder::new(OPUS_RATE, opus::Channels::Mono)?;
|
||||
let pb: Arc<Mutex<Vec<f32>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let pb2 = pb.clone();
|
||||
let vol2 = output_volume.clone();
|
||||
let out_channels = cfg.stream.channels as usize;
|
||||
let needs_resample = cfg.sample_rate != OPUS_RATE;
|
||||
let opus_frame = config::frame_samples(OPUS_RATE);
|
||||
|
||||
let stream = device.build_output_stream(
|
||||
cfg.stream,
|
||||
move |out: &mut [f32], _| {
|
||||
let gain = (*vol2.lock().unwrap() / 100.0).clamp(0.0, 2.0);
|
||||
let mut buf = pb2.lock().unwrap();
|
||||
if out_channels == 1 {
|
||||
let n = buf.len().min(out.len());
|
||||
for (o, s) in out[..n].iter_mut().zip(buf[..n].iter()) {
|
||||
*o = s * gain;
|
||||
}
|
||||
buf.drain(..n);
|
||||
out[n..].fill(0.0);
|
||||
} else {
|
||||
let frames = out.len() / out_channels;
|
||||
for i in 0..frames {
|
||||
let sample = if i < buf.len() { buf[i] * gain } else { 0.0 };
|
||||
for ch in 0..out_channels {
|
||||
out[i * out_channels + ch] = sample;
|
||||
}
|
||||
}
|
||||
let drain_n = frames.min(buf.len());
|
||||
buf.drain(..drain_n);
|
||||
}
|
||||
},
|
||||
|e| tracing::error!("output: {e}"),
|
||||
None,
|
||||
)?;
|
||||
stream.play()?;
|
||||
info!("playback started ({} Hz)", cfg.sample_rate);
|
||||
|
||||
let clock = Instant::now();
|
||||
let initial = jitter_cfg.lock().unwrap();
|
||||
let mut jitter = JitterBuffer::new(initial.target_ms, initial.adaptive);
|
||||
drop(initial);
|
||||
let mut stream_open = true;
|
||||
|
||||
while stream_open || !jitter.is_empty() {
|
||||
let cfg_now = jitter_cfg.lock().unwrap().clone();
|
||||
jitter.set_target_ms(cfg_now.target_ms);
|
||||
if jitter.is_adaptive() != cfg_now.adaptive {
|
||||
let target = jitter.target_ms();
|
||||
let now_ms = clock.elapsed().as_millis() as u64;
|
||||
let mut new_jb = JitterBuffer::new(target, cfg_now.adaptive);
|
||||
while let Some(mut pkt) = jitter.pop_ready(now_ms) {
|
||||
pkt.arrived_at = now_ms;
|
||||
new_jb.push(pkt);
|
||||
}
|
||||
jitter = new_jb;
|
||||
tracing::info!(adaptive = cfg_now.adaptive, "jitter: adaptive mode toggled");
|
||||
}
|
||||
loop {
|
||||
match opus_rx.try_recv() {
|
||||
Ok(pkt) => {
|
||||
last_remote_voice_ms.store(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
jitter.push(BufferedPacket {
|
||||
voice_seq: pkt.voice_seq,
|
||||
timestamp: pkt.timestamp,
|
||||
channel_id: pkt.channel_id,
|
||||
opus_data: pkt.opus_data,
|
||||
arrived_at: clock.elapsed().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
stream_open = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let now_ms = clock.elapsed().as_millis() as u64;
|
||||
while let Some(pkt) = jitter.pop_ready(now_ms) {
|
||||
decode_into_playback(
|
||||
&mut decoder,
|
||||
&pkt.opus_data,
|
||||
opus_frame,
|
||||
needs_resample,
|
||||
cfg.sample_rate,
|
||||
&pb,
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_into_playback(
|
||||
decoder: &mut opus::Decoder,
|
||||
data: &[u8],
|
||||
opus_frame: usize,
|
||||
needs_resample: bool,
|
||||
device_rate: u32,
|
||||
pb: &Arc<Mutex<Vec<f32>>>,
|
||||
) {
|
||||
let mut pcm = vec![0f32; opus_frame];
|
||||
match decoder.decode_float(data, &mut pcm, false) {
|
||||
Ok(n) => {
|
||||
pcm.truncate(n);
|
||||
let samples = if needs_resample {
|
||||
config::resample_rate(&pcm, OPUS_RATE, device_rate)
|
||||
} else {
|
||||
pcm
|
||||
};
|
||||
pb.lock().unwrap().extend_from_slice(&samples);
|
||||
}
|
||||
Err(e) => warn!("decode: {e}"),
|
||||
}
|
||||
}
|
||||
142
src/audio/processing/mod.rs
Normal file
142
src/audio/processing/mod.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
mod rnnoise;
|
||||
|
||||
pub use rnnoise::DenoiseState;
|
||||
|
||||
pub struct TransmitGate {
|
||||
pub mic_enabled: bool,
|
||||
pub vad_mode: u8,
|
||||
pub vad_threshold: f32,
|
||||
pub ptt_held: bool,
|
||||
pub input_volume: f32,
|
||||
pub noise_suppress: bool,
|
||||
}
|
||||
|
||||
impl Default for TransmitGate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mic_enabled: true,
|
||||
vad_mode: 0,
|
||||
vad_threshold: 40.0,
|
||||
ptt_held: false,
|
||||
input_volume: 80.0,
|
||||
noise_suppress: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for TransmitGate {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
mic_enabled: self.mic_enabled,
|
||||
vad_mode: self.vad_mode,
|
||||
vad_threshold: self.vad_threshold,
|
||||
ptt_held: self.ptt_held,
|
||||
input_volume: self.input_volume,
|
||||
noise_suppress: self.noise_suppress,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for TransmitGate {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TransmitGate")
|
||||
.field("mic_enabled", &self.mic_enabled)
|
||||
.field("vad_mode", &self.vad_mode)
|
||||
.field("vad_threshold", &self.vad_threshold)
|
||||
.field("ptt_held", &self.ptt_held)
|
||||
.field("input_volume", &self.input_volume)
|
||||
.field("noise_suppress", &self.noise_suppress)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_rms(pcm: &[f32]) -> f32 {
|
||||
if pcm.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_sq: f32 = pcm.iter().map(|s| s * s).sum::<f32>() / pcm.len() as f32;
|
||||
mean_sq.sqrt()
|
||||
}
|
||||
|
||||
fn vad_rms_threshold(ui_threshold: f32) -> f32 {
|
||||
0.002 + (ui_threshold.clamp(0.0, 100.0) / 100.0) * 0.118
|
||||
}
|
||||
|
||||
impl TransmitGate {
|
||||
pub fn apply_input_volume(pcm: &mut [f32], volume_pct: f32) {
|
||||
let gain = (volume_pct / 100.0).clamp(0.0, 2.0);
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
return;
|
||||
}
|
||||
for s in pcm {
|
||||
*s *= gain;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_transmit(&self, pcm: &[f32]) -> bool {
|
||||
if !self.mic_enabled {
|
||||
return false;
|
||||
}
|
||||
match self.vad_mode {
|
||||
2 => true,
|
||||
0 => self.ptt_held,
|
||||
1 => frame_rms(pcm) >= vad_rms_threshold(self.vad_threshold),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn loud_frame() -> Vec<f32> {
|
||||
vec![0.25f32; 960]
|
||||
}
|
||||
|
||||
fn quiet_frame() -> Vec<f32> {
|
||||
vec![0.0001f32; 960]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_disabled_blocks() {
|
||||
let gate = TransmitGate {
|
||||
mic_enabled: false,
|
||||
vad_mode: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!gate.should_transmit(&loud_frame()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_on_when_mic_enabled() {
|
||||
let gate = TransmitGate {
|
||||
vad_mode: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(gate.should_transmit(&quiet_frame()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_requires_held() {
|
||||
let mut gate = TransmitGate {
|
||||
vad_mode: 0,
|
||||
ptt_held: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!gate.should_transmit(&loud_frame()));
|
||||
gate.ptt_held = true;
|
||||
assert!(gate.should_transmit(&loud_frame()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vad_uses_threshold() {
|
||||
let gate = TransmitGate {
|
||||
vad_mode: 1,
|
||||
vad_threshold: 40.0,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!gate.should_transmit(&quiet_frame()));
|
||||
assert!(gate.should_transmit(&loud_frame()));
|
||||
}
|
||||
}
|
||||
46
src/audio/processing/rnnoise.rs
Normal file
46
src/audio/processing/rnnoise.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#[cfg(feature = "rnnoise")]
|
||||
mod imp {
|
||||
use anyhow::Result;
|
||||
|
||||
const FRAME_SIZE: usize = nnnoiseless::DenoiseState::FRAME_SIZE;
|
||||
|
||||
pub struct DenoiseState {
|
||||
inner: Box<nnnoiseless::DenoiseState<'static>>,
|
||||
out_buf: Vec<f32>,
|
||||
}
|
||||
|
||||
impl DenoiseState {
|
||||
pub fn new() -> Result<Self> {
|
||||
let inner = nnnoiseless::DenoiseState::new();
|
||||
Ok(Self {
|
||||
inner,
|
||||
out_buf: vec![0.0; FRAME_SIZE],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn process(&mut self, pcm: &mut [f32]) {
|
||||
for chunk in pcm.chunks_exact_mut(FRAME_SIZE) {
|
||||
let input: Vec<f32> = chunk.iter().map(|&s| s * 32768.0).collect();
|
||||
self.inner.process_frame(&mut self.out_buf, &input);
|
||||
for (out, &denoised) in chunk.iter_mut().zip(self.out_buf.iter()) {
|
||||
*out = (denoised / 32768.0).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rnnoise"))]
|
||||
mod imp {
|
||||
pub struct DenoiseState;
|
||||
|
||||
impl DenoiseState {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub fn process(&mut self, _pcm: &mut [f32]) {}
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::DenoiseState;
|
||||
131
src/bin/e2e_voice.rs
Normal file
131
src/bin/e2e_voice.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! Headless voice path smoke test: two TCP sessions, UDP relay between them.
|
||||
//!
|
||||
//! Requires gateway and voice-node running:
|
||||
//! cargo run -p vnox-gateway -- --config dev/config.toml
|
||||
//! cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tracing::info;
|
||||
use vnox_client::{
|
||||
identity,
|
||||
net::{crypto::SessionCrypto, framing, handshake, payloads::*, voice},
|
||||
};
|
||||
|
||||
const GATEWAY: &str = "127.0.0.1:7600";
|
||||
const VOICE_CHANNEL: &str = "voice";
|
||||
|
||||
struct Session {
|
||||
_stream: TcpStream,
|
||||
_crypto: SessionCrypto,
|
||||
udp: UdpSocket,
|
||||
voice_endpoint: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
async fn connect_client(nickname: &str) -> Result<Session> {
|
||||
let identity = identity::generate(nickname);
|
||||
let mut stream = TcpStream::connect(GATEWAY).await.context("TCP connect")?;
|
||||
let mut seq = 0u32;
|
||||
let (_session_id, _node_name, _private_mode, crypto) =
|
||||
handshake::run(&mut stream, &identity, &mut seq).await?;
|
||||
|
||||
framing::io::write_encrypted(
|
||||
&mut stream,
|
||||
PID_JOIN_CHANNEL,
|
||||
&mut seq,
|
||||
&serde_json::to_vec(&JoinChannelPayload {
|
||||
channel_id: VOICE_CHANNEL.into(),
|
||||
})?,
|
||||
&crypto,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pkt = framing::io::read_encrypted(&mut stream, &crypto).await?;
|
||||
if pkt.packet_id != PID_CHANNEL_STATE {
|
||||
return Err(anyhow!(
|
||||
"expected CHANNEL_STATE, got 0x{:04X}",
|
||||
pkt.packet_id
|
||||
));
|
||||
}
|
||||
let state: ChannelStatePayload = serde_json::from_slice(&pkt.payload)?;
|
||||
let voice_endpoint: std::net::SocketAddr = state
|
||||
.voice_endpoint
|
||||
.parse()
|
||||
.context("parse voice_endpoint")?;
|
||||
|
||||
let udp = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
info!(
|
||||
"{nickname}: voice_endpoint={voice_endpoint} udp={}",
|
||||
udp.local_addr()?
|
||||
);
|
||||
|
||||
Ok(Session {
|
||||
_stream: stream,
|
||||
_crypto: crypto,
|
||||
udp,
|
||||
voice_endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let channel_key = voice::channel_key(VOICE_CHANNEL);
|
||||
let marker = b"vnox-e2e-test-payload";
|
||||
|
||||
// Dummy crypto for the test packet
|
||||
let dummy_shared = [0u8; 32];
|
||||
let dummy_crypto = SessionCrypto::derive(&dummy_shared, "test");
|
||||
let sender_pubkey = [0u8; 32];
|
||||
let packet = voice::build_packet(channel_key, 1, 0, marker, &dummy_crypto, 0, &sender_pubkey);
|
||||
|
||||
let alice = connect_client("alice").await?;
|
||||
let bob = connect_client("bob").await?;
|
||||
|
||||
// Register both clients on the voice node.
|
||||
alice
|
||||
.udp
|
||||
.send_to(&packet, alice.voice_endpoint)
|
||||
.await
|
||||
.context("alice register")?;
|
||||
bob.udp
|
||||
.send_to(&packet, alice.voice_endpoint)
|
||||
.await
|
||||
.context("bob register")?;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
let recv = tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1472];
|
||||
let (len, from) = bob.udp.recv_from(&mut buf).await.context("bob recv")?;
|
||||
if len >= 20 && &buf[20..len] == marker {
|
||||
info!("PASS: bob received relayed packet from {from} ({len} bytes)");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("unexpected UDP payload (len={len})"))
|
||||
}
|
||||
});
|
||||
|
||||
// Alice speaks; voice node should relay to bob.
|
||||
alice
|
||||
.udp
|
||||
.send_to(&packet, alice.voice_endpoint)
|
||||
.await
|
||||
.context("alice send")?;
|
||||
|
||||
info!(
|
||||
"sent voice packet to {} (channel_key={channel_key})",
|
||||
alice.voice_endpoint
|
||||
);
|
||||
|
||||
timeout(Duration::from_secs(5), recv)
|
||||
.await
|
||||
.context("timed out waiting for voice relay")?
|
||||
.context("voice recv task failed")??;
|
||||
|
||||
info!("e2e voice test passed");
|
||||
Ok(())
|
||||
}
|
||||
189
src/identity.rs
Normal file
189
src/identity.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use anyhow::Result;
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::identity_vault::Vault;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Identity {
|
||||
pub nickname: String,
|
||||
/// hex-encoded public key (64 chars)
|
||||
pub pubkey_hex: String,
|
||||
/// hex-encoded private key — never leave device
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub privkey_hex: Option<String>,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Short form of the pubkey for display (first 8 hex chars + …)
|
||||
pub fn short_id(&self) -> String {
|
||||
format!("{}…", &self.pubkey_hex[..8])
|
||||
}
|
||||
|
||||
pub fn signing_key(&self) -> Option<SigningKey> {
|
||||
let hex = self.privkey_hex.as_ref()?;
|
||||
let bytes = hex::decode(hex).ok()?;
|
||||
SigningKey::from_bytes(bytes.as_slice().try_into().ok()?).into()
|
||||
}
|
||||
|
||||
pub fn verifying_key(&self) -> Option<VerifyingKey> {
|
||||
let bytes = hex::decode(&self.pubkey_hex).ok()?;
|
||||
VerifyingKey::from_bytes(bytes.as_slice().try_into().ok()?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load identity from disk or generate a new one.
|
||||
///
|
||||
/// Tries the vault format first (`identity.vault.json`). Falls back to the
|
||||
/// legacy plain `identity.json` for existing installs.
|
||||
///
|
||||
/// If a vault is found and is encrypted, returns
|
||||
/// `LoadError::PassphraseRequired` so the caller can prompt the user.
|
||||
pub fn load_or_generate() -> Result<Identity> {
|
||||
let vault_path = vault_path();
|
||||
let legacy_path = identity_path();
|
||||
|
||||
if vault_path.exists() {
|
||||
let text = std::fs::read_to_string(&vault_path)?;
|
||||
let vault: Vault = serde_json::from_str(&text)?;
|
||||
if vault.is_encrypted() {
|
||||
return Err(anyhow::anyhow!(LoadError::PassphraseRequired {
|
||||
path: vault_path,
|
||||
}));
|
||||
}
|
||||
let inner = vault.open(None)?;
|
||||
let identity: Identity = serde_json::from_str(&inner)?;
|
||||
return Ok(identity);
|
||||
}
|
||||
|
||||
if legacy_path.exists() {
|
||||
let text = std::fs::read_to_string(&legacy_path)?;
|
||||
let identity: Identity = serde_json::from_str(&text)?;
|
||||
return Ok(identity);
|
||||
}
|
||||
|
||||
// Generate new keypair and save as plain vault.
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let identity = Identity {
|
||||
nickname: "user".into(),
|
||||
pubkey_hex: hex::encode(verifying_key.to_bytes()),
|
||||
privkey_hex: Some(hex::encode(signing_key.to_bytes())),
|
||||
};
|
||||
save(&identity, None)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Load an encrypted vault with the given passphrase.
|
||||
pub fn load_with_passphrase(passphrase: &str) -> Result<Identity> {
|
||||
let vault_path = vault_path();
|
||||
let text = std::fs::read_to_string(&vault_path)?;
|
||||
let vault: Vault = serde_json::from_str(&text)?;
|
||||
let inner = vault.open(Some(passphrase))?;
|
||||
let identity: Identity = serde_json::from_str(&inner)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Save identity to disk, optionally encrypting with a passphrase.
|
||||
/// When `passphrase` is `None` or empty, writes a plain vault.
|
||||
pub fn save(identity: &Identity, passphrase: Option<&str>) -> Result<()> {
|
||||
let path = vault_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let inner_json = serde_json::to_string_pretty(identity)?;
|
||||
let vault = Vault::seal(&inner_json, passphrase)?;
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&vault)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if the on-disk vault is encrypted and requires a passphrase.
|
||||
pub fn is_vault_encrypted() -> bool {
|
||||
let path = vault_path();
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
serde_json::from_str::<Vault>(&text)
|
||||
.map(|v| v.is_encrypted())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Ephemeral identity for tests (not persisted).
|
||||
pub fn generate(nickname: &str) -> Identity {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
Identity {
|
||||
nickname: nickname.into(),
|
||||
pubkey_hex: hex::encode(verifying_key.to_bytes()),
|
||||
privkey_hex: Some(hex::encode(signing_key.to_bytes())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Export the identity to a portable keyfile (JSON).
|
||||
///
|
||||
/// When a passphrase is provided, the keyfile is encrypted with Argon2id +
|
||||
/// ChaCha20-Poly1305 (same scheme as the on-disk vault). When no passphrase
|
||||
/// is provided, the keyfile is plain JSON — useful for tests but **not**
|
||||
/// recommended for production use.
|
||||
///
|
||||
/// The returned string can be written to a `.vnoxkey` file or pasted into
|
||||
/// a chat message for transfer.
|
||||
pub fn export_keyfile(identity: &Identity, passphrase: Option<&str>) -> Result<String> {
|
||||
let inner = serde_json::to_string_pretty(identity)?;
|
||||
let vault = crate::identity_vault::Vault::seal(&inner, passphrase)?;
|
||||
Ok(serde_json::to_string_pretty(&vault)?)
|
||||
}
|
||||
|
||||
/// Import an identity from a previously exported keyfile.
|
||||
///
|
||||
/// If the keyfile is encrypted, a passphrase must be provided. Returns the
|
||||
/// deserialized identity without writing it to disk — call `save()` to persist.
|
||||
pub fn import_keyfile(keyfile_json: &str, passphrase: Option<&str>) -> Result<Identity> {
|
||||
let vault: crate::identity_vault::Vault = serde_json::from_str(keyfile_json)?;
|
||||
let inner = vault.open(passphrase)?;
|
||||
let identity: Identity = serde_json::from_str(&inner)?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Errors that can occur during identity loading.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
/// The vault on disk is encrypted and needs a passphrase to unlock.
|
||||
PassphraseRequired { path: PathBuf },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LoadError::PassphraseRequired { path } => {
|
||||
write!(
|
||||
f,
|
||||
"identity vault at {} is encrypted; passphrase required",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoadError {}
|
||||
|
||||
fn vault_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("vnox")
|
||||
.join("identity.vault.json")
|
||||
}
|
||||
|
||||
fn identity_path() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("vnox")
|
||||
.join("identity.json")
|
||||
}
|
||||
158
src/identity_vault.rs
Normal file
158
src/identity_vault.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//! Encrypted vault for the identity keypair.
|
||||
//!
|
||||
//! Format on disk (JSON):
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1,
|
||||
//! "scheme": "argon2id+chacha20-poly1305",
|
||||
//! "salt": "<hex 16 bytes>",
|
||||
//! "nonce": "<hex 12 bytes>",
|
||||
//! "ciphertext": "<hex, plaintext is the inner Identity JSON>"
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! When `passphrase` is `None`, the identity is stored as plain JSON (legacy behavior).
|
||||
|
||||
use anyhow::Result;
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead};
|
||||
use serde::{Deserialize, Serialize};
|
||||
const VAULT_VERSION: u32 = 1;
|
||||
const VAULT_SCHEME: &str = "argon2id+chacha20-poly1305";
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 12;
|
||||
const KEY_LEN: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Vault {
|
||||
pub version: u32,
|
||||
pub scheme: String,
|
||||
/// Hex-encoded salt for Argon2id.
|
||||
#[serde(default)]
|
||||
pub salt: String,
|
||||
/// Hex-encoded ChaCha20-Poly1305 nonce.
|
||||
#[serde(default)]
|
||||
pub nonce: String,
|
||||
/// Hex-encoded ciphertext (encrypts the inner Identity JSON).
|
||||
/// Empty when no passphrase is set.
|
||||
#[serde(default)]
|
||||
pub ciphertext: String,
|
||||
/// Plaintext Identity JSON — only set when no passphrase is used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plaintext: Option<String>,
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// Seal the given JSON-serialized identity with a passphrase.
|
||||
/// If `passphrase` is empty, stores as plaintext (no encryption).
|
||||
pub fn seal(identity_json: &str, passphrase: Option<&str>) -> Result<Self> {
|
||||
if passphrase.map(|p| p.is_empty()).unwrap_or(true) {
|
||||
return Ok(Self {
|
||||
version: VAULT_VERSION,
|
||||
scheme: "plain".into(),
|
||||
salt: String::new(),
|
||||
nonce: String::new(),
|
||||
ciphertext: String::new(),
|
||||
plaintext: Some(identity_json.into()),
|
||||
});
|
||||
}
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut salt);
|
||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut nonce_bytes);
|
||||
|
||||
let key = derive_key(passphrase.unwrap(), &salt)?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?;
|
||||
let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher
|
||||
.encrypt(nonce, identity_json.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("encrypt identity: {e:?}"))?;
|
||||
|
||||
Ok(Self {
|
||||
version: VAULT_VERSION,
|
||||
scheme: VAULT_SCHEME.into(),
|
||||
salt: hex::encode(salt),
|
||||
nonce: hex::encode(nonce_bytes),
|
||||
ciphertext: hex::encode(&ct),
|
||||
plaintext: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the vault and return the inner Identity JSON.
|
||||
/// If `passphrase` is None but the vault is encrypted, returns an error.
|
||||
pub fn open(&self, passphrase: Option<&str>) -> Result<String> {
|
||||
if self.scheme == "plain" {
|
||||
return self
|
||||
.plaintext
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("plain vault has no plaintext"));
|
||||
}
|
||||
let passphrase = passphrase
|
||||
.ok_or_else(|| anyhow::anyhow!("vault is encrypted but no passphrase provided"))?;
|
||||
let salt = hex::decode(&self.salt).map_err(|e| anyhow::anyhow!("bad vault salt: {e}"))?;
|
||||
let nonce_bytes =
|
||||
hex::decode(&self.nonce).map_err(|e| anyhow::anyhow!("bad vault nonce: {e}"))?;
|
||||
let ct = hex::decode(&self.ciphertext)
|
||||
.map_err(|e| anyhow::anyhow!("bad vault ciphertext: {e}"))?;
|
||||
let key = derive_key(passphrase, &salt)?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.map_err(|e| anyhow::anyhow!("chacha key init: {e}"))?;
|
||||
let nonce = chacha20poly1305::Nonce::from_slice(&nonce_bytes);
|
||||
let pt = cipher
|
||||
.decrypt(nonce, ct.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("wrong passphrase or corrupted vault"))?;
|
||||
String::from_utf8(pt).map_err(|e| anyhow::anyhow!("vault plaintext is not UTF-8: {e}"))
|
||||
}
|
||||
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
self.scheme != "plain"
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> {
|
||||
// m=64 MiB, t=3, p=4 — moderate cost, ~250 ms on a modern CPU.
|
||||
let params = Params::new(64 * 1024, 3, 4, Some(KEY_LEN))
|
||||
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut out = [0u8; KEY_LEN];
|
||||
argon
|
||||
.hash_password_into(passphrase.as_bytes(), salt, &mut out)
|
||||
.map_err(|e| anyhow::anyhow!("argon2 derive: {e}"))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_encrypted() {
|
||||
let inner = r#"{"nickname":"alice","pubkey_hex":"abcd","privkey_hex":"0011"}"#;
|
||||
let v = Vault::seal(inner, Some("hunter2")).unwrap();
|
||||
assert!(v.is_encrypted());
|
||||
let opened = v.open(Some("hunter2")).unwrap();
|
||||
assert_eq!(opened, inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
let v = Vault::seal("secret", Some("hunter2")).unwrap();
|
||||
assert!(v.open(Some("wrong")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_roundtrip() {
|
||||
let inner = r#"{"nickname":"bob"}"#;
|
||||
let v = Vault::seal(inner, None).unwrap();
|
||||
assert!(!v.is_encrypted());
|
||||
let opened = v.open(None).unwrap();
|
||||
assert_eq!(opened, inner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_passphrase_treated_as_plain() {
|
||||
let v = Vault::seal("x", Some("")).unwrap();
|
||||
assert!(!v.is_encrypted());
|
||||
}
|
||||
}
|
||||
75
src/jitter/adaptive.rs
Normal file
75
src/jitter/adaptive.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use tracing::info;
|
||||
|
||||
use super::JitterBuffer;
|
||||
|
||||
pub const MIN_TARGET_MS: u32 = 20;
|
||||
pub const MAX_TARGET_MS: u32 = 150;
|
||||
pub const JITTER_WINDOW: usize = 64;
|
||||
const LOSS_INCREASE_THRESH: u32 = 5;
|
||||
const LOSS_DECREASE_THRESH: u32 = 1;
|
||||
const STABLE_WINDOWS_BEFORE_DECREASE: u32 = 10;
|
||||
pub const LOSS_WINDOW_PACKETS: u32 = 50;
|
||||
const ADJUST_STEP_MS: u32 = 5;
|
||||
|
||||
impl JitterBuffer {
|
||||
pub(super) fn observed_jitter_ms(&self) -> u32 {
|
||||
if self.arrival_count < 2 {
|
||||
return 0;
|
||||
}
|
||||
let mut deltas = Vec::with_capacity(self.arrival_count - 1);
|
||||
for i in 0..self.arrival_count - 1 {
|
||||
let idx0 = (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i) % JITTER_WINDOW;
|
||||
let idx1 =
|
||||
(self.arrival_idx + JITTER_WINDOW - self.arrival_count + i + 1) % JITTER_WINDOW;
|
||||
let delta = self.arrival_times[idx1].saturating_sub(self.arrival_times[idx0]);
|
||||
deltas.push(delta);
|
||||
}
|
||||
if deltas.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
deltas.sort_unstable();
|
||||
let p90_idx = ((deltas.len() as f64) * 0.90).ceil() as usize - 1;
|
||||
let p90_idx = p90_idx.min(deltas.len() - 1);
|
||||
deltas[p90_idx] as u32
|
||||
}
|
||||
|
||||
pub(super) fn adapt_target(&mut self) {
|
||||
let loss_pct = if self.total_expected > 0 {
|
||||
(self.loss_count as f64 / self.total_expected as f64 * 100.0) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let jitter_ms = self.observed_jitter_ms();
|
||||
let jitter_based_target = (jitter_ms * 2).clamp(MIN_TARGET_MS, MAX_TARGET_MS);
|
||||
|
||||
if loss_pct >= LOSS_INCREASE_THRESH {
|
||||
let new_target = (self.target_ms + ADJUST_STEP_MS).min(MAX_TARGET_MS);
|
||||
if new_target != self.target_ms {
|
||||
info!(
|
||||
target_ms = new_target,
|
||||
loss_pct, jitter_ms, "adaptive: increasing target due to packet loss"
|
||||
);
|
||||
self.target_ms = new_target;
|
||||
}
|
||||
self.stable_windows = 0;
|
||||
} else if loss_pct <= LOSS_DECREASE_THRESH {
|
||||
self.stable_windows += 1;
|
||||
if self.stable_windows >= STABLE_WINDOWS_BEFORE_DECREASE {
|
||||
let candidate = self.target_ms.saturating_sub(ADJUST_STEP_MS);
|
||||
let new_target = candidate.max(jitter_based_target).max(MIN_TARGET_MS);
|
||||
if new_target != self.target_ms {
|
||||
info!(
|
||||
target_ms = new_target,
|
||||
loss_pct, jitter_ms, "adaptive: decreasing target, link stable"
|
||||
);
|
||||
self.target_ms = new_target;
|
||||
}
|
||||
self.stable_windows = 0;
|
||||
}
|
||||
} else {
|
||||
self.stable_windows = 0;
|
||||
}
|
||||
self.loss_count = 0;
|
||||
self.total_expected = 0;
|
||||
}
|
||||
}
|
||||
100
src/jitter/mod.rs
Normal file
100
src/jitter/mod.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
pub mod adaptive;
|
||||
pub mod relay;
|
||||
|
||||
pub use adaptive::{JITTER_WINDOW, LOSS_WINDOW_PACKETS, MAX_TARGET_MS, MIN_TARGET_MS};
|
||||
|
||||
pub struct JitterBuffer {
|
||||
target_ms: u32,
|
||||
adaptive: bool,
|
||||
packets: BTreeMap<u32, BufferedPacket>,
|
||||
last_played: Option<u32>,
|
||||
arrival_times: Vec<u64>,
|
||||
arrival_idx: usize,
|
||||
arrival_count: usize,
|
||||
loss_count: u32,
|
||||
total_expected: u32,
|
||||
stable_windows: u32,
|
||||
}
|
||||
|
||||
impl JitterBuffer {
|
||||
pub fn new(target_ms: u32, adaptive: bool) -> Self {
|
||||
Self {
|
||||
target_ms,
|
||||
adaptive,
|
||||
packets: BTreeMap::new(),
|
||||
last_played: None,
|
||||
arrival_times: vec![0u64; JITTER_WINDOW],
|
||||
arrival_idx: 0,
|
||||
arrival_count: 0,
|
||||
loss_count: 0,
|
||||
total_expected: 0,
|
||||
stable_windows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, pkt: BufferedPacket) {
|
||||
let arrived = pkt.arrived_at;
|
||||
self.arrival_times[self.arrival_idx] = arrived;
|
||||
self.arrival_idx = (self.arrival_idx + 1) % JITTER_WINDOW;
|
||||
if self.arrival_count < JITTER_WINDOW {
|
||||
self.arrival_count += 1;
|
||||
}
|
||||
|
||||
let gap = self
|
||||
.packets
|
||||
.keys()
|
||||
.next_back()
|
||||
.map(|&highest| {
|
||||
if pkt.voice_seq > highest {
|
||||
pkt.voice_seq.wrapping_sub(highest).saturating_sub(1)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if gap > 0 {
|
||||
self.loss_count += gap;
|
||||
}
|
||||
self.total_expected += 1 + gap;
|
||||
self.packets.insert(pkt.voice_seq, pkt);
|
||||
|
||||
if self.adaptive && self.total_expected >= LOSS_WINDOW_PACKETS {
|
||||
self.adapt_target();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.packets.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.packets.is_empty()
|
||||
}
|
||||
|
||||
pub fn set_target_ms(&mut self, target_ms: u32) {
|
||||
self.target_ms = target_ms.clamp(MIN_TARGET_MS, MAX_TARGET_MS);
|
||||
}
|
||||
|
||||
pub fn target_ms(&self) -> u32 {
|
||||
self.target_ms
|
||||
}
|
||||
|
||||
pub fn is_adaptive(&self) -> bool {
|
||||
self.adaptive
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BufferedPacket {
|
||||
pub voice_seq: u32,
|
||||
pub timestamp: u32,
|
||||
pub channel_id: u64,
|
||||
pub opus_data: Vec<u8>,
|
||||
pub arrived_at: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
44
src/jitter/relay.rs
Normal file
44
src/jitter/relay.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use super::{BufferedPacket, JitterBuffer};
|
||||
|
||||
impl JitterBuffer {
|
||||
pub fn pop_ready(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||
if self.adaptive {
|
||||
return self.pop_ready_adaptive(now_ms);
|
||||
}
|
||||
self.pop_ready_fixed(now_ms)
|
||||
}
|
||||
|
||||
pub fn has_gap(&self) -> bool {
|
||||
match self.last_played {
|
||||
None => false,
|
||||
Some(last) => {
|
||||
let expected = last.wrapping_add(1);
|
||||
!self.packets.contains_key(&expected) && !self.packets.is_empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_ready_fixed(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||
let seq = *self.packets.keys().next()?;
|
||||
let pkt = self.packets.get(&seq)?;
|
||||
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
|
||||
if buffered_for >= self.target_ms as u64 {
|
||||
let pkt = self.packets.remove(&seq)?;
|
||||
self.last_played = Some(seq);
|
||||
return Some(pkt);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn pop_ready_adaptive(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||
let seq = *self.packets.keys().next()?;
|
||||
let pkt = self.packets.get(&seq)?;
|
||||
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
|
||||
if buffered_for >= self.target_ms as u64 {
|
||||
let pkt = self.packets.remove(&seq)?;
|
||||
self.last_played = Some(seq);
|
||||
return Some(pkt);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
103
src/jitter/tests.rs
Normal file
103
src/jitter/tests.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use super::*;
|
||||
|
||||
fn pkt(seq: u32, arrived_at: u64) -> BufferedPacket {
|
||||
BufferedPacket {
|
||||
voice_seq: seq,
|
||||
timestamp: 0,
|
||||
channel_id: 1,
|
||||
opus_data: vec![seq as u8],
|
||||
arrived_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pop_ready_waits_for_target_ms() {
|
||||
let mut jb = JitterBuffer::new(20, false);
|
||||
jb.push(pkt(1, 100));
|
||||
assert!(jb.pop_ready(110).is_none());
|
||||
assert_eq!(jb.pop_ready(120).unwrap().voice_seq, 1);
|
||||
assert!(jb.pop_ready(120).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pop_ready_releases_in_sequence_order() {
|
||||
let mut jb = JitterBuffer::new(0, false);
|
||||
jb.push(pkt(2, 0));
|
||||
jb.push(pkt(1, 0));
|
||||
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 1);
|
||||
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_gap_after_played_seq() {
|
||||
let mut jb = JitterBuffer::new(0, false);
|
||||
jb.push(pkt(1, 0));
|
||||
jb.pop_ready(0);
|
||||
jb.push(pkt(3, 0));
|
||||
assert!(jb.has_gap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_gap_when_next_seq_present() {
|
||||
let mut jb = JitterBuffer::new(0, false);
|
||||
jb.push(pkt(1, 0));
|
||||
jb.pop_ready(0);
|
||||
jb.push(pkt(2, 0));
|
||||
assert!(!jb.has_gap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_mode_increases_on_loss() {
|
||||
let mut jb = JitterBuffer::new(40, true);
|
||||
jb.push(pkt(1, 0));
|
||||
jb.push(pkt(3, 1));
|
||||
for i in 4..=50 {
|
||||
jb.push(pkt(i, i as u64));
|
||||
}
|
||||
assert!(jb.target_ms <= MAX_TARGET_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_target_ms_clamps() {
|
||||
let mut jb = JitterBuffer::new(40, false);
|
||||
jb.set_target_ms(0);
|
||||
assert_eq!(jb.target_ms, MIN_TARGET_MS);
|
||||
jb.set_target_ms(500);
|
||||
assert_eq!(jb.target_ms, MAX_TARGET_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_mode_respects_flag() {
|
||||
let mut jb = JitterBuffer::new(40, false);
|
||||
jb.push(pkt(1, 0));
|
||||
for i in 2..=100 {
|
||||
jb.push(pkt(i, i as u64));
|
||||
}
|
||||
assert_eq!(jb.target_ms, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_jitter_smooth_timeline() {
|
||||
let mut jb = JitterBuffer::new(40, true);
|
||||
for i in 0..JITTER_WINDOW {
|
||||
jb.push(pkt(i as u32, (i as u64) * 10));
|
||||
}
|
||||
let jitter = jb.observed_jitter_ms();
|
||||
assert!(
|
||||
(8..=12).contains(&jitter),
|
||||
"jitter = {jitter} (expected ~10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_jitter_spike() {
|
||||
let mut jb = JitterBuffer::new(40, true);
|
||||
let times = [
|
||||
0, 10, 20, 70, 80, 90, 140, 150, 160, 210, 220, 230, 280, 290, 300,
|
||||
];
|
||||
for (i, &t) in times.iter().enumerate() {
|
||||
jb.push(pkt(i as u32, t));
|
||||
}
|
||||
let jitter = jb.observed_jitter_ms();
|
||||
assert!(jitter >= 40, "jitter = {jitter} (expected >= 40)");
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod audio;
|
||||
pub mod identity;
|
||||
pub mod identity_vault;
|
||||
pub mod jitter;
|
||||
pub mod net;
|
||||
39
src/main.rs
Normal file
39
src/main.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
mod ui;
|
||||
|
||||
pub use ui::VnoxApp;
|
||||
pub use ui::{chat, connect, keybind, members, settings, sidebar, state, theme, titlebar};
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use vnox_client::{identity, net};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new("info,wgpu=warn,wgpu_hal=warn,egui_wgpu=warn,naga=warn")
|
||||
});
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
|
||||
let identity = identity::load_or_generate()?;
|
||||
info!("identity: {}", identity.short_id());
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let _guard = rt.enter();
|
||||
|
||||
let net = net::spawn(identity.clone());
|
||||
|
||||
eframe::run_native(
|
||||
"Vnox",
|
||||
eframe::NativeOptions {
|
||||
viewport: eframe::egui::ViewportBuilder::default()
|
||||
.with_title("Vnox")
|
||||
.with_inner_size([900.0, 560.0])
|
||||
.with_min_inner_size([600.0, 400.0]),
|
||||
..Default::default()
|
||||
},
|
||||
Box::new(|cc| Ok(Box::new(ui::VnoxApp::new(cc, identity, net)))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("eframe: {e}"))
|
||||
}
|
||||
46
src/net/crypto/cipher.rs
Normal file
46
src/net/crypto/cipher.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use chacha20poly1305::{
|
||||
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
||||
aead::{Aead, Payload},
|
||||
};
|
||||
|
||||
fn make_nonce(cid: &[u8; 8], seq: u64) -> [u8; 12] {
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce[..8].copy_from_slice(cid);
|
||||
nonce[8..12].copy_from_slice(&(seq as u32).to_le_bytes());
|
||||
nonce
|
||||
}
|
||||
|
||||
pub fn encrypt_with_key(key: &[u8; 32], cid: &[u8; 8], seq: u64, plaintext: &[u8]) -> Vec<u8> {
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||
let nonce_bytes = make_nonce(cid, seq);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
cipher
|
||||
.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.expect("ChaCha20-Poly1305 encryption infallible")
|
||||
}
|
||||
|
||||
pub fn decrypt_with_key(
|
||||
key: &[u8; 32],
|
||||
cid: &[u8; 8],
|
||||
seq: u64,
|
||||
ciphertext: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||
let nonce_bytes = make_nonce(cid, seq);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
cipher
|
||||
.decrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: ciphertext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("decryption failed: {e:?}"))
|
||||
}
|
||||
99
src/net/crypto/mod.rs
Normal file
99
src/net/crypto/mod.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use hkdf::Hkdf;
|
||||
use sha2::{Digest, Sha256};
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||
|
||||
pub mod cipher;
|
||||
|
||||
/// Session encryption context (mirrors gateway/src/proto/crypto.rs).
|
||||
///
|
||||
/// Two derived keys:
|
||||
/// - `c2s_key`: client → server
|
||||
/// - `s2c_key`: server → client
|
||||
///
|
||||
/// Nonce: `cid (8 bytes) || packet_seq (4 bytes LE)`.
|
||||
#[derive(Clone)]
|
||||
pub struct SessionCrypto {
|
||||
c2s_key: [u8; 32],
|
||||
s2c_key: [u8; 32],
|
||||
cid: [u8; 8],
|
||||
}
|
||||
|
||||
impl SessionCrypto {
|
||||
pub fn derive(shared_secret: &[u8; 32], session_id: &str) -> Self {
|
||||
let salt = b"VNOX-LNEx-KDF-v1";
|
||||
let hk = Hkdf::<Sha256>::new(Some(salt), shared_secret);
|
||||
|
||||
let mut keys = [0u8; 64];
|
||||
hk.expand(b"session-keys", &mut keys)
|
||||
.expect("64 bytes within HKDF max");
|
||||
|
||||
let c2s_key: [u8; 32] = keys[..32].try_into().unwrap();
|
||||
let s2c_key: [u8; 32] = keys[32..64].try_into().unwrap();
|
||||
|
||||
let hash = Sha256::digest(session_id.as_bytes());
|
||||
let cid: [u8; 8] = hash[..8].try_into().unwrap();
|
||||
|
||||
Self {
|
||||
c2s_key,
|
||||
s2c_key,
|
||||
cid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt_c2s(&self, seq: u64, plaintext: &[u8]) -> Vec<u8> {
|
||||
cipher::encrypt_with_key(&self.c2s_key, &self.cid, seq, plaintext)
|
||||
}
|
||||
|
||||
pub fn decrypt_s2c(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
cipher::decrypt_with_key(&self.s2c_key, &self.cid, seq, ciphertext)
|
||||
}
|
||||
|
||||
pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
cipher::decrypt_with_key(&self.c2s_key, &self.cid, seq, ciphertext)
|
||||
}
|
||||
|
||||
pub fn c2s_key(&self) -> &[u8; 32] {
|
||||
&self.c2s_key
|
||||
}
|
||||
|
||||
pub fn cid(&self) -> &[u8; 8] {
|
||||
&self.cid
|
||||
}
|
||||
|
||||
pub fn new_ephemeral() -> (EphemeralSecret, PublicKey) {
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = EphemeralSecret::random_from_rng(&mut rng);
|
||||
let pk = PublicKey::from(&sk);
|
||||
(sk, pk)
|
||||
}
|
||||
|
||||
pub fn ecdh(secret: EphemeralSecret, peer_public: &PublicKey) -> SharedSecret {
|
||||
secret.diffie_hellman(peer_public)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_then_decrypt_roundtrip() {
|
||||
let shared_secret = [0xABu8; 32];
|
||||
let crypto = SessionCrypto::derive(&shared_secret, "test-session");
|
||||
|
||||
let plaintext = b"hello encrypted world";
|
||||
let ct = crypto.encrypt_c2s(42, plaintext);
|
||||
let decrypted = crypto.decrypt_c2s(42, &ct).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_ciphertext_fails() {
|
||||
let shared_secret = [0xABu8; 32];
|
||||
let crypto = SessionCrypto::derive(&shared_secret, "test-session");
|
||||
|
||||
let mut ct = crypto.encrypt_c2s(0, b"data");
|
||||
ct[5] ^= 0xFF;
|
||||
assert!(crypto.decrypt_c2s(0, &ct).is_err());
|
||||
}
|
||||
}
|
||||
51
src/net/framing/io.rs
Normal file
51
src/net/framing/io.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use super::super::crypto::SessionCrypto;
|
||||
use super::super::payloads::HDR_SIZE;
|
||||
use super::RawPacket;
|
||||
use super::parse_header;
|
||||
|
||||
const ENCRYPTED_FLAG: u16 = 1 << 1;
|
||||
const KNOWN_FLAGS: u16 = ENCRYPTED_FLAG;
|
||||
|
||||
pub async fn read_encrypted(stream: &mut TcpStream, crypto: &SessionCrypto) -> Result<RawPacket> {
|
||||
let mut buf = [0u8; HDR_SIZE];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
let (packet_id, flags, sequence, payload_length) = parse_header(&buf)?;
|
||||
if flags & !KNOWN_FLAGS != 0 {
|
||||
return Err(anyhow!("unknown packet flags: 0x{:04X}", flags));
|
||||
}
|
||||
if flags & ENCRYPTED_FLAG == 0 {
|
||||
return Err(anyhow!("packet not encrypted"));
|
||||
}
|
||||
let mut encrypted = vec![0u8; payload_length as usize];
|
||||
if !encrypted.is_empty() {
|
||||
stream.read_exact(&mut encrypted).await?;
|
||||
}
|
||||
let payload = crypto.decrypt_s2c(sequence as u64, &encrypted)?;
|
||||
Ok(RawPacket { packet_id, payload })
|
||||
}
|
||||
|
||||
pub async fn write_encrypted(
|
||||
stream: &mut TcpStream,
|
||||
id: u16,
|
||||
seq: &mut u32,
|
||||
payload: &[u8],
|
||||
crypto: &SessionCrypto,
|
||||
) -> Result<()> {
|
||||
let encrypted = crypto.encrypt_c2s(*seq as u64, payload);
|
||||
let mut hdr = [0u8; HDR_SIZE];
|
||||
hdr[0..2].copy_from_slice(&id.to_be_bytes());
|
||||
hdr[2..4].copy_from_slice(&ENCRYPTED_FLAG.to_be_bytes());
|
||||
hdr[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||
hdr[8..12].copy_from_slice(&(encrypted.len() as u32).to_be_bytes());
|
||||
*seq = seq.wrapping_add(1);
|
||||
let mut frame = Vec::with_capacity(HDR_SIZE + encrypted.len());
|
||||
frame.extend_from_slice(&hdr);
|
||||
frame.extend_from_slice(&encrypted);
|
||||
stream.write_all(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
101
src/net/framing/mod.rs
Normal file
101
src/net/framing/mod.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use super::payloads::{HDR_SIZE, MAX_PAYLOAD};
|
||||
|
||||
pub struct RawPacket {
|
||||
pub packet_id: u16,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(super) fn parse_header(buf: &[u8; HDR_SIZE]) -> Result<(u16, u16, u32, u32)> {
|
||||
let packet_id = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
let flags = u16::from_be_bytes([buf[2], buf[3]]);
|
||||
let sequence = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
|
||||
let payload_length = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
if payload_length > MAX_PAYLOAD {
|
||||
return Err(anyhow!("payload too large: {payload_length}"));
|
||||
}
|
||||
Ok((packet_id, flags, sequence, payload_length))
|
||||
}
|
||||
|
||||
pub fn encode_frame(id: u16, seq: &mut u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = [0u8; HDR_SIZE];
|
||||
hdr[0..2].copy_from_slice(&id.to_be_bytes());
|
||||
hdr[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||
hdr[8..12].copy_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
*seq = seq.wrapping_add(1);
|
||||
let mut frame = Vec::with_capacity(HDR_SIZE + payload.len());
|
||||
frame.extend_from_slice(&hdr);
|
||||
frame.extend_from_slice(payload);
|
||||
frame
|
||||
}
|
||||
|
||||
pub fn decode_frame(bytes: &[u8]) -> Result<RawPacket> {
|
||||
if bytes.len() < HDR_SIZE {
|
||||
return Err(anyhow!("frame too short"));
|
||||
}
|
||||
let hdr: [u8; HDR_SIZE] = bytes[..HDR_SIZE].try_into().expect("header length");
|
||||
let (packet_id, _, _, payload_length) = parse_header(&hdr)?;
|
||||
let end = HDR_SIZE + payload_length as usize;
|
||||
if bytes.len() < end {
|
||||
return Err(anyhow!("truncated frame"));
|
||||
}
|
||||
Ok(RawPacket {
|
||||
packet_id,
|
||||
payload: bytes[HDR_SIZE..end].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read(stream: &mut TcpStream) -> Result<RawPacket> {
|
||||
let mut buf = [0u8; HDR_SIZE];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
let (packet_id, _, _, payload_length) = parse_header(&buf)?;
|
||||
let mut payload = vec![0u8; payload_length as usize];
|
||||
if !payload.is_empty() {
|
||||
stream.read_exact(&mut payload).await?;
|
||||
}
|
||||
Ok(RawPacket { packet_id, payload })
|
||||
}
|
||||
|
||||
pub async fn write(stream: &mut TcpStream, id: u16, seq: &mut u32, payload: &[u8]) -> Result<()> {
|
||||
let frame = encode_frame(id, seq, payload);
|
||||
stream.write_all(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub mod io;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::net::payloads::PID_PING;
|
||||
|
||||
#[test]
|
||||
fn encode_decode_roundtrip() {
|
||||
let payload = br#"{"ping":true}"#;
|
||||
let mut seq = 7u32;
|
||||
let frame = encode_frame(PID_PING, &mut seq, payload);
|
||||
assert_eq!(seq, 8);
|
||||
let pkt = decode_frame(&frame).unwrap();
|
||||
assert_eq!(pkt.packet_id, PID_PING);
|
||||
assert_eq!(pkt.payload, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_oversized_length() {
|
||||
let mut hdr = [0u8; HDR_SIZE];
|
||||
hdr[0..2].copy_from_slice(&PID_PING.to_be_bytes());
|
||||
hdr[8..12].copy_from_slice(&(MAX_PAYLOAD + 1).to_be_bytes());
|
||||
assert!(decode_frame(&hdr).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_truncated_body() {
|
||||
let mut seq = 0u32;
|
||||
let frame = encode_frame(PID_PING, &mut seq, b"x");
|
||||
let truncated = &frame[..frame.len() - 1];
|
||||
assert!(decode_frame(truncated).is_err());
|
||||
}
|
||||
}
|
||||
78
src/net/handshake.rs
Normal file
78
src/net/handshake.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::crypto::SessionCrypto;
|
||||
use super::framing;
|
||||
use super::payloads::*;
|
||||
use crate::identity::Identity;
|
||||
|
||||
/// Perform HELLO → AUTH → SESSION with X25519 ECDH key exchange.
|
||||
///
|
||||
/// Returns (session_id, node_name, private_mode, crypto).
|
||||
/// All subsequent packets must be encrypted with `crypto`.
|
||||
pub async fn run(
|
||||
stream: &mut TcpStream,
|
||||
identity: &Identity,
|
||||
seq: &mut u32,
|
||||
) -> Result<(String, String, bool, SessionCrypto)> {
|
||||
// HELLO — receive server's ephemeral X25519 public key
|
||||
let pkt = framing::read(stream).await?;
|
||||
if pkt.packet_id != PID_HELLO {
|
||||
return Err(anyhow!("expected HELLO, got 0x{:04X}", pkt.packet_id));
|
||||
}
|
||||
let hello: HelloPayload = serde_json::from_slice(&pkt.payload)?;
|
||||
debug!("← HELLO node={}", hello.node_name);
|
||||
|
||||
// Generate our own ephemeral X25519 keypair
|
||||
let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral();
|
||||
let eph_pk_hex = hex::encode(eph_pk.as_bytes());
|
||||
|
||||
// Parse server's ephemeral key from HELLO
|
||||
let server_eph_raw =
|
||||
hex::decode(&hello.server_eph_pubkey).map_err(|_| anyhow!("bad server_eph_pubkey hex"))?;
|
||||
let server_eph_key: [u8; 32] = server_eph_raw
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("bad server_eph_pubkey length"))?;
|
||||
let server_eph_pk = x25519_dalek::PublicKey::from(server_eph_key);
|
||||
|
||||
// AUTH — include our ephemeral public key
|
||||
let nonce = hex::decode(&hello.challenge_nonce)?;
|
||||
let sk = identity
|
||||
.signing_key()
|
||||
.ok_or_else(|| anyhow!("no signing key"))?;
|
||||
use ed25519_dalek::Signer;
|
||||
let sig = sk.sign(&nonce);
|
||||
|
||||
let auth = AuthPayload {
|
||||
client_pubkey: identity.pubkey_hex.clone(),
|
||||
nickname: identity.nickname.clone(),
|
||||
lnex_version: LNEX_VERSION.into(),
|
||||
signature: hex::encode(sig.to_bytes()),
|
||||
client_eph_pubkey: eph_pk_hex,
|
||||
};
|
||||
framing::write(stream, PID_AUTH, seq, &serde_json::to_vec(&auth)?).await?;
|
||||
debug!("→ AUTH nick={}", identity.nickname);
|
||||
|
||||
// SESSION
|
||||
let pkt = framing::read(stream).await?;
|
||||
if pkt.packet_id == PID_ERROR {
|
||||
let e: ErrorPayload = serde_json::from_slice(&pkt.payload)?;
|
||||
return Err(anyhow!("auth rejected: {}", e.message));
|
||||
}
|
||||
if pkt.packet_id != PID_SESSION {
|
||||
return Err(anyhow!("expected SESSION, got 0x{:04X}", pkt.packet_id));
|
||||
}
|
||||
let sess: SessionPayload = serde_json::from_slice(&pkt.payload)?;
|
||||
|
||||
// Derive encryption keys from ECDH shared secret
|
||||
let shared_secret = SessionCrypto::ecdh(eph_sk, &server_eph_pk);
|
||||
let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id);
|
||||
|
||||
info!(
|
||||
"authenticated — session {} encrypted",
|
||||
&sess.session_id[..8]
|
||||
);
|
||||
|
||||
Ok((sess.session_id, hello.node_name, hello.private_mode, crypto))
|
||||
}
|
||||
162
src/net/internal/dispatch/channel.rs
Normal file
162
src/net/internal/dispatch/channel.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use serde_json;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::super::super::payloads::*;
|
||||
use super::super::super::types::NetEvent;
|
||||
use super::super::super::types::{ChannelListItem as InfoChannelListItem, ChatMsg, MemberInfo};
|
||||
|
||||
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
|
||||
match pid {
|
||||
PID_CHANNEL_STATE => {
|
||||
let p: ChannelStatePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChannelState {
|
||||
channel_id: p.channel_id,
|
||||
channel_name: p.channel_name,
|
||||
kind: p.kind,
|
||||
members: p
|
||||
.members
|
||||
.into_iter()
|
||||
.map(|m| MemberInfo {
|
||||
user_id: m.user_id,
|
||||
nickname: m.nickname,
|
||||
in_voice: m.in_voice,
|
||||
})
|
||||
.collect(),
|
||||
voice_endpoint: p.voice_endpoint,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_CHANNEL_CREATE => {
|
||||
let p: ChannelCreatePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChannelCreated {
|
||||
channel_id: p.channel_id,
|
||||
channel_name: p.channel_name,
|
||||
kind: p.kind,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_CHANNEL_DELETE => {
|
||||
let p: ChannelDeletePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChannelDeleted {
|
||||
channel_id: p.channel_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_CHANNEL_LIST => {
|
||||
let p: ChannelListPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChannelListEvent {
|
||||
channels: p
|
||||
.channels
|
||||
.into_iter()
|
||||
.map(|c| InfoChannelListItem {
|
||||
channel_id: c.channel_id,
|
||||
channel_name: c.channel_name,
|
||||
kind: c.kind,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_CHAT_MESSAGE => {
|
||||
let p: ChatMessagePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChatMessage {
|
||||
message_id: p.message_id,
|
||||
channel_id: p.channel_id,
|
||||
sender_id: p.sender_id.clone(),
|
||||
nickname: p.sender_id,
|
||||
content: p.content,
|
||||
timestamp: p.timestamp,
|
||||
reply_to: p.reply_to,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_CHAT_HISTORY => {
|
||||
let p: ChatHistoryPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ChatHistory {
|
||||
channel_id: p.channel_id,
|
||||
messages: p
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMsg {
|
||||
message_id: m.message_id,
|
||||
sender_id: m.sender_id,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
reply_to: m.reply_to,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_USER_JOIN => {
|
||||
let p: UserJoinPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::UserJoin {
|
||||
channel_id: p.channel_id,
|
||||
user_id: p.user_id,
|
||||
nickname: p.nickname,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_USER_LEAVE => {
|
||||
let p: UserLeavePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::UserLeave {
|
||||
channel_id: p.channel_id,
|
||||
user_id: p.user_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_PONG => {
|
||||
let p: PongPayload = serde_json::from_slice(payload)?;
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
let rtt_ms = (now - p.timestamp).max(0) as u32;
|
||||
let _ = tx.send(NetEvent::LatencyUpdate { rtt_ms }).await;
|
||||
}
|
||||
PID_READ_RECEIPT_BROADCAST => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ReadReceiptBroadcast {
|
||||
channel_id: p["channel_id"].as_str().unwrap_or_default().into(),
|
||||
user_id: p["user_id"].as_str().unwrap_or_default().into(),
|
||||
last_read_message_id: p["last_read_message_id"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_ERROR => {
|
||||
let p: ErrorPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::Error {
|
||||
code: p.code,
|
||||
message: p.message,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_DISCONNECT => return Err(anyhow!("server disconnected")),
|
||||
PID_TYPING_START => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::TypingStart {
|
||||
user_id: p["user_id"].as_str().unwrap_or_default().into(),
|
||||
nickname: p["nickname"].as_str().unwrap_or_default().into(),
|
||||
channel_id: p["channel_id"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
152
src/net/internal/dispatch/dm.rs
Normal file
152
src/net/internal/dispatch/dm.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use anyhow::Result;
|
||||
use serde_json;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
use super::super::super::payloads::*;
|
||||
use super::super::super::types::*;
|
||||
|
||||
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
|
||||
match pid {
|
||||
PID_DM_START => {
|
||||
let p: DmStartResponsePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::DmStart {
|
||||
dm_id: p.dm_id,
|
||||
other_user_id: p.other_user_id,
|
||||
other_nickname: p.other_nickname,
|
||||
messages: p
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMsg {
|
||||
message_id: String::new(),
|
||||
sender_id: m.sender_id,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
reply_to: None,
|
||||
})
|
||||
.collect(),
|
||||
unread_count: p.unread_count,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_DM_MESSAGE => {
|
||||
let p: DmMessagePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::DmMessage {
|
||||
dm_id: p.dm_id,
|
||||
sender_id: p.sender_id,
|
||||
content: p.content,
|
||||
timestamp: p.timestamp,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_DM_HISTORY => {
|
||||
let p: DmHistoryPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::DmHistory {
|
||||
dm_id: p.dm_id,
|
||||
messages: p
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMsg {
|
||||
message_id: String::new(),
|
||||
sender_id: m.sender_id,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
reply_to: None,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_FRIEND_LIST => {
|
||||
let p: FriendListPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::FriendList {
|
||||
friends: p
|
||||
.friends
|
||||
.into_iter()
|
||||
.map(|f| FriendInfo {
|
||||
user_id: f.user_id,
|
||||
nickname: f.nickname,
|
||||
status: f.status,
|
||||
since: f.since,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_MESSAGE_REACTION_ADD => {
|
||||
let p: ReactionPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ReactionAdded {
|
||||
channel_id: p.channel_id,
|
||||
message_id: p.message_id,
|
||||
user_id: p.user_id,
|
||||
emoji: p.emoji,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_MESSAGE_REACTION_REMOVE => {
|
||||
let p: ReactionPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::ReactionRemoved {
|
||||
channel_id: p.channel_id,
|
||||
message_id: p.message_id,
|
||||
user_id: p.user_id,
|
||||
emoji: p.emoji,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_MESSAGE_EDIT => {
|
||||
let p: ChatMessagePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::MessageEdited {
|
||||
channel_id: p.channel_id,
|
||||
message_id: p.message_id,
|
||||
content: p.content,
|
||||
edited: p.edited,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_MESSAGE_DELETE => {
|
||||
let p: MessageDeletePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::MessageDeleted {
|
||||
channel_id: p.channel_id,
|
||||
message_id: p.message_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_FRIEND_EVENT => {
|
||||
let p: FriendEventPayload = serde_json::from_slice(payload)?;
|
||||
match p.event_type.as_str() {
|
||||
"request" => {
|
||||
let _ = tx
|
||||
.send(NetEvent::FriendRequested {
|
||||
user_id: p.user_id,
|
||||
nickname: p.nickname.unwrap_or_default(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
"accepted" => {
|
||||
let _ = tx
|
||||
.send(NetEvent::FriendAccepted {
|
||||
user_id: p.user_id,
|
||||
nickname: p.nickname.unwrap_or_default(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
"removed" => {
|
||||
let _ = tx
|
||||
.send(NetEvent::FriendRemoved { user_id: p.user_id })
|
||||
.await;
|
||||
}
|
||||
_ => debug!("unhandled friend event: {}", p.event_type),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
53
src/net/internal/dispatch/mod.rs
Normal file
53
src/net/internal/dispatch/mod.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
use super::super::payloads::*;
|
||||
use super::super::types::*;
|
||||
|
||||
mod channel;
|
||||
mod dm;
|
||||
mod social;
|
||||
|
||||
pub async fn incoming(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
|
||||
match pid {
|
||||
PID_CHANNEL_STATE
|
||||
| PID_CHAT_MESSAGE
|
||||
| PID_CHAT_HISTORY
|
||||
| PID_USER_JOIN
|
||||
| PID_USER_LEAVE
|
||||
| PID_PONG
|
||||
| PID_READ_RECEIPT_BROADCAST
|
||||
| PID_ERROR
|
||||
| PID_DISCONNECT
|
||||
| PID_TYPING_START => channel::handle(pid, payload, tx).await?,
|
||||
|
||||
PID_DM_START
|
||||
| PID_DM_MESSAGE
|
||||
| PID_DM_HISTORY
|
||||
| PID_FRIEND_LIST
|
||||
| PID_MESSAGE_REACTION_ADD
|
||||
| PID_MESSAGE_REACTION_REMOVE
|
||||
| PID_MESSAGE_EDIT
|
||||
| PID_MESSAGE_DELETE
|
||||
| PID_FRIEND_EVENT => dm::handle(pid, payload, tx).await?,
|
||||
|
||||
PID_GUILD_LIST
|
||||
| PID_GUILD_CREATE
|
||||
| PID_GUILD_DELETE
|
||||
| PID_GUILD_MEMBER_JOIN
|
||||
| PID_GUILD_MEMBER_LEAVE
|
||||
| PID_GUILD_MEMBER_KICK
|
||||
| PID_INVITE_CREATE
|
||||
| PID_INVITE_ACCEPT
|
||||
| PID_INVITE_DELETE
|
||||
| PID_ROLE_CREATE
|
||||
| PID_ROLE_DELETE
|
||||
| PID_USER_ROLE_UPDATE
|
||||
| PID_PRESENCE_SYNC
|
||||
| PID_PRESENCE_EVENT => social::handle(pid, payload, tx).await?,
|
||||
|
||||
other => debug!("unhandled 0x{:04X}", other),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
232
src/net/internal/dispatch/social.rs
Normal file
232
src/net/internal/dispatch/social.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
use anyhow::Result;
|
||||
use serde_json;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::super::super::payloads::*;
|
||||
use super::super::super::types::*;
|
||||
|
||||
pub async fn handle(pid: u16, payload: &[u8], tx: &mpsc::Sender<NetEvent>) -> Result<()> {
|
||||
match pid {
|
||||
PID_GUILD_LIST => {
|
||||
let p: GuildListPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildList {
|
||||
guilds: p
|
||||
.guilds
|
||||
.into_iter()
|
||||
.map(|g| GuildInfo {
|
||||
guild_id: g.guild_id,
|
||||
name: g.name,
|
||||
owner_id: g.owner_id,
|
||||
icon_url: g.icon_url,
|
||||
member_count: g.member_count,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_CREATE => {
|
||||
let p: GuildInfoPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildCreated {
|
||||
guild_id: p.guild_id,
|
||||
name: p.name,
|
||||
owner_id: p.owner_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_DELETE => {
|
||||
let p: GuildDeletePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildDeleted {
|
||||
guild_id: p.guild_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_MEMBER_JOIN => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildMemberJoined {
|
||||
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
|
||||
user_id: p["user_id"].as_str().unwrap_or_default().into(),
|
||||
nickname: p["nickname"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_MEMBER_LEAVE => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildMemberLeft {
|
||||
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
|
||||
user_id: p["user_id"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_USER_ROLE_UPDATE => {
|
||||
let p: UserRoleUpdatePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::UserColor {
|
||||
user_id: p.user_id,
|
||||
color: p.color,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_PRESENCE_SYNC => {
|
||||
let p: PresenceSyncPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::PresenceSync {
|
||||
presences: p
|
||||
.presences
|
||||
.into_iter()
|
||||
.map(|p| PresenceInfo {
|
||||
user_id: p.user_id,
|
||||
status: p.status,
|
||||
custom_status: p.custom_status,
|
||||
activity: p.activity,
|
||||
activity_text: p.activity_text,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_PRESENCE_EVENT => {
|
||||
let p: PresenceEventPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::PresenceUpdated {
|
||||
user_id: p.user_id,
|
||||
status: p.status,
|
||||
custom_status: p.custom_status,
|
||||
activity: p.activity,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_MEMBER_KICK => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildMemberKicked {
|
||||
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
|
||||
user_id: p["user_id"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_INVITE_CREATE => {
|
||||
let p: InviteInfoPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::InviteCreated {
|
||||
id: p.id,
|
||||
guild_id: p.guild_id,
|
||||
guild_name: p.guild_name,
|
||||
code: p.code,
|
||||
creator_id: p.creator_id,
|
||||
max_uses: p.max_uses,
|
||||
uses: p.uses,
|
||||
expires_at: p.expires_at,
|
||||
created_at: p.created_at,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_INVITE_ACCEPT => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::InviteAccepted {
|
||||
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
|
||||
guild_name: p["guild_name"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_INVITE_DELETE => {
|
||||
let p: InviteDeletePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::InviteDeleted {
|
||||
guild_id: p.guild_id,
|
||||
invite_id: p.invite_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_ROLE_CREATE => {
|
||||
let p: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::RoleCreated {
|
||||
role_id: p["id"].as_str().unwrap_or_default().into(),
|
||||
guild_id: p["guild_id"].as_str().unwrap_or_default().into(),
|
||||
name: p["name"].as_str().unwrap_or_default().into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_ROLE_DELETE => {
|
||||
let p: RoleDeletePayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::RoleDeleted {
|
||||
role_id: p.role_id,
|
||||
guild_id: p.guild_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_AUDIT_LOG => {
|
||||
let p: GuildAuditLogPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildAuditLog {
|
||||
guild_id: p.guild_id,
|
||||
entries: p.entries,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_MEMBER_LIST => {
|
||||
let p: GuildMemberListPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildMemberList {
|
||||
guild_id: p.guild_id,
|
||||
members: p.members,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_GUILD_ROLE_LIST => {
|
||||
let p: GuildRoleListPayload = serde_json::from_slice(payload)?;
|
||||
let _ = tx
|
||||
.send(NetEvent::GuildRoleList {
|
||||
guild_id: p.guild_id,
|
||||
roles: p.roles,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
PID_BLOCK_LIST => {
|
||||
// Server may send {"blocked": [...]} — try to parse properly first,
|
||||
// fall back to JSON value extraction.
|
||||
let blocked: Vec<String> = match serde_json::from_slice::<BlockListPayload>(payload) {
|
||||
Ok(p) => p.blocked,
|
||||
Err(_) => {
|
||||
let v: serde_json::Value = serde_json::from_slice(payload)?;
|
||||
v["blocked"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let _ = tx.send(NetEvent::BlockList { blocked }).await;
|
||||
}
|
||||
PID_BLOCK_USER => {
|
||||
let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default();
|
||||
let user_id = v["user_id"]
|
||||
.as_str()
|
||||
.or_else(|| v["blocked"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let _ = tx.send(NetEvent::BlockedUser { user_id }).await;
|
||||
}
|
||||
PID_UNBLOCK_USER => {
|
||||
let v: serde_json::Value = serde_json::from_slice(payload).unwrap_or_default();
|
||||
let user_id = v["user_id"]
|
||||
.as_str()
|
||||
.or_else(|| v["unblocked"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let _ = tx.send(NetEvent::UnblockedUser { user_id }).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
1
src/net/internal/mod.rs
Normal file
1
src/net/internal/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub(super) mod dispatch;
|
||||
43
src/net/mod.rs
Normal file
43
src/net/mod.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
pub mod crypto;
|
||||
pub mod framing;
|
||||
pub mod handshake;
|
||||
mod internal;
|
||||
pub mod payloads;
|
||||
mod session;
|
||||
mod types;
|
||||
pub mod voice;
|
||||
|
||||
pub use types::{ChatMsg, FriendInfo, GuildInfo, MemberInfo, NetCommand, NetEvent, PresenceInfo};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::identity::Identity;
|
||||
|
||||
// ─── Handle ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NetHandle {
|
||||
cmd_tx: mpsc::Sender<NetCommand>,
|
||||
event_rx: Arc<Mutex<mpsc::Receiver<NetEvent>>>,
|
||||
}
|
||||
|
||||
impl NetHandle {
|
||||
pub async fn send(&self, cmd: NetCommand) {
|
||||
let _ = self.cmd_tx.send(cmd).await;
|
||||
}
|
||||
pub fn try_recv(&self) -> Option<NetEvent> {
|
||||
self.event_rx.try_lock().ok()?.try_recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(identity: Identity) -> NetHandle {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<NetCommand>(64);
|
||||
let (event_tx, event_rx) = mpsc::channel::<NetEvent>(256);
|
||||
tokio::spawn(session::net_task(identity, cmd_rx, event_tx));
|
||||
NetHandle {
|
||||
cmd_tx,
|
||||
event_rx: Arc::new(Mutex::new(event_rx)),
|
||||
}
|
||||
}
|
||||
152
src/net/payloads/channel.rs
Normal file
152
src/net/payloads/channel.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct HelloPayload {
|
||||
pub lnex_version: String,
|
||||
pub server_pubkey: String,
|
||||
pub challenge_nonce: String,
|
||||
pub node_name: String,
|
||||
pub server_eph_pubkey: String,
|
||||
#[serde(default)]
|
||||
pub private_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct AuthPayload {
|
||||
pub client_pubkey: String,
|
||||
pub nickname: String,
|
||||
pub lnex_version: String,
|
||||
pub signature: String,
|
||||
pub client_eph_pubkey: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SessionPayload {
|
||||
pub session_id: String,
|
||||
pub token: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PingPayload {
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PongPayload {
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct JoinChannelPayload {
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct LeaveChannelPayload {
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ChannelStatePayload {
|
||||
pub channel_id: String,
|
||||
pub channel_name: String,
|
||||
pub kind: String,
|
||||
pub members: Vec<WireMember>,
|
||||
pub voice_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChannelCreatePayload {
|
||||
pub channel_id: String,
|
||||
pub channel_name: String,
|
||||
/// "text" or "voice".
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub guild_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChannelDeletePayload {
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChannelListPayload {
|
||||
pub channels: Vec<ChannelListItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChannelListItem {
|
||||
pub channel_id: String,
|
||||
pub channel_name: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct WireMember {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub in_voice: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ChatMessagePayload {
|
||||
pub message_id: String,
|
||||
pub channel_id: String,
|
||||
pub sender_id: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
#[serde(default)]
|
||||
pub edited: bool,
|
||||
/// Optional message_id this message is replying to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ChatHistoryPayload {
|
||||
pub channel_id: String,
|
||||
pub messages: Vec<ChatMessagePayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ReactionPayload {
|
||||
pub message_id: String,
|
||||
pub channel_id: String,
|
||||
pub emoji: String,
|
||||
#[serde(default)]
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct MessageEditPayload {
|
||||
pub message_id: String,
|
||||
pub channel_id: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct MessageDeletePayload {
|
||||
pub message_id: String,
|
||||
pub channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UserJoinPayload {
|
||||
pub channel_id: String,
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UserLeavePayload {
|
||||
pub channel_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ErrorPayload {
|
||||
pub code: u32,
|
||||
pub message: String,
|
||||
}
|
||||
133
src/net/payloads/dm.rs
Normal file
133
src/net/payloads/dm.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DmStartPayload {
|
||||
pub target_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DmStartResponsePayload {
|
||||
pub dm_id: String,
|
||||
pub other_user_id: String,
|
||||
pub other_nickname: String,
|
||||
pub messages: Vec<DmMessagePayload>,
|
||||
#[serde(default)]
|
||||
pub unread_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DmMessagePayload {
|
||||
pub dm_id: String,
|
||||
pub sender_id: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DmHistoryPayload {
|
||||
pub dm_id: String,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<DmMessagePayload>,
|
||||
#[serde(default)]
|
||||
pub search_query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PresenceUpdatePayload {
|
||||
pub status: String,
|
||||
/// Free-form activity type ("playing", "listening", "watching", "streaming").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity_type: Option<String>,
|
||||
/// Free-form activity text (e.g. "CS2 ranked").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity_text: Option<String>,
|
||||
/// Free-form custom status text (Discord-style "what's on your mind?").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PresenceInfoPayload {
|
||||
pub user_id: String,
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PresenceSyncPayload {
|
||||
pub presences: Vec<PresenceInfoPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PresenceEventPayload {
|
||||
pub user_id: String,
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendRequestPayload {
|
||||
pub target_user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendAcceptPayload {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendDeclinePayload {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendRemovePayload {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FriendInfoPayload {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub status: String,
|
||||
pub since: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendListPayload {
|
||||
pub friends: Vec<FriendInfoPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct BlockUserPayload {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UnblockUserPayload {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct BlockListPayload {
|
||||
pub blocked: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FriendEventPayload {
|
||||
pub event_type: String,
|
||||
pub user_id: String,
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
176
src/net/payloads/guild.rs
Normal file
176
src/net/payloads/guild.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildCreatePayload {
|
||||
pub name: String,
|
||||
pub icon_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildInfoPayload {
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub owner_id: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub member_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildListPayload {
|
||||
pub guilds: Vec<GuildInfoPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildDeletePayload {
|
||||
pub guild_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildJoinPayload {
|
||||
pub guild_id: String,
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildLeavePayload {
|
||||
pub guild_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UserRoleUpdatePayload {
|
||||
pub user_id: String,
|
||||
pub guild_id: String,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct InviteCreatePayload {
|
||||
pub guild_id: String,
|
||||
pub max_uses: Option<i64>,
|
||||
pub expires_in_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct InviteAcceptPayload {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct InviteDeletePayload {
|
||||
pub guild_id: String,
|
||||
pub invite_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct RoleCreatePayload {
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub color: Option<String>,
|
||||
pub permissions: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct RoleDeletePayload {
|
||||
pub guild_id: String,
|
||||
pub role_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildMemberKickPayload {
|
||||
pub guild_id: String,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct InviteInfoPayload {
|
||||
pub id: String,
|
||||
pub guild_id: String,
|
||||
pub guild_name: String,
|
||||
pub code: String,
|
||||
pub creator_id: String,
|
||||
pub max_uses: Option<i64>,
|
||||
pub uses: i64,
|
||||
pub expires_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildAuditLogFetchPayload {
|
||||
pub guild_id: String,
|
||||
#[serde(default = "default_audit_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
fn default_audit_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AuditLogEntryPayload {
|
||||
pub id: String,
|
||||
pub guild_id: String,
|
||||
pub actor_id: String,
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub target_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildAuditLogPayload {
|
||||
pub guild_id: String,
|
||||
pub entries: Vec<AuditLogEntryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildMemberListFetchPayload {
|
||||
pub guild_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildMemberInfoPayload {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub joined_at: i64,
|
||||
pub role_color: String,
|
||||
pub role_name: String,
|
||||
pub is_owner: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildMemberListPayload {
|
||||
pub guild_id: String,
|
||||
pub members: Vec<GuildMemberInfoPayload>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct RoleAssignPayload {
|
||||
pub guild_id: String,
|
||||
pub user_id: String,
|
||||
pub role_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GuildRoleListFetchPayload {
|
||||
pub guild_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildRoleInfoPayload {
|
||||
pub id: String,
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub permissions: u64,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct GuildRoleListPayload {
|
||||
pub guild_id: String,
|
||||
pub roles: Vec<GuildRoleInfoPayload>,
|
||||
}
|
||||
76
src/net/payloads/mod.rs
Normal file
76
src/net/payloads/mod.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/// Wire payload types and packet ID constants.
|
||||
pub const PID_HELLO: u16 = 0x0001;
|
||||
pub const PID_AUTH: u16 = 0x0002;
|
||||
pub const PID_SESSION: u16 = 0x0003;
|
||||
pub const PID_PING: u16 = 0x0004;
|
||||
pub const PID_PONG: u16 = 0x0005;
|
||||
pub const PID_VOICE_PACKET: u16 = 0x0010;
|
||||
pub const PID_CHAT_MESSAGE: u16 = 0x0020;
|
||||
pub const PID_CHAT_HISTORY: u16 = 0x0021;
|
||||
pub const PID_JOIN_CHANNEL: u16 = 0x0030;
|
||||
pub const PID_LEAVE_CHANNEL: u16 = 0x0031;
|
||||
pub const PID_CHANNEL_STATE: u16 = 0x0032;
|
||||
pub const PID_CHANNEL_CREATE: u16 = 0x0033;
|
||||
pub const PID_CHANNEL_DELETE: u16 = 0x0034;
|
||||
pub const PID_CHANNEL_LIST: u16 = 0x0035;
|
||||
pub const PID_USER_JOIN: u16 = 0x0040;
|
||||
pub const PID_USER_LEAVE: u16 = 0x0041;
|
||||
pub const PID_DM_START: u16 = 0x0060;
|
||||
pub const PID_DM_MESSAGE: u16 = 0x0061;
|
||||
pub const PID_DM_HISTORY: u16 = 0x0062;
|
||||
pub const PID_DM_READ_ACK: u16 = 0x0063;
|
||||
pub const PID_MESSAGE_REACTION_ADD: u16 = 0x0066;
|
||||
pub const PID_MESSAGE_REACTION_REMOVE: u16 = 0x0067;
|
||||
pub const PID_MESSAGE_EDIT: u16 = 0x0068;
|
||||
pub const PID_MESSAGE_DELETE: u16 = 0x0069;
|
||||
pub const PID_READ_RECEIPT: u16 = 0x0064;
|
||||
pub const PID_READ_RECEIPT_BROADCAST: u16 = 0x0065;
|
||||
pub const PID_TYPING_START: u16 = 0x0070;
|
||||
pub const PID_GUILD_CREATE: u16 = 0x0100;
|
||||
pub const PID_GUILD_DELETE: u16 = 0x0101;
|
||||
pub const PID_GUILD_LIST: u16 = 0x0102;
|
||||
pub const PID_GUILD_MEMBER_JOIN: u16 = 0x0103;
|
||||
pub const PID_GUILD_MEMBER_LEAVE: u16 = 0x0104;
|
||||
pub const PID_GUILD_MEMBER_KICK: u16 = 0x0105;
|
||||
pub const PID_ROLE_CREATE: u16 = 0x0106;
|
||||
pub const PID_ROLE_DELETE: u16 = 0x0107;
|
||||
pub const PID_INVITE_CREATE: u16 = 0x0108;
|
||||
pub const PID_INVITE_ACCEPT: u16 = 0x0109;
|
||||
pub const PID_INVITE_DELETE: u16 = 0x010A;
|
||||
pub const PID_USER_ROLE_UPDATE: u16 = 0x010B;
|
||||
pub const PID_GUILD_AUDIT_LOG_FETCH: u16 = 0x010C;
|
||||
pub const PID_GUILD_AUDIT_LOG: u16 = 0x010D;
|
||||
pub const PID_GUILD_MEMBER_LIST_FETCH: u16 = 0x010E;
|
||||
pub const PID_GUILD_MEMBER_LIST: u16 = 0x010F;
|
||||
pub const PID_GUILD_ROLE_ASSIGN: u16 = 0x0110;
|
||||
pub const PID_GUILD_ROLE_UNASSIGN: u16 = 0x0111;
|
||||
pub const PID_GUILD_ROLE_LIST_FETCH: u16 = 0x0112;
|
||||
pub const PID_GUILD_ROLE_LIST: u16 = 0x0113;
|
||||
pub const PID_PERMISSION_CHECK: u16 = 0x0050;
|
||||
pub const PID_PERMISSION_DENY: u16 = 0x0051;
|
||||
pub const PID_PRESENCE_UPDATE: u16 = 0x0140;
|
||||
pub const PID_PRESENCE_SYNC: u16 = 0x0141;
|
||||
pub const PID_PRESENCE_EVENT: u16 = 0x0142;
|
||||
pub const PID_FRIEND_REQUEST: u16 = 0x0150;
|
||||
pub const PID_FRIEND_ACCEPT: u16 = 0x0151;
|
||||
pub const PID_FRIEND_DECLINE: u16 = 0x0152;
|
||||
pub const PID_FRIEND_REMOVE: u16 = 0x0153;
|
||||
pub const PID_FRIEND_LIST: u16 = 0x0154;
|
||||
pub const PID_BLOCK_USER: u16 = 0x0155;
|
||||
pub const PID_UNBLOCK_USER: u16 = 0x0156;
|
||||
pub const PID_BLOCK_LIST: u16 = 0x0157;
|
||||
pub const PID_FRIEND_EVENT: u16 = 0x0158;
|
||||
pub const PID_ERROR: u16 = 0x00F0;
|
||||
pub const PID_DISCONNECT: u16 = 0x00FF;
|
||||
|
||||
pub const LNEX_VERSION: &str = "v1";
|
||||
pub const HDR_SIZE: usize = 12;
|
||||
pub const MAX_PAYLOAD: u32 = 4 * 1024 * 1024;
|
||||
|
||||
mod channel;
|
||||
mod dm;
|
||||
mod guild;
|
||||
|
||||
pub use channel::*;
|
||||
pub use dm::*;
|
||||
pub use guild::*;
|
||||
167
src/net/session/handlers/core.rs
Normal file
167
src/net/session/handlers/core.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{MissedTickBehavior, interval};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::identity::Identity;
|
||||
use crate::net::{
|
||||
framing, handshake, internal,
|
||||
payloads::*,
|
||||
types::{NetCommand, NetEvent},
|
||||
};
|
||||
use serde_json;
|
||||
|
||||
use super::super::switch::ReconnectRequested;
|
||||
use super::{VoiceState, handle_cmd};
|
||||
|
||||
pub async fn session_loop(
|
||||
identity: &Identity,
|
||||
address: &str,
|
||||
channels: &mut Vec<String>,
|
||||
restored: bool,
|
||||
cmd_rx: &mut mpsc::Receiver<NetCommand>,
|
||||
event_tx: &mpsc::Sender<NetEvent>,
|
||||
) -> Result<()> {
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
let mut seq = 0u32;
|
||||
|
||||
let (session_id, node_name, private_mode, crypto) =
|
||||
handshake::run(&mut stream, identity, &mut seq).await?;
|
||||
let _ = event_tx
|
||||
.send(NetEvent::Connected {
|
||||
session_id: session_id.clone(),
|
||||
node_name,
|
||||
restored,
|
||||
private_mode,
|
||||
})
|
||||
.await;
|
||||
|
||||
for channel_id in channels.clone() {
|
||||
framing::io::write_encrypted(
|
||||
&mut stream,
|
||||
PID_JOIN_CHANNEL,
|
||||
&mut seq,
|
||||
&serde_json::to_vec(&JoinChannelPayload { channel_id })?,
|
||||
&crypto,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut voice_state = VoiceState::new(&crypto, event_tx).await?;
|
||||
let mut voice_endpoint: Option<std::net::SocketAddr> = None;
|
||||
let mut ping_tick = interval(Duration::from_secs(2));
|
||||
ping_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ping_tick.tick() => {
|
||||
let ts = now_ms();
|
||||
let payload = serde_json::to_vec(&PingPayload { timestamp: ts })?;
|
||||
framing::io::write_encrypted(&mut stream, PID_PING, &mut seq, &payload, &crypto).await?;
|
||||
}
|
||||
result = framing::io::read_encrypted(&mut stream, &crypto) => {
|
||||
let pkt = result?;
|
||||
if pkt.packet_id == PID_CHANNEL_STATE
|
||||
&& let Ok(p) = serde_json::from_slice::<ChannelStatePayload>(&pkt.payload)
|
||||
{
|
||||
voice_endpoint = if p.voice_endpoint.is_empty() {
|
||||
None
|
||||
} else {
|
||||
p.voice_endpoint.parse().ok()
|
||||
};
|
||||
}
|
||||
internal::dispatch::incoming(pkt.packet_id, &pkt.payload, event_tx).await?;
|
||||
}
|
||||
cmd = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
None | Some(NetCommand::Disconnect) => {
|
||||
let _ = framing::io::write_encrypted(&mut stream, PID_DISCONNECT, &mut seq,
|
||||
&serde_json::to_vec(&serde_json::json!({"reason":"bye"})).unwrap(), &crypto).await;
|
||||
return Ok(());
|
||||
}
|
||||
Some(NetCommand::Connect { address, auto_reconnect }) => {
|
||||
warn!("connect while connected to {address}, reconnecting");
|
||||
if channels.is_empty() {
|
||||
*channels = vec!["general".into()];
|
||||
}
|
||||
return Err(anyhow::Error::new(ReconnectRequested {
|
||||
address,
|
||||
auto_reconnect,
|
||||
}));
|
||||
}
|
||||
Some(NetCommand::JoinChannel { channel_id }) => {
|
||||
track_channel(channels, &channel_id);
|
||||
framing::io::write_encrypted(&mut stream, PID_JOIN_CHANNEL, &mut seq,
|
||||
&serde_json::to_vec(&JoinChannelPayload { channel_id })?, &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::LeaveChannel { channel_id }) => {
|
||||
channels.retain(|c| c != &channel_id);
|
||||
framing::io::write_encrypted(&mut stream, PID_LEAVE_CHANNEL, &mut seq,
|
||||
&serde_json::to_vec(&LeaveChannelPayload { channel_id })?, &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::ChannelCreate { channel_id, channel_name, kind }) => {
|
||||
let payload = serde_json::to_vec(&ChannelCreatePayload {
|
||||
channel_id,
|
||||
channel_name,
|
||||
kind,
|
||||
guild_id: None,
|
||||
})?;
|
||||
framing::io::write_encrypted(&mut stream, PID_CHANNEL_CREATE, &mut seq, &payload, &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::ChannelDelete { channel_id }) => {
|
||||
let payload = serde_json::to_vec(&ChannelDeletePayload { channel_id })?;
|
||||
framing::io::write_encrypted(&mut stream, PID_CHANNEL_DELETE, &mut seq, &payload, &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::ChannelList) => {
|
||||
framing::io::write_encrypted(&mut stream, PID_CHANNEL_LIST, &mut seq, b"{}", &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::SendChat { channel_id, content, reply_to }) => {
|
||||
let msg = ChatMessagePayload {
|
||||
message_id: uuid_v4(), channel_id,
|
||||
sender_id: identity.pubkey_hex.clone(),
|
||||
content, timestamp: now_ms(),
|
||||
edited: false,
|
||||
reply_to,
|
||||
};
|
||||
framing::io::write_encrypted(&mut stream, PID_CHAT_MESSAGE, &mut seq, &serde_json::to_vec(&msg)?, &crypto).await?;
|
||||
}
|
||||
Some(NetCommand::SendVoice { channel_id, voice_seq: voice_pkt_seq, timestamp, opus_data }) => {
|
||||
let sender_pubkey = identity.verifying_key()
|
||||
.map(|k| k.to_bytes())
|
||||
.unwrap_or([0u8; 32]);
|
||||
voice_state.send_voice(channel_id, voice_pkt_seq, timestamp, &opus_data, &crypto, voice_endpoint, &sender_pubkey).await?;
|
||||
}
|
||||
Some(NetCommand::ReadReceipt { channel_id, last_read_message_id }) => {
|
||||
let payload = serde_json::to_vec(&serde_json::json!({
|
||||
"channel_id": channel_id,
|
||||
"last_read_message_id": last_read_message_id,
|
||||
"user_id": identity.pubkey_hex.clone(),
|
||||
}))?;
|
||||
framing::io::write_encrypted(&mut stream, PID_READ_RECEIPT, &mut seq, &payload, &crypto).await?;
|
||||
}
|
||||
Some(other) => handle_cmd(other, &mut stream, &mut seq, &crypto, identity).await?,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
pub(super) fn uuid_v4() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn track_channel(channels: &mut Vec<String>, channel_id: &str) {
|
||||
if !channels.iter().any(|c| c == channel_id) {
|
||||
channels.push(channel_id.to_string());
|
||||
}
|
||||
}
|
||||
7
src/net/session/handlers/mod.rs
Normal file
7
src/net/session/handlers/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod core;
|
||||
mod social;
|
||||
mod voice;
|
||||
|
||||
pub use core::session_loop;
|
||||
pub use social::handle_cmd;
|
||||
pub use voice::State as VoiceState;
|
||||
62
src/net/session/handlers/social/friends.rs
Normal file
62
src/net/session/handlers/social/friends.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use anyhow::Result;
|
||||
use serde_json;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
|
||||
|
||||
pub async fn handle_cmd(
|
||||
cmd: NetCommand,
|
||||
stream: &mut TcpStream,
|
||||
seq: &mut u32,
|
||||
crypto: &SessionCrypto,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
NetCommand::PresenceUpdate {
|
||||
status,
|
||||
custom_status,
|
||||
activity,
|
||||
activity_text,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&PresenceUpdatePayload {
|
||||
status,
|
||||
activity_type: activity,
|
||||
activity_text,
|
||||
custom_status,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_PRESENCE_UPDATE, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::FriendRequest { target_user_id } => {
|
||||
let payload = serde_json::to_vec(&FriendRequestPayload { target_user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_FRIEND_REQUEST, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::FriendAccept { user_id } => {
|
||||
let payload = serde_json::to_vec(&FriendAcceptPayload { user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_FRIEND_ACCEPT, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::FriendDecline { user_id } => {
|
||||
let payload = serde_json::to_vec(&FriendDeclinePayload { user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_FRIEND_DECLINE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::FriendRemove { user_id } => {
|
||||
let payload = serde_json::to_vec(&FriendRemovePayload { user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_FRIEND_REMOVE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::FriendList => {
|
||||
framing::io::write_encrypted(stream, PID_FRIEND_LIST, seq, b"{}", crypto).await?;
|
||||
}
|
||||
NetCommand::BlockUser { user_id } => {
|
||||
let payload = serde_json::to_vec(&BlockUserPayload { user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_BLOCK_USER, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::UnblockUser { user_id } => {
|
||||
let payload = serde_json::to_vec(&UnblockUserPayload { user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_UNBLOCK_USER, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::BlockList => {
|
||||
framing::io::write_encrypted(stream, PID_BLOCK_LIST, seq, b"{}", crypto).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
143
src/net/session/handlers/social/guild.rs
Normal file
143
src/net/session/handlers/social/guild.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use anyhow::Result;
|
||||
use serde_json;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
|
||||
|
||||
pub async fn handle_cmd(
|
||||
cmd: NetCommand,
|
||||
stream: &mut TcpStream,
|
||||
seq: &mut u32,
|
||||
crypto: &SessionCrypto,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
NetCommand::GuildCreate { name } => {
|
||||
let payload = serde_json::to_vec(&GuildCreatePayload {
|
||||
name,
|
||||
icon_url: None,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_CREATE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::GuildDelete { guild_id } => {
|
||||
let payload = serde_json::to_vec(&GuildDeletePayload { guild_id })?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_DELETE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::GuildList => {
|
||||
framing::io::write_encrypted(stream, PID_GUILD_LIST, seq, b"{}", crypto).await?;
|
||||
}
|
||||
NetCommand::GuildJoin {
|
||||
guild_id,
|
||||
invite_code,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&GuildJoinPayload {
|
||||
guild_id,
|
||||
invite_code,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_JOIN, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildLeave { guild_id } => {
|
||||
let payload = serde_json::to_vec(&GuildLeavePayload { guild_id })?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_LEAVE, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::InviteCreate {
|
||||
guild_id,
|
||||
max_uses,
|
||||
expires_in_seconds,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&InviteCreatePayload {
|
||||
guild_id,
|
||||
max_uses,
|
||||
expires_in_seconds,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_INVITE_CREATE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::InviteAccept { code } => {
|
||||
let payload = serde_json::to_vec(&InviteAcceptPayload { code })?;
|
||||
framing::io::write_encrypted(stream, PID_INVITE_ACCEPT, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::InviteDelete {
|
||||
guild_id,
|
||||
invite_id,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&InviteDeletePayload {
|
||||
guild_id,
|
||||
invite_id,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_INVITE_DELETE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::RoleCreate {
|
||||
guild_id,
|
||||
name,
|
||||
color,
|
||||
permissions,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&RoleCreatePayload {
|
||||
guild_id,
|
||||
name,
|
||||
color,
|
||||
permissions,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_ROLE_CREATE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::RoleDelete { guild_id, role_id } => {
|
||||
let payload = serde_json::to_vec(&RoleDeletePayload { guild_id, role_id })?;
|
||||
framing::io::write_encrypted(stream, PID_ROLE_DELETE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::GuildMemberKick { guild_id, user_id } => {
|
||||
let payload = serde_json::to_vec(&GuildMemberKickPayload { guild_id, user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_MEMBER_KICK, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildAuditLogFetch { guild_id, limit } => {
|
||||
let payload = serde_json::to_vec(&GuildAuditLogFetchPayload { guild_id, limit })?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_AUDIT_LOG_FETCH, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildMemberListFetch { guild_id } => {
|
||||
let payload = serde_json::to_vec(&GuildMemberListFetchPayload { guild_id })?;
|
||||
framing::io::write_encrypted(
|
||||
stream,
|
||||
PID_GUILD_MEMBER_LIST_FETCH,
|
||||
seq,
|
||||
&payload,
|
||||
crypto,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildRoleAssign {
|
||||
guild_id,
|
||||
user_id,
|
||||
role_id,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&RoleAssignPayload {
|
||||
guild_id,
|
||||
user_id,
|
||||
role_id,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_ROLE_ASSIGN, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildRoleUnassign {
|
||||
guild_id,
|
||||
user_id,
|
||||
role_id,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&RoleAssignPayload {
|
||||
guild_id,
|
||||
user_id,
|
||||
role_id,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_ROLE_UNASSIGN, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::GuildRoleListFetch { guild_id } => {
|
||||
let payload = serde_json::to_vec(&GuildRoleListFetchPayload { guild_id })?;
|
||||
framing::io::write_encrypted(stream, PID_GUILD_ROLE_LIST_FETCH, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
146
src/net/session/handlers/social/mod.rs
Normal file
146
src/net/session/handlers/social/mod.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
mod friends;
|
||||
mod guild;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::identity::Identity;
|
||||
use crate::net::{crypto::SessionCrypto, framing, payloads::*, types::NetCommand};
|
||||
|
||||
use super::core::now_ms;
|
||||
|
||||
pub async fn handle_cmd(
|
||||
cmd: NetCommand,
|
||||
stream: &mut TcpStream,
|
||||
seq: &mut u32,
|
||||
crypto: &SessionCrypto,
|
||||
identity: &Identity,
|
||||
) -> Result<()> {
|
||||
match cmd {
|
||||
cmd @ (NetCommand::GuildCreate { .. }
|
||||
| NetCommand::GuildDelete { .. }
|
||||
| NetCommand::GuildList
|
||||
| NetCommand::GuildJoin { .. }
|
||||
| NetCommand::GuildLeave { .. }
|
||||
| NetCommand::InviteCreate { .. }
|
||||
| NetCommand::InviteAccept { .. }
|
||||
| NetCommand::InviteDelete { .. }
|
||||
| NetCommand::RoleCreate { .. }
|
||||
| NetCommand::RoleDelete { .. }
|
||||
| NetCommand::GuildMemberKick { .. }
|
||||
| NetCommand::GuildAuditLogFetch { .. }
|
||||
| NetCommand::GuildMemberListFetch { .. }
|
||||
| NetCommand::GuildRoleAssign { .. }
|
||||
| NetCommand::GuildRoleUnassign { .. }
|
||||
| NetCommand::GuildRoleListFetch { .. }) => {
|
||||
guild::handle_cmd(cmd, stream, seq, crypto).await?
|
||||
}
|
||||
|
||||
cmd @ (NetCommand::PresenceUpdate { .. }
|
||||
| NetCommand::FriendRequest { .. }
|
||||
| NetCommand::FriendAccept { .. }
|
||||
| NetCommand::FriendDecline { .. }
|
||||
| NetCommand::FriendRemove { .. }
|
||||
| NetCommand::FriendList
|
||||
| NetCommand::BlockUser { .. }
|
||||
| NetCommand::UnblockUser { .. }
|
||||
| NetCommand::BlockList) => friends::handle_cmd(cmd, stream, seq, crypto).await?,
|
||||
|
||||
NetCommand::ReactionAdd {
|
||||
channel_id,
|
||||
message_id,
|
||||
emoji,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&ReactionPayload {
|
||||
message_id,
|
||||
channel_id,
|
||||
emoji,
|
||||
user_id: String::new(),
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_MESSAGE_REACTION_ADD, seq, &payload, crypto)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::ReactionRemove {
|
||||
channel_id,
|
||||
message_id,
|
||||
emoji,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&ReactionPayload {
|
||||
message_id,
|
||||
channel_id,
|
||||
emoji,
|
||||
user_id: String::new(),
|
||||
})?;
|
||||
framing::io::write_encrypted(
|
||||
stream,
|
||||
PID_MESSAGE_REACTION_REMOVE,
|
||||
seq,
|
||||
&payload,
|
||||
crypto,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::MessageEdit {
|
||||
channel_id,
|
||||
message_id,
|
||||
content,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&MessageEditPayload {
|
||||
message_id,
|
||||
channel_id,
|
||||
content,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_MESSAGE_EDIT, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::MessageDelete {
|
||||
channel_id,
|
||||
message_id,
|
||||
} => {
|
||||
let payload = serde_json::to_vec(&MessageDeletePayload {
|
||||
message_id,
|
||||
channel_id,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_MESSAGE_DELETE, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::DmStart { target_user_id } => {
|
||||
let payload = serde_json::to_vec(&DmStartPayload { target_user_id })?;
|
||||
framing::io::write_encrypted(stream, PID_DM_START, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::DmSend { dm_id, content } => {
|
||||
let msg = DmMessagePayload {
|
||||
dm_id,
|
||||
sender_id: identity.pubkey_hex.clone(),
|
||||
content,
|
||||
timestamp: now_ms(),
|
||||
};
|
||||
framing::io::write_encrypted(
|
||||
stream,
|
||||
PID_DM_MESSAGE,
|
||||
seq,
|
||||
&serde_json::to_vec(&msg)?,
|
||||
crypto,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
NetCommand::TypingStart { channel_id } => {
|
||||
let payload = serde_json::to_vec(&serde_json::json!({"channel_id": channel_id}))?;
|
||||
framing::io::write_encrypted(stream, PID_TYPING_START, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::DmReadAck { dm_id } => {
|
||||
let payload = serde_json::to_vec(&serde_json::json!({"dm_id": dm_id}))?;
|
||||
framing::io::write_encrypted(stream, PID_DM_READ_ACK, seq, &payload, crypto).await?;
|
||||
}
|
||||
NetCommand::DmSearch { dm_id, query } => {
|
||||
let payload = serde_json::to_vec(&DmHistoryPayload {
|
||||
dm_id,
|
||||
messages: vec![],
|
||||
search_query: if query.is_empty() { None } else { Some(query) },
|
||||
limit: None,
|
||||
})?;
|
||||
framing::io::write_encrypted(stream, PID_DM_HISTORY, seq, &payload, crypto).await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
47
src/net/session/handlers/voice.rs
Normal file
47
src/net/session/handlers/voice.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::net::{crypto::SessionCrypto, types::NetEvent};
|
||||
|
||||
pub struct State {
|
||||
pub udp: Arc<UdpSocket>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub async fn new(crypto: &SessionCrypto, event_tx: &mpsc::Sender<NetEvent>) -> Result<Self> {
|
||||
let udp = Arc::new(UdpSocket::bind("0.0.0.0:0").await?);
|
||||
crate::net::voice::spawn_recv(udp.clone(), (*crypto).clone(), event_tx.clone());
|
||||
Ok(Self { udp, seq: 0 })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_voice(
|
||||
&mut self,
|
||||
channel_id: u64,
|
||||
voice_pkt_seq: u32,
|
||||
timestamp: u32,
|
||||
opus_data: &[u8],
|
||||
crypto: &SessionCrypto,
|
||||
endpoint: Option<std::net::SocketAddr>,
|
||||
sender_pubkey: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
if let Some(ep) = endpoint {
|
||||
let pkt = crate::net::voice::build_packet(
|
||||
channel_id,
|
||||
voice_pkt_seq,
|
||||
timestamp,
|
||||
opus_data,
|
||||
crypto,
|
||||
self.seq,
|
||||
sender_pubkey,
|
||||
);
|
||||
let _ = self.udp.send_to(&pkt, ep).await;
|
||||
self.seq += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
src/net/session/mod.rs
Normal file
6
src/net/session/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod handlers;
|
||||
mod reconnect;
|
||||
mod switch;
|
||||
mod task;
|
||||
|
||||
pub use task::net_task;
|
||||
24
src/net/session/reconnect.rs
Normal file
24
src/net/session/reconnect.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
use std::time::Duration;
|
||||
|
||||
/// Max delay between reconnect attempts.
|
||||
pub const MAX_BACKOFF_SECS: u64 = 30;
|
||||
|
||||
/// Exponential backoff after a failed session (1s, 2s, 4s, … capped at 30s).
|
||||
pub fn backoff_delay(failure_count: u32) -> Duration {
|
||||
let shift = failure_count.min(5);
|
||||
let secs = (1u64 << shift).min(MAX_BACKOFF_SECS);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_and_caps() {
|
||||
assert_eq!(backoff_delay(0).as_secs(), 1);
|
||||
assert_eq!(backoff_delay(1).as_secs(), 2);
|
||||
assert_eq!(backoff_delay(3).as_secs(), 8);
|
||||
assert_eq!(backoff_delay(10).as_secs(), MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
20
src/net/session/switch.rs
Normal file
20
src/net/session/switch.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/// User switched node while connected (`NetCommand::Connect`).
|
||||
#[derive(Debug)]
|
||||
pub struct ReconnectRequested {
|
||||
pub address: String,
|
||||
pub auto_reconnect: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ReconnectRequested {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "reconnect to {}", self.address)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ReconnectRequested {}
|
||||
|
||||
pub struct SessionTarget {
|
||||
pub address: String,
|
||||
pub auto_reconnect: bool,
|
||||
pub channels: Vec<String>,
|
||||
}
|
||||
116
src/net/session/task.rs
Normal file
116
src/net/session/task.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::identity::Identity;
|
||||
use crate::net::types::{NetCommand, NetEvent};
|
||||
|
||||
use super::{
|
||||
handlers::session_loop,
|
||||
reconnect,
|
||||
switch::{ReconnectRequested, SessionTarget},
|
||||
};
|
||||
|
||||
pub async fn net_task(
|
||||
identity: Identity,
|
||||
mut cmd_rx: mpsc::Receiver<NetCommand>,
|
||||
event_tx: mpsc::Sender<NetEvent>,
|
||||
) {
|
||||
let mut target: Option<SessionTarget> = None;
|
||||
let mut failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
let Some(mut session) = target.take() else {
|
||||
match wait_for_connect(&mut cmd_rx).await {
|
||||
Some(t) => {
|
||||
failures = 0;
|
||||
target = Some(t);
|
||||
}
|
||||
None => return,
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if failures > 0 {
|
||||
let delay = reconnect::backoff_delay(failures - 1);
|
||||
let _ = event_tx
|
||||
.send(NetEvent::Reconnecting {
|
||||
attempt: failures,
|
||||
delay_secs: delay.as_secs(),
|
||||
})
|
||||
.await;
|
||||
sleep(delay).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
"connecting to {} (attempt {})",
|
||||
session.address,
|
||||
failures + 1
|
||||
);
|
||||
|
||||
let restored = failures > 0;
|
||||
match session_loop(
|
||||
&identity,
|
||||
&session.address,
|
||||
&mut session.channels,
|
||||
restored,
|
||||
&mut cmd_rx,
|
||||
&event_tx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
failures = 0;
|
||||
let _ = event_tx
|
||||
.send(NetEvent::Disconnected {
|
||||
reason: "disconnected".into(),
|
||||
will_retry: false,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(req) = e.downcast_ref::<ReconnectRequested>() {
|
||||
info!("switching node to {}", req.address);
|
||||
session.address = req.address.clone();
|
||||
session.auto_reconnect = req.auto_reconnect;
|
||||
failures = 0;
|
||||
target = Some(session);
|
||||
continue;
|
||||
}
|
||||
error!("session ended: {e}");
|
||||
let retry = session.auto_reconnect;
|
||||
let _ = event_tx
|
||||
.send(NetEvent::Disconnected {
|
||||
reason: e.to_string(),
|
||||
will_retry: retry,
|
||||
})
|
||||
.await;
|
||||
if retry {
|
||||
failures = failures.saturating_add(1);
|
||||
target = Some(session);
|
||||
} else {
|
||||
failures = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_connect(cmd_rx: &mut mpsc::Receiver<NetCommand>) -> Option<SessionTarget> {
|
||||
loop {
|
||||
match cmd_rx.recv().await {
|
||||
Some(NetCommand::Connect {
|
||||
address,
|
||||
auto_reconnect,
|
||||
}) => {
|
||||
return Some(SessionTarget {
|
||||
address,
|
||||
auto_reconnect,
|
||||
channels: vec!["general".into()],
|
||||
});
|
||||
}
|
||||
Some(NetCommand::Disconnect) | None => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/net/types/command.rs
Normal file
162
src/net/types/command.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#[derive(Debug)]
|
||||
pub enum NetCommand {
|
||||
Connect {
|
||||
address: String,
|
||||
auto_reconnect: bool,
|
||||
},
|
||||
JoinChannel {
|
||||
channel_id: String,
|
||||
},
|
||||
LeaveChannel {
|
||||
channel_id: String,
|
||||
},
|
||||
ChannelCreate {
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
kind: String,
|
||||
},
|
||||
ChannelDelete {
|
||||
channel_id: String,
|
||||
},
|
||||
ChannelList,
|
||||
SendChat {
|
||||
channel_id: String,
|
||||
content: String,
|
||||
/// Optional message_id this message is replying to.
|
||||
reply_to: Option<String>,
|
||||
},
|
||||
SendVoice {
|
||||
channel_id: u64,
|
||||
voice_seq: u32,
|
||||
timestamp: u32,
|
||||
opus_data: Vec<u8>,
|
||||
},
|
||||
DmStart {
|
||||
target_user_id: String,
|
||||
},
|
||||
DmSend {
|
||||
dm_id: String,
|
||||
content: String,
|
||||
},
|
||||
DmReadAck {
|
||||
dm_id: String,
|
||||
},
|
||||
DmSearch {
|
||||
dm_id: String,
|
||||
query: String,
|
||||
},
|
||||
GuildCreate {
|
||||
name: String,
|
||||
},
|
||||
GuildDelete {
|
||||
guild_id: String,
|
||||
},
|
||||
GuildList,
|
||||
GuildJoin {
|
||||
guild_id: String,
|
||||
invite_code: Option<String>,
|
||||
},
|
||||
GuildLeave {
|
||||
guild_id: String,
|
||||
},
|
||||
InviteCreate {
|
||||
guild_id: String,
|
||||
max_uses: Option<i64>,
|
||||
expires_in_seconds: Option<i64>,
|
||||
},
|
||||
InviteAccept {
|
||||
code: String,
|
||||
},
|
||||
InviteDelete {
|
||||
guild_id: String,
|
||||
invite_id: String,
|
||||
},
|
||||
RoleCreate {
|
||||
guild_id: String,
|
||||
name: String,
|
||||
color: Option<String>,
|
||||
permissions: Option<u64>,
|
||||
},
|
||||
RoleDelete {
|
||||
guild_id: String,
|
||||
role_id: String,
|
||||
},
|
||||
GuildMemberKick {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
GuildAuditLogFetch {
|
||||
guild_id: String,
|
||||
limit: i64,
|
||||
},
|
||||
GuildMemberListFetch {
|
||||
guild_id: String,
|
||||
},
|
||||
GuildRoleAssign {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
role_id: String,
|
||||
},
|
||||
GuildRoleUnassign {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
role_id: String,
|
||||
},
|
||||
GuildRoleListFetch {
|
||||
guild_id: String,
|
||||
},
|
||||
PresenceUpdate {
|
||||
status: String,
|
||||
custom_status: Option<String>,
|
||||
activity: Option<String>,
|
||||
/// Free-form text describing the current activity (e.g. "playing CS2").
|
||||
activity_text: Option<String>,
|
||||
},
|
||||
TypingStart {
|
||||
channel_id: String,
|
||||
},
|
||||
ReadReceipt {
|
||||
channel_id: String,
|
||||
last_read_message_id: String,
|
||||
},
|
||||
FriendRequest {
|
||||
target_user_id: String,
|
||||
},
|
||||
FriendAccept {
|
||||
user_id: String,
|
||||
},
|
||||
FriendDecline {
|
||||
user_id: String,
|
||||
},
|
||||
FriendRemove {
|
||||
user_id: String,
|
||||
},
|
||||
FriendList,
|
||||
BlockUser {
|
||||
user_id: String,
|
||||
},
|
||||
UnblockUser {
|
||||
user_id: String,
|
||||
},
|
||||
BlockList,
|
||||
ReactionAdd {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
emoji: String,
|
||||
},
|
||||
ReactionRemove {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
emoji: String,
|
||||
},
|
||||
MessageEdit {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
content: String,
|
||||
},
|
||||
MessageDelete {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
},
|
||||
Disconnect,
|
||||
}
|
||||
227
src/net/types/event.rs
Normal file
227
src/net/types/event.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
use super::info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NetEvent {
|
||||
Connected {
|
||||
session_id: String,
|
||||
node_name: String,
|
||||
restored: bool,
|
||||
private_mode: bool,
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
will_retry: bool,
|
||||
},
|
||||
Reconnecting {
|
||||
attempt: u32,
|
||||
delay_secs: u64,
|
||||
},
|
||||
ChannelState {
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
kind: String,
|
||||
members: Vec<MemberInfo>,
|
||||
voice_endpoint: String,
|
||||
},
|
||||
ChannelCreated {
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
kind: String,
|
||||
},
|
||||
ChannelDeleted {
|
||||
channel_id: String,
|
||||
},
|
||||
ChannelListEvent {
|
||||
channels: Vec<ChannelListItem>,
|
||||
},
|
||||
ChatMessage {
|
||||
message_id: String,
|
||||
channel_id: String,
|
||||
sender_id: String,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: i64,
|
||||
/// Optional message_id this message is replying to.
|
||||
reply_to: Option<String>,
|
||||
},
|
||||
ChatHistory {
|
||||
channel_id: String,
|
||||
messages: Vec<ChatMsg>,
|
||||
},
|
||||
UserJoin {
|
||||
channel_id: String,
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
},
|
||||
UserLeave {
|
||||
channel_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
VoicePacket {
|
||||
channel_id: u64,
|
||||
voice_seq: u32,
|
||||
timestamp: u32,
|
||||
sender: SocketAddr,
|
||||
/// Hex-encoded Ed25519 public key of the sender (Phase 1.3 attribution).
|
||||
/// Empty for legacy packets that didn't include sender_id.
|
||||
sender_id: String,
|
||||
opus_data: Vec<u8>,
|
||||
},
|
||||
Error {
|
||||
code: u32,
|
||||
message: String,
|
||||
},
|
||||
LatencyUpdate {
|
||||
rtt_ms: u32,
|
||||
},
|
||||
DmStart {
|
||||
dm_id: String,
|
||||
other_user_id: String,
|
||||
other_nickname: String,
|
||||
messages: Vec<ChatMsg>,
|
||||
unread_count: u32,
|
||||
},
|
||||
DmMessage {
|
||||
dm_id: String,
|
||||
sender_id: String,
|
||||
content: String,
|
||||
timestamp: i64,
|
||||
},
|
||||
DmHistory {
|
||||
dm_id: String,
|
||||
messages: Vec<ChatMsg>,
|
||||
},
|
||||
TypingStart {
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
channel_id: String,
|
||||
},
|
||||
ReadReceiptBroadcast {
|
||||
channel_id: String,
|
||||
user_id: String,
|
||||
last_read_message_id: String,
|
||||
},
|
||||
GuildList {
|
||||
guilds: Vec<GuildInfo>,
|
||||
},
|
||||
GuildCreated {
|
||||
guild_id: String,
|
||||
name: String,
|
||||
owner_id: String,
|
||||
},
|
||||
GuildDeleted {
|
||||
guild_id: String,
|
||||
},
|
||||
GuildMemberJoined {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
},
|
||||
GuildMemberLeft {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
GuildMemberKicked {
|
||||
guild_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
InviteCreated {
|
||||
id: String,
|
||||
guild_id: String,
|
||||
guild_name: String,
|
||||
code: String,
|
||||
creator_id: String,
|
||||
max_uses: Option<i64>,
|
||||
uses: i64,
|
||||
expires_at: Option<i64>,
|
||||
created_at: i64,
|
||||
},
|
||||
InviteAccepted {
|
||||
guild_id: String,
|
||||
guild_name: String,
|
||||
},
|
||||
InviteDeleted {
|
||||
guild_id: String,
|
||||
invite_id: String,
|
||||
},
|
||||
RoleCreated {
|
||||
role_id: String,
|
||||
guild_id: String,
|
||||
name: String,
|
||||
},
|
||||
RoleDeleted {
|
||||
role_id: String,
|
||||
guild_id: String,
|
||||
},
|
||||
GuildAuditLog {
|
||||
guild_id: String,
|
||||
entries: Vec<crate::net::payloads::AuditLogEntryPayload>,
|
||||
},
|
||||
GuildMemberList {
|
||||
guild_id: String,
|
||||
members: Vec<crate::net::payloads::GuildMemberInfoPayload>,
|
||||
},
|
||||
GuildRoleList {
|
||||
guild_id: String,
|
||||
roles: Vec<crate::net::payloads::GuildRoleInfoPayload>,
|
||||
},
|
||||
UserColor {
|
||||
user_id: String,
|
||||
color: String,
|
||||
},
|
||||
PresenceSync {
|
||||
presences: Vec<PresenceInfo>,
|
||||
},
|
||||
PresenceUpdated {
|
||||
user_id: String,
|
||||
status: String,
|
||||
custom_status: Option<String>,
|
||||
activity: Option<String>,
|
||||
},
|
||||
FriendList {
|
||||
friends: Vec<FriendInfo>,
|
||||
},
|
||||
FriendRequested {
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
},
|
||||
FriendAccepted {
|
||||
user_id: String,
|
||||
nickname: String,
|
||||
},
|
||||
FriendRemoved {
|
||||
user_id: String,
|
||||
},
|
||||
BlockList {
|
||||
blocked: Vec<String>,
|
||||
},
|
||||
BlockedUser {
|
||||
user_id: String,
|
||||
},
|
||||
UnblockedUser {
|
||||
user_id: String,
|
||||
},
|
||||
ReactionAdded {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
user_id: String,
|
||||
emoji: String,
|
||||
},
|
||||
ReactionRemoved {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
user_id: String,
|
||||
emoji: String,
|
||||
},
|
||||
MessageEdited {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
content: String,
|
||||
edited: bool,
|
||||
},
|
||||
MessageDeleted {
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
},
|
||||
}
|
||||
49
src/net/types/info.rs
Normal file
49
src/net/types/info.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct MemberInfo {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub in_voice: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelListItem {
|
||||
pub channel_id: String,
|
||||
pub channel_name: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMsg {
|
||||
pub message_id: String,
|
||||
pub sender_id: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
/// Optional message_id this message is replying to.
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GuildInfo {
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub owner_id: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub member_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresenceInfo {
|
||||
pub user_id: String,
|
||||
pub status: String,
|
||||
pub custom_status: Option<String>,
|
||||
pub activity: Option<String>,
|
||||
pub activity_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FriendInfo {
|
||||
pub user_id: String,
|
||||
pub nickname: String,
|
||||
pub status: String,
|
||||
pub since: i64,
|
||||
}
|
||||
8
src/net/types/mod.rs
Normal file
8
src/net/types/mod.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// Public API types: commands UI→net and events net→UI.
|
||||
mod command;
|
||||
mod event;
|
||||
mod info;
|
||||
|
||||
pub use command::NetCommand;
|
||||
pub use event::NetEvent;
|
||||
pub use info::{ChannelListItem, ChatMsg, FriendInfo, GuildInfo, MemberInfo, PresenceInfo};
|
||||
84
src/net/voice/mod.rs
Normal file
84
src/net/voice/mod.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
use super::crypto::SessionCrypto;
|
||||
use super::payloads::PID_VOICE_PACKET;
|
||||
|
||||
mod receive;
|
||||
pub use receive::spawn_recv;
|
||||
|
||||
/// Stable string channel id to u64 key for UDP voice packets.
|
||||
pub fn channel_key(id: &str) -> u64 {
|
||||
let mut h: u64 = 5381;
|
||||
for b in id.bytes() {
|
||||
h = h.wrapping_mul(33).wrapping_add(u64::from(b));
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Build a UDP voice packet with encryption.
|
||||
///
|
||||
/// Plaintext layout (Phase 1.2+):
|
||||
/// `[voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus_data:N]`
|
||||
///
|
||||
/// `sender_id` is the raw Ed25519 public key of the sender, so receivers can
|
||||
/// attribute the voice activity to a specific user (Phase 1.3 speaking indicators).
|
||||
pub fn build_packet(
|
||||
channel_id: u64,
|
||||
voice_seq: u32,
|
||||
timestamp: u32,
|
||||
opus: &[u8],
|
||||
crypto: &SessionCrypto,
|
||||
packet_seq: u64,
|
||||
sender_pubkey: &[u8; 32],
|
||||
) -> Vec<u8> {
|
||||
let mut plaintext = Vec::with_capacity(16 + 32 + opus.len());
|
||||
plaintext.extend_from_slice(&voice_seq.to_be_bytes());
|
||||
plaintext.extend_from_slice(×tamp.to_be_bytes());
|
||||
plaintext.extend_from_slice(&channel_id.to_be_bytes());
|
||||
plaintext.extend_from_slice(sender_pubkey);
|
||||
plaintext.extend_from_slice(opus);
|
||||
|
||||
let encrypted = crypto.encrypt_c2s(packet_seq, &plaintext);
|
||||
|
||||
let mut buf = Vec::with_capacity(4 + encrypted.len());
|
||||
buf.extend_from_slice(&PID_VOICE_PACKET.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes());
|
||||
buf.extend_from_slice(&encrypted);
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn channel_key_is_stable() {
|
||||
assert_eq!(channel_key("voice"), channel_key("voice"));
|
||||
assert_ne!(channel_key("voice"), channel_key("general"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_packet_header_layout() {
|
||||
let key = channel_key("voice");
|
||||
let shared = [0u8; 32];
|
||||
let crypto = SessionCrypto::derive(&shared, "test");
|
||||
let sender = [0xABu8; 32];
|
||||
let pkt = build_packet(key, 42, 1000, b"opus", &crypto, 0, &sender);
|
||||
|
||||
let packet_id = u16::from_be_bytes([pkt[0], pkt[1]]);
|
||||
assert_eq!(packet_id, PID_VOICE_PACKET);
|
||||
|
||||
let encrypted_payload = &pkt[4..];
|
||||
let plaintext = crypto.decrypt_c2s(0, encrypted_payload).unwrap();
|
||||
assert_eq!(plaintext.len(), 16 + 32 + 4);
|
||||
|
||||
let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap());
|
||||
let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap());
|
||||
let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap());
|
||||
let sender_id: [u8; 32] = plaintext[16..48].try_into().unwrap();
|
||||
|
||||
assert_eq!(pkt_voice_seq, 42);
|
||||
assert_eq!(timestamp, 1000);
|
||||
assert_eq!(channel_id, key);
|
||||
assert_eq!(sender_id, sender);
|
||||
assert_eq!(&plaintext[48..], b"opus");
|
||||
}
|
||||
}
|
||||
80
src/net/voice/receive.rs
Normal file
80
src/net/voice/receive.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::warn;
|
||||
|
||||
use super::super::crypto::SessionCrypto;
|
||||
use super::super::payloads::PID_VOICE_PACKET;
|
||||
use super::super::types::NetEvent;
|
||||
|
||||
/// Spawn a task that reads UDP datagrams, decrypts them, and forwards as VoicePacket events.
|
||||
pub fn spawn_recv(udp: Arc<UdpSocket>, crypto: SessionCrypto, tx: mpsc::Sender<NetEvent>) {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 1472];
|
||||
let mut voice_seq: u64 = 0;
|
||||
|
||||
loop {
|
||||
match udp.recv_from(&mut buf).await {
|
||||
Ok((len, src)) => {
|
||||
if len < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let packet_id = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
if packet_id != PID_VOICE_PACKET {
|
||||
continue;
|
||||
}
|
||||
|
||||
let encrypted_payload = &buf[4..len];
|
||||
let plaintext = match crypto.decrypt_s2c(voice_seq, encrypted_payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("voice packet decryption failed from {src}: {e}");
|
||||
voice_seq += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Layout: [voice_seq:4][timestamp:4][channel_id:8][sender_id:32][opus]
|
||||
// Legacy format (no sender_id) is 16+ bytes — gracefully handle.
|
||||
if plaintext.len() < 16 {
|
||||
warn!("voice packet payload too short from {src}");
|
||||
voice_seq += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let pkt_voice_seq = u32::from_be_bytes(plaintext[0..4].try_into().unwrap());
|
||||
let timestamp = u32::from_be_bytes(plaintext[4..8].try_into().unwrap());
|
||||
let channel_id = u64::from_be_bytes(plaintext[8..16].try_into().unwrap());
|
||||
|
||||
// sender_id: 32 raw bytes if present, hex-encoded.
|
||||
// Empty string when packet is from legacy sender without sender_id.
|
||||
let (sender_id_hex, opus_data) = if plaintext.len() >= 48 {
|
||||
let sender_bytes: [u8; 32] = plaintext[16..48].try_into().unwrap();
|
||||
(hex::encode(sender_bytes), plaintext[48..].to_vec())
|
||||
} else {
|
||||
// Legacy: no sender_id in payload.
|
||||
(String::new(), plaintext[16..].to_vec())
|
||||
};
|
||||
|
||||
let _ = tx
|
||||
.send(NetEvent::VoicePacket {
|
||||
channel_id,
|
||||
voice_seq: pkt_voice_seq,
|
||||
timestamp,
|
||||
sender: src,
|
||||
sender_id: sender_id_hex,
|
||||
opus_data,
|
||||
})
|
||||
.await;
|
||||
|
||||
voice_seq += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("UDP recv: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
51
src/ui/app/frame.rs
Normal file
51
src/ui/app/frame.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use eframe::egui::{self, Frame};
|
||||
|
||||
use crate::connect;
|
||||
use crate::state::ConnState;
|
||||
use crate::{chat, members, settings, sidebar, theme, titlebar};
|
||||
|
||||
use super::VnoxApp;
|
||||
|
||||
impl eframe::App for VnoxApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.handle_voice_keybinds(ctx);
|
||||
self.sync_audio_controls(ctx);
|
||||
self.poll_net();
|
||||
|
||||
if matches!(
|
||||
self.ui.conn,
|
||||
ConnState::Connecting | ConnState::Reconnecting { .. }
|
||||
) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
if matches!(self.ui.conn, ConnState::Connected { .. }) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
if self.audio.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(20));
|
||||
}
|
||||
|
||||
titlebar::show_titlebar(ctx, &mut self.ui);
|
||||
|
||||
if matches!(self.ui.conn, ConnState::Connected { .. }) {
|
||||
sidebar::guild_bar(ctx, &mut self.ui, &self.net);
|
||||
}
|
||||
sidebar::show(ctx, &mut self.ui, &self.identity, &self.net);
|
||||
if matches!(self.ui.conn, ConnState::Connected { .. }) {
|
||||
members::show(ctx, &mut self.ui);
|
||||
}
|
||||
egui::CentralPanel::default()
|
||||
.frame(Frame::NONE.fill(theme::BG_BASE))
|
||||
.show(ctx, |ui| match &self.ui.conn.clone() {
|
||||
ConnState::Disconnected
|
||||
| ConnState::Connecting
|
||||
| ConnState::Reconnecting { .. } => {
|
||||
connect::show(ui, &mut self.ui, &self.identity, &self.net)
|
||||
}
|
||||
ConnState::Connected { .. } => {
|
||||
chat::show(ui, ctx, &mut self.ui, &self.identity, &self.net)
|
||||
}
|
||||
});
|
||||
settings::show_window(ctx, &mut self.ui, &self.identity);
|
||||
}
|
||||
}
|
||||
123
src/ui/app/mod.rs
Normal file
123
src/ui/app/mod.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
mod frame;
|
||||
mod net_poll;
|
||||
mod setup;
|
||||
|
||||
use vnox_client::{
|
||||
audio::{AudioPipeline, AudioSync},
|
||||
identity::Identity,
|
||||
net::{NetCommand, NetHandle},
|
||||
};
|
||||
|
||||
use crate::keybind;
|
||||
use crate::state::{ConnState, VoiceUiState};
|
||||
use crate::theme::apply_visuals;
|
||||
|
||||
pub struct VnoxApp {
|
||||
pub(crate) identity: Identity,
|
||||
pub(crate) net: NetHandle,
|
||||
pub(crate) ui: crate::state::UiState,
|
||||
pub(crate) audio: Option<AudioPipeline>,
|
||||
pub(crate) voice_channel: Option<String>,
|
||||
pub(crate) last_pipeline_config: Option<vnox_client::audio::PipelineConfig>,
|
||||
}
|
||||
|
||||
impl VnoxApp {
|
||||
pub fn new(cc: &eframe::CreationContext, identity: Identity, net: NetHandle) -> Self {
|
||||
apply_visuals(&cc.egui_ctx);
|
||||
Self {
|
||||
identity,
|
||||
net,
|
||||
ui: crate::state::UiState::default(),
|
||||
audio: None,
|
||||
voice_channel: None,
|
||||
last_pipeline_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stop_voice(&mut self) {
|
||||
self.audio = None;
|
||||
self.voice_channel = None;
|
||||
self.last_pipeline_config = None;
|
||||
self.ui.voice = VoiceUiState::Off;
|
||||
self.ui.voice_joined_at = None;
|
||||
}
|
||||
|
||||
pub(crate) fn sync_voice_audio(&mut self) {
|
||||
if !matches!(self.ui.conn, ConnState::Connected { .. }) {
|
||||
self.stop_voice();
|
||||
return;
|
||||
}
|
||||
let active_voice = self
|
||||
.ui
|
||||
.active_channel
|
||||
.as_ref()
|
||||
.and_then(|id| self.ui.channels.iter().find(|c| &c.id == id))
|
||||
.filter(|c| c.kind == "voice")
|
||||
.map(|c| c.id.clone());
|
||||
|
||||
match active_voice {
|
||||
Some(id) => self.start_voice(&id),
|
||||
None => self.stop_voice(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_audio_controls(&mut self, ctx: &eframe::egui::Context) {
|
||||
let Some(pipeline) = self.audio.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let ptt_held = if self.ui.vad_mode == 0 {
|
||||
keybind::binding_held(ctx, &self.ui.kb_ptt)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
pipeline.sync_controls(AudioSync {
|
||||
mic_enabled: self.ui.mic_enabled,
|
||||
vad_mode: self.ui.vad_mode,
|
||||
vad_threshold: self.ui.vad_threshold,
|
||||
input_volume: self.ui.input_volume,
|
||||
ptt_held,
|
||||
output_volume: self.ui.output_volume,
|
||||
jitter_size: self.ui.jitter_size,
|
||||
adaptive_buffer: self.ui.adaptive_buffer,
|
||||
voice_bitrate_kbps: self.ui.voice_bitrate,
|
||||
noise_suppress: self.ui.noise_suppress,
|
||||
});
|
||||
|
||||
if let Some(ch) = self.voice_channel.clone() {
|
||||
let cfg = self.pipeline_config(&ch);
|
||||
if self.last_pipeline_config.as_ref() != Some(&cfg) {
|
||||
self.start_voice(&ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_voice_keybinds(&mut self, ctx: &eframe::egui::Context) {
|
||||
if ctx.wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
if keybind::binding_pressed(ctx, &self.ui.kb_mute) {
|
||||
self.ui.mic_enabled = !self.ui.mic_enabled;
|
||||
}
|
||||
if keybind::binding_pressed(ctx, &self.ui.kb_deafen) {
|
||||
self.ui.deafen = !self.ui.deafen;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flush_encoded_voice(&mut self) {
|
||||
let Some(pipeline) = self.audio.as_ref() else {
|
||||
return;
|
||||
};
|
||||
while let Some(frame) = pipeline.poll_encoded() {
|
||||
let net = self.net.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::SendVoice {
|
||||
channel_id: frame.channel_id,
|
||||
voice_seq: frame.voice_seq,
|
||||
timestamp: frame.timestamp,
|
||||
opus_data: frame.data,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/ui/app/net_poll/chat/channel.rs
Normal file
138
src/ui/app/net_poll/chat/channel.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
use crate::state::{Channel, DmConversation, UiState};
|
||||
use vnox_client::net::{NetCommand, NetEvent, NetHandle};
|
||||
|
||||
use super::into_msg;
|
||||
|
||||
pub(super) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||
match event {
|
||||
NetEvent::ChannelState {
|
||||
channel_id,
|
||||
channel_name,
|
||||
kind,
|
||||
members,
|
||||
..
|
||||
} => {
|
||||
ui.server_channel = Some(channel_id.clone());
|
||||
let mems: Vec<String> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
ui.user_names.insert(m.user_id.clone(), m.nickname.clone());
|
||||
m.nickname.clone()
|
||||
})
|
||||
.collect();
|
||||
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
|
||||
ch.members = mems;
|
||||
} else {
|
||||
ui.channels.push(Channel {
|
||||
id: channel_id.clone(),
|
||||
name: channel_name,
|
||||
kind: kind.clone(),
|
||||
members: mems,
|
||||
});
|
||||
}
|
||||
if ui.active_channel.is_none() {
|
||||
ui.active_channel = Some(channel_id.clone());
|
||||
}
|
||||
let net = net.clone();
|
||||
let ch_id = channel_id;
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::ReadReceipt {
|
||||
channel_id: ch_id,
|
||||
last_read_message_id: uuid::Uuid::new_v4().to_string(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
NetEvent::UserJoin {
|
||||
channel_id,
|
||||
user_id,
|
||||
nickname,
|
||||
..
|
||||
} => {
|
||||
ui.user_names.insert(user_id, nickname.clone());
|
||||
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id)
|
||||
&& !ch.members.contains(&nickname)
|
||||
{
|
||||
ch.members.push(nickname);
|
||||
}
|
||||
}
|
||||
NetEvent::UserLeave {
|
||||
channel_id,
|
||||
user_id,
|
||||
} => {
|
||||
if let Some(ch) = ui.channels.iter_mut().find(|c| c.id == channel_id) {
|
||||
let nickname = ui.user_names.remove(&user_id);
|
||||
ch.members.retain(|m| {
|
||||
m != &user_id && nickname.as_deref().map(|n| m != n).unwrap_or(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
NetEvent::ChannelCreated {
|
||||
channel_id,
|
||||
channel_name,
|
||||
kind,
|
||||
} => {
|
||||
// Another session created a channel — add to local list if not present.
|
||||
if !ui.channels.iter().any(|c| c.id == channel_id) {
|
||||
ui.channels.push(Channel {
|
||||
id: channel_id,
|
||||
name: channel_name,
|
||||
kind,
|
||||
members: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
NetEvent::ChannelDeleted { channel_id } => {
|
||||
ui.channels.retain(|c| c.id != channel_id);
|
||||
// Clear active_channel if it pointed at the deleted channel.
|
||||
if ui.active_channel.as_deref() == Some(&channel_id) {
|
||||
ui.active_channel = ui.channels.first().map(|c| c.id.clone());
|
||||
}
|
||||
// Drop any cached messages for the deleted channel.
|
||||
ui.messages.remove(&channel_id);
|
||||
}
|
||||
NetEvent::ChannelListEvent { channels } => {
|
||||
// Replace local channel list with the server-authoritative one.
|
||||
// Preserve `members` for channels we were already tracking.
|
||||
let old: std::collections::HashMap<String, Channel> = std::mem::take(&mut ui.channels)
|
||||
.into_iter()
|
||||
.map(|c| (c.id.clone(), c))
|
||||
.collect();
|
||||
ui.channels = channels
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let members = old
|
||||
.get(&c.channel_id)
|
||||
.map(|o| o.members.clone())
|
||||
.unwrap_or_default();
|
||||
Channel {
|
||||
id: c.channel_id,
|
||||
name: c.channel_name,
|
||||
kind: c.kind,
|
||||
members,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
NetEvent::DmStart {
|
||||
dm_id,
|
||||
other_user_id,
|
||||
other_nickname,
|
||||
messages,
|
||||
unread_count,
|
||||
} => {
|
||||
if !ui.dm_conversations.iter().any(|c| c.dm_id == dm_id) {
|
||||
ui.dm_conversations.push(DmConversation {
|
||||
dm_id: dm_id.clone(),
|
||||
other_user_id,
|
||||
other_nickname,
|
||||
unread_count,
|
||||
});
|
||||
}
|
||||
ui.dm_messages
|
||||
.insert(dm_id.clone(), messages.into_iter().map(into_msg).collect());
|
||||
ui.active_dm_id = Some(dm_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
149
src/ui/app/net_poll/chat/messages.rs
Normal file
149
src/ui/app/net_poll/chat/messages.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use crate::state::{ChatMessage, UiState};
|
||||
use std::collections::HashMap;
|
||||
use vnox_client::net::{NetEvent, NetHandle};
|
||||
|
||||
use super::into_msg;
|
||||
|
||||
pub(super) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) {
|
||||
match event {
|
||||
NetEvent::ChatMessage {
|
||||
message_id,
|
||||
channel_id,
|
||||
sender_id,
|
||||
content,
|
||||
timestamp,
|
||||
reply_to,
|
||||
..
|
||||
} => {
|
||||
ui.messages
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.push(ChatMessage {
|
||||
message_id,
|
||||
sender_id,
|
||||
content,
|
||||
timestamp,
|
||||
edited: false,
|
||||
reactions: HashMap::new(),
|
||||
reply_to,
|
||||
});
|
||||
ui.scroll_bottom = true;
|
||||
}
|
||||
NetEvent::ChatHistory {
|
||||
channel_id,
|
||||
messages,
|
||||
} => {
|
||||
ui.messages
|
||||
.insert(channel_id, messages.into_iter().map(into_msg).collect());
|
||||
ui.scroll_bottom = true;
|
||||
}
|
||||
NetEvent::Error { message, .. } => {
|
||||
ui.status_msg = Some(format!("error: {message}"));
|
||||
}
|
||||
NetEvent::ReactionAdded {
|
||||
channel_id,
|
||||
message_id,
|
||||
user_id,
|
||||
emoji,
|
||||
} => {
|
||||
if let Some(msgs) = ui.messages.get_mut(&channel_id)
|
||||
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
|
||||
{
|
||||
msg.reactions.entry(emoji).or_default().push(user_id);
|
||||
}
|
||||
}
|
||||
NetEvent::ReactionRemoved {
|
||||
channel_id,
|
||||
message_id,
|
||||
user_id,
|
||||
emoji,
|
||||
} => {
|
||||
if let Some(msgs) = ui.messages.get_mut(&channel_id)
|
||||
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
|
||||
&& let Some(users) = msg.reactions.get_mut(&emoji)
|
||||
{
|
||||
users.retain(|u| u != &user_id);
|
||||
if users.is_empty() {
|
||||
msg.reactions.remove(&emoji);
|
||||
}
|
||||
}
|
||||
}
|
||||
NetEvent::MessageEdited {
|
||||
channel_id,
|
||||
message_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
if let Some(msgs) = ui.messages.get_mut(&channel_id)
|
||||
&& let Some(msg) = msgs.iter_mut().find(|m| m.message_id == message_id)
|
||||
{
|
||||
msg.content = content;
|
||||
msg.edited = true;
|
||||
}
|
||||
}
|
||||
NetEvent::MessageDeleted {
|
||||
channel_id,
|
||||
message_id,
|
||||
} => {
|
||||
if let Some(msgs) = ui.messages.get_mut(&channel_id) {
|
||||
msgs.retain(|m| m.message_id != message_id);
|
||||
}
|
||||
}
|
||||
NetEvent::DmMessage {
|
||||
dm_id,
|
||||
sender_id,
|
||||
content,
|
||||
timestamp,
|
||||
..
|
||||
} => {
|
||||
ui.dm_messages
|
||||
.entry(dm_id.clone())
|
||||
.or_default()
|
||||
.push(ChatMessage {
|
||||
message_id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_id,
|
||||
content,
|
||||
timestamp,
|
||||
edited: false,
|
||||
reactions: HashMap::new(),
|
||||
reply_to: None,
|
||||
});
|
||||
if ui.active_dm_id.as_deref() != Some(&dm_id)
|
||||
&& let Some(c) = ui.dm_conversations.iter_mut().find(|c| c.dm_id == dm_id)
|
||||
{
|
||||
c.unread_count += 1;
|
||||
}
|
||||
}
|
||||
NetEvent::DmHistory { dm_id, messages } => {
|
||||
ui.dm_messages
|
||||
.insert(dm_id, messages.into_iter().map(into_msg).collect());
|
||||
}
|
||||
NetEvent::ReadReceiptBroadcast {
|
||||
channel_id,
|
||||
user_id,
|
||||
last_read_message_id,
|
||||
} => {
|
||||
ui.read_receipts
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.insert(user_id, last_read_message_id);
|
||||
}
|
||||
NetEvent::UserColor { user_id, color } => {
|
||||
if let Some(c) = parse_hex_color(&color) {
|
||||
ui.user_colors.insert(user_id, c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex_color(hex: &str) -> Option<eframe::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(eframe::egui::Color32::from_rgb(r, g, b))
|
||||
}
|
||||
28
src/ui/app/net_poll/chat/mod.rs
Normal file
28
src/ui/app/net_poll/chat/mod.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
mod channel;
|
||||
mod messages;
|
||||
|
||||
use crate::state::{ChatMessage, UiState};
|
||||
use std::collections::HashMap;
|
||||
use vnox_client::net::{ChatMsg, NetEvent, NetHandle};
|
||||
|
||||
fn into_msg(m: ChatMsg) -> ChatMessage {
|
||||
ChatMessage {
|
||||
message_id: m.message_id,
|
||||
sender_id: m.sender_id,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
edited: false,
|
||||
reactions: HashMap::new(),
|
||||
reply_to: m.reply_to,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle(ui: &mut UiState, net: &NetHandle, event: NetEvent) {
|
||||
match event {
|
||||
NetEvent::ChannelState { .. }
|
||||
| NetEvent::UserJoin { .. }
|
||||
| NetEvent::UserLeave { .. }
|
||||
| NetEvent::DmStart { .. } => channel::handle(ui, net, event),
|
||||
e => messages::handle(ui, net, e),
|
||||
}
|
||||
}
|
||||
169
src/ui/app/net_poll/mod.rs
Normal file
169
src/ui/app/net_poll/mod.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
mod chat;
|
||||
mod social;
|
||||
mod status;
|
||||
|
||||
use vnox_client::{
|
||||
audio::IncomingVoice,
|
||||
net::{NetCommand, NetEvent, NetHandle},
|
||||
};
|
||||
|
||||
use crate::state::ConnState;
|
||||
|
||||
use super::VnoxApp;
|
||||
|
||||
impl VnoxApp {
|
||||
pub(crate) fn poll_net(&mut self) {
|
||||
while let Some(ev) = self.net.try_recv() {
|
||||
match ev {
|
||||
NetEvent::Connected {
|
||||
session_id,
|
||||
node_name,
|
||||
restored,
|
||||
private_mode,
|
||||
} => self.on_connected(session_id, node_name, restored, private_mode),
|
||||
NetEvent::Disconnected { reason, will_retry } => {
|
||||
self.stop_voice();
|
||||
status::handle_disconnected(&mut self.ui, &reason, will_retry);
|
||||
}
|
||||
NetEvent::Reconnecting {
|
||||
attempt,
|
||||
delay_secs,
|
||||
} => {
|
||||
self.stop_voice();
|
||||
status::handle_reconnecting(&mut self.ui, attempt, delay_secs);
|
||||
}
|
||||
NetEvent::VoicePacket {
|
||||
channel_id,
|
||||
voice_seq,
|
||||
timestamp,
|
||||
opus_data,
|
||||
sender_id,
|
||||
..
|
||||
} => {
|
||||
// Track which remote user is speaking (Phase 1.3 attribution).
|
||||
if !sender_id.is_empty() {
|
||||
self.ui.last_remote_speaker_id = sender_id.clone();
|
||||
self.ui.last_voice_packet_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
if !self.ui.deafen
|
||||
&& let Some(pipeline) = self.audio.as_ref()
|
||||
{
|
||||
pipeline.push_voice(IncomingVoice {
|
||||
voice_seq,
|
||||
timestamp,
|
||||
channel_id,
|
||||
opus_data,
|
||||
});
|
||||
}
|
||||
}
|
||||
event => process_net_event(&mut self.ui, &self.net, event),
|
||||
}
|
||||
self.sync_voice_audio();
|
||||
self.flush_encoded_voice();
|
||||
}
|
||||
// Refresh speaking indicators from the audio pipeline.
|
||||
if let Some(pipeline) = self.audio.as_ref() {
|
||||
self.ui.local_speaking = pipeline.local_speaking();
|
||||
self.ui.remote_speaking = pipeline.remote_speaking(500);
|
||||
} else {
|
||||
self.ui.local_speaking = false;
|
||||
self.ui.remote_speaking = false;
|
||||
}
|
||||
// Decay remote speaker id if no packet arrived in 700 ms.
|
||||
if !self.ui.remote_speaking {
|
||||
self.ui.last_remote_speaker_id.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_connected(
|
||||
&mut self,
|
||||
session_id: String,
|
||||
node_name: String,
|
||||
restored: bool,
|
||||
private_mode: bool,
|
||||
) {
|
||||
self.ui.conn = ConnState::Connected {
|
||||
session_id,
|
||||
node_name,
|
||||
};
|
||||
self.ui.private_mode = private_mode;
|
||||
if restored {
|
||||
self.ui.status_msg = Some("reconnected".into());
|
||||
if let Some(ch) = self.ui.active_channel.clone() {
|
||||
let net = self.net.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::JoinChannel { channel_id: ch }).await;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.ui.seed_default_channels();
|
||||
self.ui.active_channel = Some("general".into());
|
||||
self.ui.status_msg = None;
|
||||
let net = self.net.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::JoinChannel {
|
||||
channel_id: "general".into(),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process_net_event(ui: &mut crate::state::UiState, net: &NetHandle, event: NetEvent) {
|
||||
match &event {
|
||||
NetEvent::ChannelState { .. }
|
||||
| NetEvent::ChannelCreated { .. }
|
||||
| NetEvent::ChannelDeleted { .. }
|
||||
| NetEvent::ChannelListEvent { .. }
|
||||
| NetEvent::ChatMessage { .. }
|
||||
| NetEvent::ChatHistory { .. }
|
||||
| NetEvent::UserJoin { .. }
|
||||
| NetEvent::UserLeave { .. }
|
||||
| NetEvent::Error { .. }
|
||||
| NetEvent::ReactionAdded { .. }
|
||||
| NetEvent::ReactionRemoved { .. }
|
||||
| NetEvent::MessageEdited { .. }
|
||||
| NetEvent::MessageDeleted { .. }
|
||||
| NetEvent::DmStart { .. }
|
||||
| NetEvent::DmMessage { .. }
|
||||
| NetEvent::DmHistory { .. }
|
||||
| NetEvent::ReadReceiptBroadcast { .. }
|
||||
| NetEvent::UserColor { .. } => chat::handle(ui, net, event),
|
||||
|
||||
NetEvent::GuildList { .. }
|
||||
| NetEvent::GuildCreated { .. }
|
||||
| NetEvent::GuildDeleted { .. }
|
||||
| NetEvent::GuildMemberJoined { .. }
|
||||
| NetEvent::GuildMemberLeft { .. }
|
||||
| NetEvent::GuildMemberKicked { .. }
|
||||
| NetEvent::InviteCreated { .. }
|
||||
| NetEvent::InviteAccepted { .. }
|
||||
| NetEvent::InviteDeleted { .. }
|
||||
| NetEvent::RoleCreated { .. }
|
||||
| NetEvent::RoleDeleted { .. }
|
||||
| NetEvent::GuildAuditLog { .. }
|
||||
| NetEvent::GuildMemberList { .. }
|
||||
| NetEvent::GuildRoleList { .. }
|
||||
| NetEvent::FriendList { .. }
|
||||
| NetEvent::FriendRequested { .. }
|
||||
| NetEvent::FriendAccepted { .. }
|
||||
| NetEvent::FriendRemoved { .. }
|
||||
| NetEvent::BlockList { .. }
|
||||
| NetEvent::BlockedUser { .. }
|
||||
| NetEvent::UnblockedUser { .. } => social::handle(ui, event),
|
||||
|
||||
NetEvent::LatencyUpdate { .. }
|
||||
| NetEvent::TypingStart { .. }
|
||||
| NetEvent::PresenceSync { .. }
|
||||
| NetEvent::PresenceUpdated { .. } => status::handle(ui, net, event),
|
||||
|
||||
NetEvent::Connected { .. }
|
||||
| NetEvent::Disconnected { .. }
|
||||
| NetEvent::Reconnecting { .. }
|
||||
| NetEvent::VoicePacket { .. } => {}
|
||||
}
|
||||
}
|
||||
148
src/ui/app/net_poll/social.rs
Normal file
148
src/ui/app/net_poll/social.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use vnox_client::net::NetEvent;
|
||||
|
||||
use crate::state::{FriendState, GuildState};
|
||||
|
||||
use crate::state::UiState;
|
||||
|
||||
pub(crate) fn handle(ui: &mut UiState, event: NetEvent) {
|
||||
match event {
|
||||
NetEvent::GuildAuditLog { guild_id, entries } => {
|
||||
ui.audit_log_entries = entries;
|
||||
ui.audit_log_guild_id = Some(guild_id);
|
||||
}
|
||||
NetEvent::GuildMemberList { guild_id, members } => {
|
||||
ui.guild_member_list_guild_id = Some(guild_id);
|
||||
ui.guild_member_list = members;
|
||||
}
|
||||
NetEvent::GuildRoleList { guild_id, roles } => {
|
||||
ui.guild_role_list_guild_id = Some(guild_id);
|
||||
ui.guild_role_list = roles;
|
||||
}
|
||||
NetEvent::BlockList { blocked } => {
|
||||
ui.blocked_users = blocked;
|
||||
}
|
||||
NetEvent::BlockedUser { user_id } => {
|
||||
if !ui.blocked_users.contains(&user_id) {
|
||||
ui.blocked_users.push(user_id);
|
||||
}
|
||||
}
|
||||
NetEvent::UnblockedUser { user_id } => {
|
||||
ui.blocked_users.retain(|u| u != &user_id);
|
||||
}
|
||||
NetEvent::GuildList { guilds } => {
|
||||
ui.guilds = guilds
|
||||
.into_iter()
|
||||
.map(|g| GuildState {
|
||||
guild_id: g.guild_id,
|
||||
name: g.name,
|
||||
owner_id: g.owner_id,
|
||||
member_count: g.member_count,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
NetEvent::GuildCreated {
|
||||
guild_id,
|
||||
name,
|
||||
owner_id,
|
||||
} => {
|
||||
ui.guilds.push(GuildState {
|
||||
guild_id: guild_id.clone(),
|
||||
name,
|
||||
owner_id,
|
||||
member_count: 1,
|
||||
});
|
||||
ui.active_guild_id = Some(guild_id);
|
||||
}
|
||||
NetEvent::GuildDeleted { guild_id } => {
|
||||
ui.guilds.retain(|g| g.guild_id != guild_id);
|
||||
if ui.active_guild_id.as_deref() == Some(&guild_id) {
|
||||
ui.active_guild_id = None;
|
||||
}
|
||||
}
|
||||
NetEvent::GuildMemberJoined {
|
||||
guild_id,
|
||||
user_id: _,
|
||||
nickname: _,
|
||||
} => {
|
||||
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
|
||||
g.member_count += 1;
|
||||
}
|
||||
}
|
||||
NetEvent::GuildMemberLeft {
|
||||
guild_id,
|
||||
user_id: _,
|
||||
} => {
|
||||
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
|
||||
g.member_count = g.member_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
NetEvent::GuildMemberKicked {
|
||||
guild_id,
|
||||
user_id: _,
|
||||
} => {
|
||||
if let Some(g) = ui.guilds.iter_mut().find(|g| g.guild_id == guild_id) {
|
||||
g.member_count = g.member_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
NetEvent::InviteCreated { .. } => {
|
||||
// UI will fetch invite list separately if needed
|
||||
}
|
||||
NetEvent::InviteAccepted {
|
||||
guild_id,
|
||||
guild_name,
|
||||
} if !ui.guilds.iter().any(|g| g.guild_id == guild_id) => {
|
||||
ui.guilds.push(GuildState {
|
||||
guild_id,
|
||||
name: guild_name,
|
||||
owner_id: String::new(),
|
||||
member_count: 1,
|
||||
});
|
||||
}
|
||||
NetEvent::InviteDeleted {
|
||||
guild_id: _,
|
||||
invite_id: _,
|
||||
} => {}
|
||||
NetEvent::RoleCreated { .. } => {}
|
||||
NetEvent::RoleDeleted { .. } => {}
|
||||
NetEvent::FriendList { friends } => {
|
||||
ui.friends = friends
|
||||
.into_iter()
|
||||
.map(|f| FriendState {
|
||||
user_id: f.user_id,
|
||||
nickname: f.nickname,
|
||||
status: f.status,
|
||||
since: f.since,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
NetEvent::FriendRequested { user_id, nickname } => {
|
||||
ui.pending_friend_requests.push(FriendState {
|
||||
user_id,
|
||||
nickname,
|
||||
status: "offline".into(),
|
||||
since: 0,
|
||||
});
|
||||
}
|
||||
NetEvent::FriendAccepted { user_id, nickname } => {
|
||||
// Remove from pending requests
|
||||
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
|
||||
// Avoid duplicate entries in friends list
|
||||
if !ui.friends.iter().any(|f| f.user_id == user_id) {
|
||||
ui.friends.push(FriendState {
|
||||
user_id,
|
||||
nickname,
|
||||
status: "online".into(),
|
||||
since: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
NetEvent::FriendRemoved { user_id } => {
|
||||
ui.friends.retain(|f| f.user_id != user_id);
|
||||
ui.pending_friend_requests.retain(|f| f.user_id != user_id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
69
src/ui/app/net_poll/status.rs
Normal file
69
src/ui/app/net_poll/status.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use vnox_client::net::{NetEvent, NetHandle, PresenceInfo};
|
||||
|
||||
use crate::state::ConnState;
|
||||
|
||||
use crate::state::UiState;
|
||||
|
||||
pub(crate) fn handle_disconnected(ui: &mut UiState, reason: &str, will_retry: bool) {
|
||||
if will_retry {
|
||||
ui.status_msg = Some(format!("connection lost: {reason}"));
|
||||
} else {
|
||||
ui.conn = ConnState::Disconnected;
|
||||
ui.channels.clear();
|
||||
ui.active_channel = None;
|
||||
ui.user_names.clear();
|
||||
ui.server_channel = None;
|
||||
ui.rtt_ms = None;
|
||||
ui.status_msg = Some(format!("disconnected: {reason}"));
|
||||
}
|
||||
ui.dm_conversations.clear();
|
||||
ui.dm_messages.clear();
|
||||
ui.active_dm_id = None;
|
||||
ui.new_dm_input = None;
|
||||
ui.private_mode = false;
|
||||
}
|
||||
|
||||
pub(crate) fn handle_reconnecting(ui: &mut UiState, attempt: u32, delay_secs: u64) {
|
||||
ui.conn = ConnState::Reconnecting { attempt };
|
||||
ui.status_msg = Some(format!("reconnecting in {delay_secs}s (attempt {attempt})"));
|
||||
}
|
||||
|
||||
pub(crate) fn handle(ui: &mut UiState, _net: &NetHandle, event: NetEvent) {
|
||||
match event {
|
||||
NetEvent::LatencyUpdate { rtt_ms } => {
|
||||
ui.rtt_ms = Some(rtt_ms);
|
||||
}
|
||||
NetEvent::TypingStart {
|
||||
user_id,
|
||||
nickname,
|
||||
channel_id,
|
||||
} => {
|
||||
let entry = ui.typing_users.entry(channel_id).or_default();
|
||||
entry.retain(|(u, _)| u != &user_id);
|
||||
entry.push((nickname, std::time::Instant::now()));
|
||||
}
|
||||
NetEvent::PresenceSync { presences } => {
|
||||
for p in presences {
|
||||
ui.presences.insert(p.user_id.clone(), p);
|
||||
}
|
||||
}
|
||||
NetEvent::PresenceUpdated {
|
||||
user_id,
|
||||
status,
|
||||
custom_status,
|
||||
activity,
|
||||
} => {
|
||||
ui.presences.insert(
|
||||
user_id.clone(),
|
||||
PresenceInfo {
|
||||
user_id,
|
||||
status,
|
||||
custom_status,
|
||||
activity,
|
||||
activity_text: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
50
src/ui/app/setup.rs
Normal file
50
src/ui/app/setup.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
use tracing::{info, warn};
|
||||
use vnox_client::audio::{self, PipelineConfig};
|
||||
use vnox_client::net::voice;
|
||||
|
||||
use super::VnoxApp;
|
||||
|
||||
impl VnoxApp {
|
||||
pub(crate) fn pipeline_config(&self, channel_id: &str) -> PipelineConfig {
|
||||
PipelineConfig {
|
||||
channel_key: voice::channel_key(channel_id),
|
||||
mic_device_index: self.ui.mic_device,
|
||||
output_device_index: self.ui.output_device,
|
||||
voice_bitrate_kbps: self.ui.voice_bitrate,
|
||||
packet_interval: self.ui.packet_interval,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_voice(&mut self, channel_id: &str) {
|
||||
let cfg = self.pipeline_config(channel_id);
|
||||
if self.voice_channel.as_deref() == Some(channel_id)
|
||||
&& self.audio.is_some()
|
||||
&& self.last_pipeline_config.as_ref() == Some(&cfg)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.audio = None;
|
||||
match audio::start(cfg.clone()) {
|
||||
Ok(pipeline) => {
|
||||
info!("voice active on channel {channel_id}");
|
||||
self.audio = Some(pipeline);
|
||||
self.voice_channel = Some(channel_id.to_string());
|
||||
self.last_pipeline_config = Some(cfg);
|
||||
self.ui.voice = crate::state::VoiceUiState::Active {
|
||||
channel: channel_id.to_string(),
|
||||
};
|
||||
if self.ui.voice_joined_at.is_none() {
|
||||
self.ui.voice_joined_at = Some(std::time::Instant::now());
|
||||
}
|
||||
self.ui.status_msg = None;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("audio start failed: {e}");
|
||||
self.voice_channel = None;
|
||||
self.last_pipeline_config = None;
|
||||
self.ui.voice = crate::state::VoiceUiState::Error(e.to_string());
|
||||
self.ui.status_msg = Some(format!("microphone error: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
src/ui/chat/dm/messages.rs
Normal file
165
src/ui/chat/dm/messages.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
use super::super::text::message_row;
|
||||
use crate::state::{ChatMessage, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Context, FontId, Frame, RichText, ScrollArea, TextEdit};
|
||||
use std::collections::HashMap;
|
||||
use vnox_client::{
|
||||
identity::Identity,
|
||||
net::{NetCommand, NetHandle},
|
||||
};
|
||||
|
||||
pub(super) fn search_bar(
|
||||
ui: &mut egui::Ui,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
dm_id: &str,
|
||||
_h: f32,
|
||||
) {
|
||||
Frame::NONE
|
||||
.inner_margin(egui::Margin::symmetric(12, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let resp = ui.add_sized(
|
||||
egui::vec2(ui.available_width(), 20.0),
|
||||
TextEdit::singleline(&mut s.dm_search_query)
|
||||
.font(FontId::proportional(12.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.hint_text(RichText::new("search…").color(t::TEXT_DISABLED))
|
||||
.frame(true),
|
||||
);
|
||||
if resp.changed() {
|
||||
let query = s.dm_search_query.trim().to_string();
|
||||
let net = net.clone();
|
||||
let id = dm_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::DmSearch { dm_id: id, query }).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn show(
|
||||
ui: &mut egui::Ui,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
identity: &Identity,
|
||||
dm_id: &str,
|
||||
max_h: f32,
|
||||
) {
|
||||
let msgs = s.dm_messages.get(dm_id).cloned().unwrap_or_default();
|
||||
ScrollArea::vertical()
|
||||
.max_height(max_h)
|
||||
.auto_shrink([false; 2])
|
||||
.stick_to_bottom(s.scroll_bottom)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(12.0);
|
||||
if msgs.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("no messages yet")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("today")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let mut prev: Option<String> = None;
|
||||
for msg in &msgs {
|
||||
let group_start = prev.as_deref() != Some(&msg.sender_id);
|
||||
message_row(ui, s, net, identity, msg, dm_id, group_start);
|
||||
prev = Some(msg.sender_id.clone());
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
s.scroll_bottom = false;
|
||||
}
|
||||
|
||||
pub(super) fn input_bar(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &Context,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
_identity: &Identity,
|
||||
dm_id: &str,
|
||||
) {
|
||||
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_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let input = egui::TextEdit::singleline(&mut s.chat_input)
|
||||
.font(FontId::proportional(13.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.hint_text(RichText::new("message…").color(t::TEXT_DISABLED))
|
||||
.frame(false)
|
||||
.desired_width(ui.available_width() - 44.0);
|
||||
|
||||
let enter = Frame::NONE
|
||||
.fill(t::BG_INTERACTIVE)
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| ui.add(input))
|
||||
.inner;
|
||||
|
||||
let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
|| ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0))
|
||||
.fill(t::ACCENT_10)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.corner_radius(t::R_MD)
|
||||
.min_size(egui::vec2(32.0, 32.0)),
|
||||
)
|
||||
.clicked();
|
||||
|
||||
if submit && !s.chat_input.trim().is_empty() {
|
||||
send_dm(s, net, dm_id);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn send_dm(s: &mut UiState, net: &NetHandle, dm_id: &str) {
|
||||
let content = s.chat_input.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
s.chat_input.clear();
|
||||
|
||||
s.dm_messages
|
||||
.entry(dm_id.to_string())
|
||||
.or_default()
|
||||
.push(ChatMessage {
|
||||
message_id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_id: String::new(),
|
||||
content: content.clone(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64,
|
||||
edited: false,
|
||||
reactions: HashMap::new(),
|
||||
reply_to: None,
|
||||
});
|
||||
s.scroll_bottom = true;
|
||||
|
||||
let net = net.clone();
|
||||
let id = dm_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::DmSend { dm_id: id, content }).await;
|
||||
});
|
||||
}
|
||||
71
src/ui/chat/dm/mod.rs
Normal file
71
src/ui/chat/dm/mod.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
mod messages;
|
||||
|
||||
use eframe::egui::{self, Context, FontId, Frame, RichText};
|
||||
use vnox_client::{identity::Identity, net::NetHandle};
|
||||
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
|
||||
pub(crate) fn dm_chat(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &Context,
|
||||
s: &mut UiState,
|
||||
identity: &Identity,
|
||||
net: &NetHandle,
|
||||
dm_id: &str,
|
||||
) {
|
||||
let conv = s
|
||||
.dm_conversations
|
||||
.iter()
|
||||
.find(|c| c.dm_id == dm_id)
|
||||
.cloned();
|
||||
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(16, 0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_height(40.0);
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(RichText::new("@").color(t::TEXT_MUTED).size(16.0));
|
||||
ui.add_space(4.0);
|
||||
let name = conv
|
||||
.as_ref()
|
||||
.map(|c| c.other_nickname.as_str())
|
||||
.unwrap_or("?");
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.font(FontId::proportional(14.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let r = ui.max_rect();
|
||||
ui.painter().hline(
|
||||
r.x_range(),
|
||||
r.top() + 40.0,
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
|
||||
let search_h = 32.0;
|
||||
let input_h = 52.0;
|
||||
messages::search_bar(ui, s, net, dm_id, search_h);
|
||||
ui.painter().hline(
|
||||
r.x_range(),
|
||||
r.top() + 40.0 + search_h,
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
|
||||
messages::show(
|
||||
ui,
|
||||
s,
|
||||
net,
|
||||
identity,
|
||||
dm_id,
|
||||
ui.available_height() - input_h - search_h,
|
||||
);
|
||||
messages::input_bar(ui, ctx, s, net, identity, dm_id);
|
||||
});
|
||||
}
|
||||
270
src/ui/chat/friends/list.rs
Normal file
270
src/ui/chat/friends/list.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
use super::{muted_label, pending, section_header, status_color, status_label};
|
||||
use crate::state::{FriendState, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, FontId, Frame, RichText, ScrollArea};
|
||||
use vnox_client::net::{NetCommand, NetHandle};
|
||||
|
||||
pub(super) fn render_content(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
|
||||
match s.friends_tab.as_str() {
|
||||
"online" => render_online(ui, s, net),
|
||||
"pending" => render_pending(ui, s, net),
|
||||
"blocked" => render_blocked(ui, s, net),
|
||||
_ => render_all(ui, s, net),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_online(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
section_header(ui, "ONLINE");
|
||||
let online: Vec<&FriendState> = s
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd"))
|
||||
.collect();
|
||||
if online.is_empty() {
|
||||
muted_label(ui, "no friends online");
|
||||
} else {
|
||||
for f in &online {
|
||||
friend_row(ui, f, net);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_all(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
section_header(ui, "ONLINE");
|
||||
let online: Vec<&FriendState> = s
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| matches!(f.status.as_str(), "online" | "idle" | "dnd"))
|
||||
.collect();
|
||||
if online.is_empty() {
|
||||
muted_label(ui, "no friends online");
|
||||
} else {
|
||||
for f in &online {
|
||||
friend_row(ui, f, net);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
section_header(ui, "OFFLINE");
|
||||
let offline: Vec<&FriendState> = s
|
||||
.friends
|
||||
.iter()
|
||||
.filter(|f| !matches!(f.status.as_str(), "online" | "idle" | "dnd"))
|
||||
.collect();
|
||||
if offline.is_empty() {
|
||||
if s.friends.is_empty() {
|
||||
muted_label(ui, "no friends yet — add some!");
|
||||
} else {
|
||||
muted_label(ui, "nobody is offline");
|
||||
}
|
||||
} else {
|
||||
for f in &offline {
|
||||
friend_row(ui, f, net);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pending(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
section_header(ui, "PENDING — INCOMING");
|
||||
if s.pending_friend_requests.is_empty() {
|
||||
muted_label(ui, "no pending requests");
|
||||
} else {
|
||||
for req in &s.pending_friend_requests {
|
||||
pending::pending_row(ui, req, net);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_blocked(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
// Block-user input row
|
||||
Frame::NONE
|
||||
.inner_margin(egui::Margin::symmetric(12, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let mut buf = s.block_input.clone().unwrap_or_default();
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.font(egui::FontId::monospace(11.0))
|
||||
.hint_text("user id to block")
|
||||
.desired_width(140.0)
|
||||
.text_color(t::TEXT_PRIMARY),
|
||||
);
|
||||
let mut trigger = false;
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
trigger = true;
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Block")
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::ERROR),
|
||||
)
|
||||
.fill(t::BG_ELEVATED)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(50.0, 18.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
trigger = true;
|
||||
}
|
||||
s.block_input = if buf.is_empty() { None } else { Some(buf) };
|
||||
if trigger
|
||||
&& let Some(target) = s.block_input.clone().filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
let net = net.clone();
|
||||
let target = target.trim().to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::BlockUser { user_id: target }).await;
|
||||
});
|
||||
s.block_input = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
section_header(ui, "BLOCKED");
|
||||
if s.blocked_users.is_empty() {
|
||||
muted_label(ui, "no blocked users — enter a user ID above to block");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch on first open of this tab.
|
||||
if s.block_list_fetched_at.is_none() {
|
||||
s.block_list_fetched_at = Some(std::time::Instant::now());
|
||||
let net = net.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::BlockList).await;
|
||||
});
|
||||
}
|
||||
|
||||
for uid in s.blocked_users.clone() {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.corner_radius(t::R_MD)
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let init: String = uid.chars().next().unwrap_or('?').to_uppercase().to_string();
|
||||
t::avatar(ui, &init, t::TEXT_DISABLED, 28.0);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(&uid)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("blocked")
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Unblock")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::SUCCESS),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.on_hover_text("unblock this user")
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let uidc = uid.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::UnblockUser { user_id: uidc }).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn friend_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.corner_radius(t::R_MD)
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let init = f
|
||||
.nickname
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string();
|
||||
t::avatar(ui, &init, status_color(&f.status), 28.0);
|
||||
|
||||
ui.add_space(8.0);
|
||||
let (dot_r, _) =
|
||||
ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter()
|
||||
.circle_filled(dot_r.center(), 4.0, status_color(&f.status));
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(&f.nickname)
|
||||
.font(FontId::proportional(13.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(status_label(&f.status))
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if t::icon_btn_drawn(
|
||||
ui,
|
||||
crate::theme::icons::envelope,
|
||||
"send message",
|
||||
None,
|
||||
18.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let uid = f.user_id.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::DmStart {
|
||||
target_user_id: uid,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if t::icon_btn_drawn(
|
||||
ui,
|
||||
crate::theme::icons::close,
|
||||
"remove friend",
|
||||
None,
|
||||
16.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let uid = f.user_id.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::FriendRemove { user_id: uid }).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
209
src/ui/chat/friends/mod.rs
Normal file
209
src/ui/chat/friends/mod.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
mod list;
|
||||
mod pending;
|
||||
|
||||
use eframe::egui::{self, FontId, Frame, RichText};
|
||||
use vnox_client::net::{NetCommand, NetHandle};
|
||||
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(16, 0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_height(44.0);
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Friends")
|
||||
.font(FontId::proportional(16.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let add_btn = egui::Button::new(
|
||||
RichText::new("Add Friend")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ACCENT_SOFT),
|
||||
)
|
||||
.fill(t::ACCENT)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(80.0, 26.0));
|
||||
if ui.add(add_btn).clicked() {
|
||||
s.add_friend_open = true;
|
||||
s.add_friend_input.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let sep = ui.max_rect();
|
||||
ui.painter().hline(
|
||||
sep.x_range(),
|
||||
sep.top() + 44.0,
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Pending count badge for the Pending tab
|
||||
let pending_count = s.pending_friend_requests.len();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (tab_key, label) in [
|
||||
("online", "Online"),
|
||||
("all", "All"),
|
||||
("pending", "Pending"),
|
||||
("blocked", "Blocked"),
|
||||
] {
|
||||
let selected = s.friends_tab == tab_key;
|
||||
let display = if tab_key == "pending" && pending_count > 0 {
|
||||
format!("{label} ({pending_count})")
|
||||
} else {
|
||||
label.to_string()
|
||||
};
|
||||
let btn = egui::Button::new(
|
||||
RichText::new(display)
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(if selected {
|
||||
t::TEXT_PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}),
|
||||
)
|
||||
.fill(if selected {
|
||||
t::BG_ELEVATED
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM);
|
||||
if ui.add(btn).clicked() {
|
||||
s.friends_tab = tab_key.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
list::render_content(ui, s, net);
|
||||
});
|
||||
|
||||
if s.add_friend_open {
|
||||
show_add_friend_popup(ui.ctx(), s, net);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_add_friend_popup(ctx: &egui::Context, s: &mut UiState, net: &NetHandle) {
|
||||
egui::Window::new("Add Friend")
|
||||
.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(320.0);
|
||||
ui.label(
|
||||
RichText::new("Enter a user ID to send a friend request.")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut s.add_friend_input)
|
||||
.font(FontId::monospace(13.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.hint_text(RichText::new("user id (pubkey hex)").color(t::TEXT_DISABLED))
|
||||
.desired_width(280.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.add_friend_open = false;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let send_btn = egui::Button::new(
|
||||
RichText::new("Send Request")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(t::ACCENT)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(110.0, 28.0));
|
||||
if ui.add(send_btn).clicked() && !s.add_friend_input.trim().is_empty() {
|
||||
let net = net.clone();
|
||||
let target = s.add_friend_input.trim().to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::FriendRequest {
|
||||
target_user_id: target,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
s.add_friend_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
s.add_friend_open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn section_header(ui: &mut egui::Ui, label: &str) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::TEXT_MUTED)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
pub(super) fn muted_label(ui: &mut egui::Ui, text: &str) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn status_color(status: &str) -> egui::Color32 {
|
||||
match status {
|
||||
"online" => t::SUCCESS,
|
||||
"idle" => t::WARNING,
|
||||
"dnd" => t::ERROR,
|
||||
_ => t::TEXT_DISABLED,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn status_label(status: &str) -> &str {
|
||||
match status {
|
||||
"online" => "online",
|
||||
"idle" => "idle",
|
||||
"dnd" => "do not disturb",
|
||||
"offline" => "offline",
|
||||
_ => status,
|
||||
}
|
||||
}
|
||||
62
src/ui/chat/friends/pending.rs
Normal file
62
src/ui/chat/friends/pending.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use eframe::egui::{self, FontId, Frame, RichText};
|
||||
use vnox_client::net::{NetCommand, NetHandle};
|
||||
|
||||
use crate::state::FriendState;
|
||||
use crate::theme as t;
|
||||
|
||||
pub(super) fn pending_row(ui: &mut egui::Ui, f: &FriendState, net: &NetHandle) {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.corner_radius(t::R_MD)
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let init = f
|
||||
.nickname
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string();
|
||||
t::avatar(ui, &init, t::WARNING, 28.0);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(&f.nickname)
|
||||
.font(FontId::proportional(13.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if t::icon_btn_drawn(
|
||||
ui,
|
||||
crate::theme::icons::check,
|
||||
"accept",
|
||||
Some(t::SUCCESS),
|
||||
18.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let uid = f.user_id.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::FriendAccept { user_id: uid }).await;
|
||||
});
|
||||
}
|
||||
if t::icon_btn_drawn(
|
||||
ui,
|
||||
crate::theme::icons::close,
|
||||
"decline",
|
||||
Some(t::ERROR),
|
||||
18.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let uid = f.user_id.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::FriendDecline { user_id: uid }).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
66
src/ui/chat/mod.rs
Normal file
66
src/ui/chat/mod.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use eframe::egui::{self, Context, FontId, RichText};
|
||||
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use vnox_client::{identity::Identity, net::NetHandle};
|
||||
|
||||
mod dm;
|
||||
mod friends;
|
||||
mod text;
|
||||
mod voice;
|
||||
|
||||
pub use friends::show as friends_panel;
|
||||
|
||||
pub fn show(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &Context,
|
||||
s: &mut UiState,
|
||||
identity: &Identity,
|
||||
net: &NetHandle,
|
||||
) {
|
||||
// If a DM conversation is active, show the DM chat view
|
||||
if let Some(dm_id) = s.active_dm_id.clone() {
|
||||
// Clear any active channel when showing DM
|
||||
s.active_channel = None;
|
||||
dm::dm_chat(ui, ctx, s, identity, net, &dm_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Home screen — show Friends panel if no guild and no DM
|
||||
if s.active_guild_id.is_none() {
|
||||
friends::show(ui, s, net);
|
||||
return;
|
||||
}
|
||||
|
||||
let ch_id = match s.active_channel.clone() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
empty_state(ui, "pick a channel");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ch = match s.channels.iter().find(|c| c.id == ch_id) {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
empty_state(ui, "channel not found");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if ch.kind == "voice" {
|
||||
voice::voice_panel(ui, s, &ch);
|
||||
} else {
|
||||
text::text_chat(ui, ctx, s, net, identity, &ch_id, &ch.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_state(ui: &mut egui::Ui, text: &str) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.font(FontId::monospace(12.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
});
|
||||
}
|
||||
186
src/ui/chat/text/input.rs
Normal file
186
src/ui/chat/text/input.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use crate::state::{ChatMessage, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Context, FontId, Frame, RichText};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use vnox_client::{
|
||||
identity::Identity,
|
||||
net::{NetCommand, NetHandle},
|
||||
};
|
||||
|
||||
pub(crate) fn chat_input_bar(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &Context,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
ch_id: &str,
|
||||
identity: &Identity,
|
||||
) {
|
||||
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_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
// Reply / edit preview bar
|
||||
if let Some((ref _reply_id, ref reply_sender)) = s.replying_to.clone() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new("replying to")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ACCENT),
|
||||
);
|
||||
let sender_nick = s.nick_for(reply_sender).to_string();
|
||||
ui.label(
|
||||
RichText::new(sender_nick)
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_SECONDARY),
|
||||
);
|
||||
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel reply", None, 14.0)
|
||||
.clicked()
|
||||
{
|
||||
s.replying_to = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
if s.editing_message_id.is_some() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new("editing")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::WARNING),
|
||||
);
|
||||
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel edit", None, 14.0)
|
||||
.clicked()
|
||||
{
|
||||
s.chat_input.clear();
|
||||
s.editing_message_id = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let hint_text = if s.editing_message_id.is_some() {
|
||||
"edit your message...".to_string()
|
||||
} else if s.replying_to.is_some() {
|
||||
"write your reply...".to_string()
|
||||
} else {
|
||||
format!("message in #{ch_id}")
|
||||
};
|
||||
let input = egui::TextEdit::singleline(&mut s.chat_input)
|
||||
.font(FontId::proportional(13.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.hint_text(RichText::new(hint_text).color(t::TEXT_DISABLED))
|
||||
.frame(false)
|
||||
.desired_width(ui.available_width() - 44.0);
|
||||
|
||||
let enter = Frame::NONE
|
||||
.fill(t::BG_INTERACTIVE)
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| ui.add(input))
|
||||
.inner;
|
||||
|
||||
if !s.chat_input.is_empty() && s.last_typing_send.elapsed().as_secs() >= 3 {
|
||||
s.last_typing_send = Instant::now();
|
||||
let net = net.clone();
|
||||
let c = ch_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::TypingStart { channel_id: c }).await;
|
||||
});
|
||||
}
|
||||
|
||||
let submit = (enter.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
|| ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("→").color(t::ACCENT).size(14.0))
|
||||
.fill(t::ACCENT_10)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.corner_radius(t::R_MD)
|
||||
.min_size(egui::vec2(32.0, 32.0)),
|
||||
)
|
||||
.clicked();
|
||||
|
||||
if submit && !s.chat_input.trim().is_empty() {
|
||||
if let Some(edit_id) = s.editing_message_id.clone() {
|
||||
let content = s.chat_input.trim().to_string();
|
||||
s.chat_input.clear();
|
||||
s.editing_message_id = None;
|
||||
let net = net.clone();
|
||||
let cid = ch_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::MessageEdit {
|
||||
channel_id: cid,
|
||||
message_id: edit_id,
|
||||
content,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
let reply_to = s.replying_to.clone().map(|(id, _)| id);
|
||||
send_chat(s, net, ch_id, &identity.pubkey_hex, reply_to);
|
||||
s.replying_to = None;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn send_chat(
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
ch_id: &str,
|
||||
sender_id: &str,
|
||||
reply_to: Option<String>,
|
||||
) {
|
||||
let content = s.chat_input.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
s.chat_input.clear();
|
||||
|
||||
s.messages
|
||||
.entry(ch_id.to_string())
|
||||
.or_default()
|
||||
.push(ChatMessage {
|
||||
message_id: uuid::Uuid::new_v4().to_string(),
|
||||
sender_id: sender_id.to_string(),
|
||||
content: content.clone(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64,
|
||||
edited: false,
|
||||
reactions: HashMap::new(),
|
||||
reply_to: reply_to.clone(),
|
||||
});
|
||||
s.scroll_bottom = true;
|
||||
|
||||
let need_join = s.server_channel.as_deref() != Some(ch_id);
|
||||
if need_join {
|
||||
s.server_channel = Some(ch_id.to_string());
|
||||
}
|
||||
|
||||
let net = net.clone();
|
||||
let ch = ch_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
if need_join {
|
||||
net.send(NetCommand::JoinChannel {
|
||||
channel_id: ch.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
net.send(NetCommand::SendChat {
|
||||
channel_id: ch,
|
||||
content,
|
||||
reply_to,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
75
src/ui/chat/text/messages/list.rs
Normal file
75
src/ui/chat/text/messages/list.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use crate::state::{TYPING_TIMEOUT, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, FontId, RichText, ScrollArea};
|
||||
use std::time::Instant;
|
||||
use vnox_client::{identity::Identity, net::NetHandle};
|
||||
|
||||
use super::row::message_row;
|
||||
|
||||
pub(crate) fn show(
|
||||
ui: &mut egui::Ui,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
identity: &Identity,
|
||||
ch_id: &str,
|
||||
max_h: f32,
|
||||
) {
|
||||
let msgs = s.messages.get(ch_id).cloned().unwrap_or_default();
|
||||
ScrollArea::vertical()
|
||||
.max_height(max_h)
|
||||
.auto_shrink([false; 2])
|
||||
.stick_to_bottom(s.scroll_bottom)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(12.0);
|
||||
if msgs.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("no messages yet")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("today")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let mut prev: Option<String> = None;
|
||||
for msg in &msgs {
|
||||
let group_start = prev.as_deref() != Some(&msg.sender_id);
|
||||
message_row(ui, s, net, identity, msg, ch_id, group_start);
|
||||
prev = Some(msg.sender_id.clone());
|
||||
}
|
||||
}
|
||||
typing_indicator(ui, s, ch_id);
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
s.scroll_bottom = false;
|
||||
}
|
||||
|
||||
fn typing_indicator(ui: &mut egui::Ui, s: &mut UiState, ch_id: &str) {
|
||||
let now = Instant::now();
|
||||
if let Some(typers) = s.typing_users.get_mut(ch_id) {
|
||||
typers.retain(|(_, t)| now.duration_since(*t) < TYPING_TIMEOUT);
|
||||
if typers.is_empty() {
|
||||
s.typing_users.remove(ch_id);
|
||||
return;
|
||||
}
|
||||
let names: Vec<&str> = typers.iter().map(|(n, _)| n.as_str()).collect();
|
||||
let label = if names.len() == 1 {
|
||||
format!("{} is typing...", names[0])
|
||||
} else {
|
||||
format!(
|
||||
"{} and {} are typing...",
|
||||
names[..names.len() - 1].join(", "),
|
||||
names[names.len() - 1]
|
||||
)
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
}
|
||||
}
|
||||
130
src/ui/chat/text/messages/mod.rs
Normal file
130
src/ui/chat/text/messages/mod.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
pub mod list;
|
||||
pub mod row;
|
||||
|
||||
pub(crate) use list::show;
|
||||
pub use row::message_row;
|
||||
|
||||
use crate::state::{ChatMessage, UiState};
|
||||
use crate::theme as t;
|
||||
use crate::theme::icons;
|
||||
use eframe::egui::{self, RichText};
|
||||
use vnox_client::{
|
||||
identity::Identity,
|
||||
net::{NetCommand, NetHandle},
|
||||
};
|
||||
|
||||
fn own_actions(
|
||||
ui: &mut egui::Ui,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
msg: &ChatMessage,
|
||||
ch_id: &str,
|
||||
) {
|
||||
if t::icon_btn_drawn(ui, icons::pencil, "edit", None, 16.0).clicked() {
|
||||
s.chat_input = msg.content.clone();
|
||||
s.editing_message_id = Some(msg.message_id.clone());
|
||||
}
|
||||
if t::icon_btn_drawn(ui, icons::trash, "delete", None, 16.0).clicked() {
|
||||
let net = net.clone();
|
||||
let mid = msg.message_id.clone();
|
||||
let cid = ch_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::MessageDelete {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn reaction_buttons(
|
||||
ui: &mut egui::Ui,
|
||||
net: &NetHandle,
|
||||
msg: &ChatMessage,
|
||||
ch_id: &str,
|
||||
identity: &Identity,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (emoji, users) in &msg.reactions {
|
||||
let count = users.len();
|
||||
let has_reacted = users.iter().any(|u| u == &identity.pubkey_hex);
|
||||
let bg = if has_reacted {
|
||||
t::ACCENT_20
|
||||
} else {
|
||||
t::BG_ELEVATED
|
||||
};
|
||||
let border = if has_reacted {
|
||||
t::BORDER_ACCENT
|
||||
} else {
|
||||
t::BORDER_SUBTLE
|
||||
};
|
||||
let label = format!("{} {}", emoji, count);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new(&label).size(12.0).color(t::TEXT_SECONDARY))
|
||||
.fill(bg)
|
||||
.stroke(egui::Stroke::new(1.0, border))
|
||||
.corner_radius(4.0)
|
||||
.min_size(egui::Vec2::ZERO),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let mid = msg.message_id.clone();
|
||||
let cid = ch_id.to_string();
|
||||
let e = emoji.clone();
|
||||
if has_reacted {
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::ReactionRemove {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
emoji: e,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::ReactionAdd {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
emoji: e,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn read_receipt_indicator(ui: &mut egui::Ui, s: &UiState) {
|
||||
if let Some(ch) = &s.active_channel
|
||||
&& s.read_receipts.contains_key(ch)
|
||||
{
|
||||
ui.add_space(6.0);
|
||||
let (rr, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
|
||||
icons::check(ui.painter(), rr, t::TEXT_DISABLED);
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_ts(ts: i64) -> String {
|
||||
let s = (ts / 1000).abs() % 86400;
|
||||
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
|
||||
}
|
||||
|
||||
fn nick_color(s: &UiState, sender_id: &str) -> egui::Color32 {
|
||||
if let Some(color) = s.user_colors.get(sender_id) {
|
||||
return *color;
|
||||
}
|
||||
let nick = s.nick_for(sender_id);
|
||||
let c = [
|
||||
t::ACCENT,
|
||||
t::SUCCESS,
|
||||
t::WARNING,
|
||||
t::INFO,
|
||||
egui::Color32::from_rgb(200, 100, 180),
|
||||
];
|
||||
c[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % c.len()]
|
||||
}
|
||||
220
src/ui/chat/text/messages/row.rs
Normal file
220
src/ui/chat/text/messages/row.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
use crate::state::{ChatMessage, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, FontId, Frame, RichText};
|
||||
use vnox_client::{
|
||||
identity::Identity,
|
||||
net::{NetCommand, NetHandle},
|
||||
};
|
||||
|
||||
use super::{fmt_ts, nick_color, own_actions, reaction_buttons, read_receipt_indicator};
|
||||
|
||||
pub fn message_row(
|
||||
ui: &mut egui::Ui,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
identity: &Identity,
|
||||
msg: &ChatMessage,
|
||||
ch_id: &str,
|
||||
group_start: bool,
|
||||
) {
|
||||
let nick = s.nick_for(&msg.sender_id).to_string();
|
||||
let is_own = msg.sender_id == identity.pubkey_hex;
|
||||
let msg_id = msg.message_id.clone();
|
||||
let sender_id = msg.sender_id.clone();
|
||||
let content_clone = msg.content.clone();
|
||||
|
||||
let row_response = Frame::NONE
|
||||
.inner_margin(egui::Margin {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: if group_start { 6 } else { 1 },
|
||||
bottom: 0,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 10.0;
|
||||
if group_start {
|
||||
let init = nick
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string();
|
||||
t::avatar(ui, &init, nick_color(s, &msg.sender_id), 32.0);
|
||||
} else {
|
||||
ui.add_space(32.0);
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
if group_start {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(&nick)
|
||||
.font(FontId::proportional(13.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(fmt_ts(msg.timestamp))
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
if msg.edited {
|
||||
ui.label(
|
||||
RichText::new("(edited)")
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
}
|
||||
if is_own {
|
||||
own_actions(ui, s, net, msg, ch_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Reply indicator
|
||||
if let Some(reply_id) = &msg.reply_to {
|
||||
let reply_snippet = s
|
||||
.messages
|
||||
.get(ch_id)
|
||||
.and_then(|msgs| msgs.iter().find(|m| &m.message_id == reply_id))
|
||||
.map(|m| {
|
||||
let reply_nick = s.nick_for(&m.sender_id).to_string();
|
||||
let snippet: String = m.content.chars().take(60).collect();
|
||||
format!("→ {reply_nick}: {snippet}")
|
||||
})
|
||||
.unwrap_or_else(|| "→ (deleted message)".to_string());
|
||||
ui.label(
|
||||
RichText::new(reply_snippet)
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED)
|
||||
.italics(),
|
||||
);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(&msg.content)
|
||||
.font(FontId::proportional(13.0))
|
||||
.color(t::TEXT_SECONDARY),
|
||||
);
|
||||
read_receipt_indicator(ui, s);
|
||||
});
|
||||
if !msg.reactions.is_empty() {
|
||||
reaction_buttons(ui, net, msg, ch_id, identity);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.response;
|
||||
|
||||
// Right-click context menu (egui 0.33 API: Response::context_menu).
|
||||
row_response.context_menu(|ui| {
|
||||
s.context_menu_message_id = Some(msg_id.clone());
|
||||
let quick_reactions = ["👍", "❤️", "😂", "🎉", "👀", "🤔"];
|
||||
ui.horizontal(|ui| {
|
||||
for emoji in quick_reactions {
|
||||
let has_reacted = msg
|
||||
.reactions
|
||||
.get(emoji)
|
||||
.is_some_and(|users| users.contains(&identity.pubkey_hex));
|
||||
let btn_text = if has_reacted {
|
||||
format!("{emoji} +")
|
||||
} else {
|
||||
emoji.to_string()
|
||||
};
|
||||
if ui
|
||||
.button(RichText::new(btn_text).font(FontId::proportional(14.0)))
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let mid = msg_id.clone();
|
||||
let cid = ch_id.to_string();
|
||||
let e = emoji.to_string();
|
||||
if has_reacted {
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::ReactionRemove {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
emoji: e,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::ReactionAdd {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
emoji: e,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("Reply").font(FontId::proportional(12.0)))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
s.replying_to = Some((msg_id.clone(), sender_id.clone()));
|
||||
s.editing_message_id = None;
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("Copy text").font(FontId::proportional(12.0)))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
ui.ctx().copy_text(content_clone.clone());
|
||||
ui.close();
|
||||
}
|
||||
if is_own {
|
||||
ui.separator();
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new("Edit").font(FontId::proportional(12.0)))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
s.chat_input = content_clone.clone();
|
||||
s.editing_message_id = Some(msg_id.clone());
|
||||
s.replying_to = None;
|
||||
ui.close();
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Delete")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::ERROR),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let net = net.clone();
|
||||
let mid = msg_id.clone();
|
||||
let cid = ch_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::MessageDelete {
|
||||
channel_id: cid,
|
||||
message_id: mid,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
77
src/ui/chat/text/mod.rs
Normal file
77
src/ui/chat/text/mod.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
mod input;
|
||||
mod messages;
|
||||
|
||||
use eframe::egui::{self, Context, FontId, Frame, RichText};
|
||||
use vnox_client::{identity::Identity, net::NetHandle};
|
||||
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
|
||||
pub(super) use messages::message_row;
|
||||
|
||||
pub(crate) fn text_chat(
|
||||
ui: &mut egui::Ui,
|
||||
ctx: &Context,
|
||||
s: &mut UiState,
|
||||
net: &NetHandle,
|
||||
identity: &Identity,
|
||||
ch_id: &str,
|
||||
ch_name: &str,
|
||||
) {
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(16, 0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_height(40.0);
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(RichText::new("#").color(t::TEXT_MUTED).size(16.0));
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(ch_name)
|
||||
.font(FontId::proportional(14.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
});
|
||||
let r = ui.max_rect();
|
||||
ui.painter().hline(
|
||||
r.x_range(),
|
||||
r.top() + 40.0,
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
|
||||
if let Some(elapsed) = s.voice_elapsed() {
|
||||
voice_banner(ui, s, &elapsed);
|
||||
}
|
||||
|
||||
let input_h = 52.0;
|
||||
messages::show(ui, s, net, identity, ch_id, ui.available_height() - input_h);
|
||||
input::chat_input_bar(ui, ctx, s, net, ch_id, identity);
|
||||
});
|
||||
}
|
||||
|
||||
fn voice_banner(ui: &mut egui::Ui, s: &UiState, elapsed: &str) {
|
||||
Frame::NONE
|
||||
.fill(t::BG_ELEVATED)
|
||||
.inner_margin(egui::Margin::symmetric(16, 5))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (dot, _) = ui.allocate_exact_size(egui::vec2(7.0, 7.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(dot.center(), 3.5, t::SUCCESS);
|
||||
ui.label(RichText::new("voice").color(t::SUCCESS).size(12.0));
|
||||
ui.label(RichText::new("·").color(t::TEXT_DISABLED).size(12.0));
|
||||
if let Some(ch) = s.active_voice_channel() {
|
||||
ui.label(RichText::new(&ch.name).color(t::TEXT_SECONDARY).size(12.0));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(elapsed)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
147
src/ui/chat/voice/mod.rs
Normal file
147
src/ui/chat/voice/mod.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
mod panel;
|
||||
|
||||
use eframe::egui::{self, FontId, Frame, RichText};
|
||||
|
||||
use crate::state::{Channel, UiState, VoiceUiState};
|
||||
use crate::theme as t;
|
||||
|
||||
pub(crate) fn voice_panel(ui: &mut egui::Ui, s: &mut UiState, ch: &Channel) {
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(16, 0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_height(40.0);
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(RichText::new("~").color(t::TEXT_MUTED).size(15.0));
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(&ch.name)
|
||||
.font(FontId::proportional(14.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
if let Some(elapsed) = s.voice_elapsed() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(elapsed)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let r = ui.max_rect();
|
||||
ui.painter().hline(
|
||||
r.x_range(),
|
||||
r.top() + 40.0,
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
|
||||
ui.add_space(16.0);
|
||||
Frame::NONE
|
||||
.inner_margin(egui::Margin::symmetric(20, 0))
|
||||
.show(ui, |ui| {
|
||||
// Voice activity banner — shows when local or remote user is speaking
|
||||
if s.local_speaking || s.remote_speaking {
|
||||
voice_activity_banner(ui, s);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if ch.members.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("no members yet")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
}
|
||||
// Identify which member is currently speaking by sender_id.
|
||||
let speaking_uid = if !s.last_remote_speaker_id.is_empty() {
|
||||
Some(s.last_remote_speaker_id.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for nick in &ch.members {
|
||||
let user_id = s
|
||||
.user_names
|
||||
.iter()
|
||||
.find(|(_, n)| *n == nick)
|
||||
.map(|(id, _)| id.clone())
|
||||
.unwrap_or_else(|| nick.clone());
|
||||
let member_speaking = speaking_uid.as_deref() == Some(&user_id);
|
||||
panel::member_card(
|
||||
ui,
|
||||
nick,
|
||||
&user_id,
|
||||
&mut s.per_user_volumes,
|
||||
member_speaking,
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
|
||||
if let VoiceUiState::Error(ref err) = s.voice {
|
||||
ui.add_space(16.0);
|
||||
Frame::NONE
|
||||
.fill(egui::Color32::from_rgba_premultiplied(217, 96, 74, 20))
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
egui::Color32::from_rgba_premultiplied(217, 96, 74, 60),
|
||||
))
|
||||
.inner_margin(egui::Margin::same(12))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("microphone error: {err}"))
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::ERROR),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
|
||||
ui.add_space(16.0);
|
||||
panel::voice_controls(ui, s);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn voice_activity_banner(ui: &mut egui::Ui, s: &UiState) {
|
||||
let remote_nick: Option<String> = if !s.last_remote_speaker_id.is_empty() {
|
||||
Some(s.nick_for(&s.last_remote_speaker_id).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (label, color) = match (s.local_speaking, s.remote_speaking, remote_nick.as_deref()) {
|
||||
(true, true, Some(nick)) => (format!("you and {nick} are talking"), t::SUCCESS),
|
||||
(true, true, None) => ("you and others are talking".to_string(), t::SUCCESS),
|
||||
(true, false, _) => ("you are talking".to_string(), t::SUCCESS),
|
||||
(false, true, Some(nick)) => (format!("{nick} is talking"), t::INFO),
|
||||
(false, true, None) => ("someone is talking".to_string(), t::INFO),
|
||||
_ => return,
|
||||
};
|
||||
Frame::NONE
|
||||
.fill(egui::Color32::from_rgba_premultiplied(
|
||||
color.r(),
|
||||
color.g(),
|
||||
color.b(),
|
||||
20,
|
||||
))
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(egui::Stroke::new(1.0, color))
|
||||
.inner_margin(egui::Margin::symmetric(12, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// Pulsating dot
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 5.0, color);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(color)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
199
src/ui/chat/voice/panel.rs
Normal file
199
src/ui/chat/voice/panel.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use eframe::egui::{self, FontId, Frame, Rect, RichText};
|
||||
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use crate::theme::icons;
|
||||
|
||||
pub(super) fn member_card(
|
||||
ui: &mut egui::Ui,
|
||||
nick: &str,
|
||||
user_id: &str,
|
||||
volumes: &mut std::collections::HashMap<String, f32>,
|
||||
speaking: bool,
|
||||
) {
|
||||
let vol = volumes.entry(user_id.to_string()).or_insert(80.0);
|
||||
let avatar_color = if speaking {
|
||||
t::SUCCESS
|
||||
} else {
|
||||
nick_color(nick)
|
||||
};
|
||||
Frame::NONE
|
||||
.fill(if speaking {
|
||||
egui::Color32::from_rgba_premultiplied(
|
||||
t::SUCCESS.r(),
|
||||
t::SUCCESS.g(),
|
||||
t::SUCCESS.b(),
|
||||
15,
|
||||
)
|
||||
} else {
|
||||
t::BG_ELEVATED
|
||||
})
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(if speaking {
|
||||
egui::Stroke::new(1.0, t::SUCCESS)
|
||||
} else {
|
||||
egui::Stroke::new(1.0, t::BORDER_DEFAULT)
|
||||
})
|
||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let init = nick
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string();
|
||||
t::avatar(ui, &init, avatar_color, 28.0);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(nick)
|
||||
.font(FontId::proportional(13.0))
|
||||
.color(if speaking {
|
||||
t::SUCCESS
|
||||
} else {
|
||||
t::TEXT_PRIMARY
|
||||
})
|
||||
.strong(),
|
||||
);
|
||||
if speaking {
|
||||
let (sr, _) = ui
|
||||
.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover());
|
||||
icons::speaker(ui.painter(), sr, t::SUCCESS);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new("vol")
|
||||
.font(FontId::proportional(9.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
ui.add(
|
||||
egui::Slider::new(vol, 0.0..=100.0)
|
||||
.show_value(false)
|
||||
.smallest_positive(0.0),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn voice_controls(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
|
||||
// Microphone button — drawn icon.
|
||||
let mic_color = if s.mic_enabled {
|
||||
t::TEXT_SECONDARY
|
||||
} else {
|
||||
t::ERROR
|
||||
};
|
||||
let mic_fill = if s.mic_enabled {
|
||||
t::BG_ELEVATED
|
||||
} else {
|
||||
egui::Color32::from_rgba_premultiplied(217, 96, 74, 20)
|
||||
};
|
||||
let mic_stroke = if s.mic_enabled {
|
||||
egui::Stroke::new(1.0, t::BORDER_DEFAULT)
|
||||
} else {
|
||||
egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(217, 96, 74, 80))
|
||||
};
|
||||
let (mic_rect, mic_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
|
||||
if ui.is_rect_visible(mic_rect) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(mic_rect, t::R_FULL, mic_fill);
|
||||
painter.rect_stroke(mic_rect, t::R_FULL, mic_stroke, egui::StrokeKind::Middle);
|
||||
let mic_icon = if s.mic_enabled {
|
||||
icons::microphone
|
||||
} else {
|
||||
icons::microphone_off
|
||||
};
|
||||
let inner = inset(mic_rect, 10.0);
|
||||
mic_icon(painter, inner, mic_color);
|
||||
}
|
||||
if mic_resp.clicked() {
|
||||
s.mic_enabled = !s.mic_enabled;
|
||||
}
|
||||
mic_resp.on_hover_text("microphone");
|
||||
|
||||
// Deafen button — drawn icon.
|
||||
let deaf_color = if s.deafen {
|
||||
t::ERROR
|
||||
} else {
|
||||
t::TEXT_SECONDARY
|
||||
};
|
||||
let deaf_fill = if s.deafen {
|
||||
egui::Color32::from_rgba_premultiplied(217, 96, 74, 20)
|
||||
} else {
|
||||
t::BG_ELEVATED
|
||||
};
|
||||
let (deaf_rect, deaf_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
|
||||
if ui.is_rect_visible(deaf_rect) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(deaf_rect, t::R_FULL, deaf_fill);
|
||||
painter.rect_stroke(
|
||||
deaf_rect,
|
||||
t::R_FULL,
|
||||
egui::Stroke::new(1.0, t::BORDER_DEFAULT),
|
||||
egui::StrokeKind::Middle,
|
||||
);
|
||||
let deaf_icon = if s.deafen {
|
||||
icons::headphones_off
|
||||
} else {
|
||||
icons::headphones
|
||||
};
|
||||
let inner = inset(deaf_rect, 10.0);
|
||||
deaf_icon(painter, inner, deaf_color);
|
||||
}
|
||||
if deaf_resp.clicked() {
|
||||
s.deafen = !s.deafen;
|
||||
}
|
||||
deaf_resp.on_hover_text("deafen");
|
||||
|
||||
// Leave button — drawn X on red background.
|
||||
let (leave_rect, leave_resp) =
|
||||
ui.allocate_exact_size(egui::vec2(44.0, 44.0), egui::Sense::click());
|
||||
if ui.is_rect_visible(leave_rect) {
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(leave_rect, t::R_FULL, t::ERROR);
|
||||
let inner = inset(leave_rect, 12.0);
|
||||
icons::close(painter, inner, egui::Color32::from_rgb(26, 16, 8));
|
||||
}
|
||||
if leave_resp.clicked()
|
||||
&& let Some(id) = s.active_channel.clone()
|
||||
{
|
||||
if s.channels.iter().any(|c| c.id == id && c.kind == "text") {
|
||||
s.active_channel = Some("general".into());
|
||||
} else {
|
||||
s.active_channel = s
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.kind == "text")
|
||||
.map(|c| c.id.clone());
|
||||
}
|
||||
}
|
||||
leave_resp.on_hover_text("leave voice channel");
|
||||
});
|
||||
}
|
||||
|
||||
fn inset(rect: Rect, pad: f32) -> Rect {
|
||||
Rect::from_min_max(
|
||||
(rect.min.x + pad, rect.min.y + pad).into(),
|
||||
(rect.max.x - pad, rect.max.y - pad).into(),
|
||||
)
|
||||
}
|
||||
|
||||
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()]
|
||||
}
|
||||
45
src/ui/connect/action.rs
Normal file
45
src/ui/connect/action.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use eframe::egui::Color32;
|
||||
|
||||
use crate::state::{ConnState, UiState};
|
||||
use crate::theme as t;
|
||||
use vnox_client::net::{NetCommand, NetHandle};
|
||||
|
||||
pub fn disconnect(s: &mut UiState, net: &NetHandle) {
|
||||
let net = net.clone();
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::Disconnect).await;
|
||||
});
|
||||
s.conn = ConnState::Disconnected;
|
||||
}
|
||||
|
||||
pub fn connect_to(s: &mut UiState, net: &NetHandle, addr: &str) {
|
||||
if addr.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(i) = s.bookmarks.iter().position(|b| b.address == addr) {
|
||||
s.selected_bookmark = i;
|
||||
}
|
||||
s.connect_input = addr.to_string();
|
||||
s.conn = ConnState::Connecting;
|
||||
let net = net.clone();
|
||||
let address = addr.to_string();
|
||||
let auto_reconnect = s.auto_reconnect;
|
||||
tokio::spawn(async move {
|
||||
net.send(NetCommand::Connect {
|
||||
address,
|
||||
auto_reconnect,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn msg_color(msg: &str) -> Color32 {
|
||||
let l = msg.to_ascii_lowercase();
|
||||
if l.contains("error") || l.contains("fail") || l.contains("refuse") {
|
||||
t::DANGER
|
||||
} else if l.contains("connected") {
|
||||
t::SUCCESS
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}
|
||||
}
|
||||
130
src/ui/connect/bookmarks.rs
Normal file
130
src/ui/connect/bookmarks.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
mod add;
|
||||
|
||||
use eframe::egui::{self, Frame};
|
||||
use vnox_client::net::NetHandle;
|
||||
|
||||
use crate::state::{ConnState, UiState};
|
||||
use crate::theme as t;
|
||||
|
||||
use super::action::connect_to;
|
||||
|
||||
pub fn server_list(ui: &mut egui::Ui, s: &mut UiState, net: &NetHandle) {
|
||||
Frame::NONE
|
||||
.fill(t::BG_ELEVATED)
|
||||
.corner_radius(t::R_LG)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.inner_margin(egui::Margin::same(16))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("SERVERS")
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let add_btn = egui::Button::new(
|
||||
egui::RichText::new("+")
|
||||
.font(egui::FontId::proportional(16.0))
|
||||
.color(t::ACCENT_SOFT),
|
||||
)
|
||||
.fill(t::ACCENT_10)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(28.0, 28.0));
|
||||
if ui.add(add_btn).on_hover_text("add server").clicked() {
|
||||
s.add_server_open = !s.add_server_open;
|
||||
if s.add_server_open {
|
||||
s.add_server_input = String::new();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if s.add_server_open {
|
||||
add::add_form(ui, s);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let busy = matches!(
|
||||
s.conn,
|
||||
ConnState::Connecting | ConnState::Reconnecting { .. }
|
||||
);
|
||||
|
||||
if s.bookmarks.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("no servers yet — click + to add one")
|
||||
.font(egui::FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
} else {
|
||||
for (i, bm) in s.bookmarks.clone().into_iter().enumerate() {
|
||||
let active = s.connect_input == bm.address;
|
||||
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 resp = Frame::NONE
|
||||
.fill(bg)
|
||||
.stroke(stroke)
|
||||
.corner_radius(t::R_SM)
|
||||
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("🖥").font(egui::FontId::proportional(13.0)),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&bm.label)
|
||||
.font(egui::FontId::proportional(13.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(&bm.address)
|
||||
.font(egui::FontId::monospace(10.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
});
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if t::icon_btn_drawn(
|
||||
ui,
|
||||
crate::theme::icons::close,
|
||||
"remove bookmark",
|
||||
None,
|
||||
16.0,
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
s.remove_bookmark(i);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
.response
|
||||
.interact(egui::Sense::click());
|
||||
|
||||
if resp.clicked() && !busy {
|
||||
s.connect_input = bm.address.clone();
|
||||
s.selected_bookmark = i;
|
||||
if matches!(s.conn, ConnState::Disconnected) {
|
||||
connect_to(s, net, &bm.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
51
src/ui/connect/bookmarks/add.rs
Normal file
51
src/ui/connect/bookmarks/add.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Frame};
|
||||
|
||||
pub fn add_form(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
ui.add_space(8.0);
|
||||
let mut addr = s.add_server_input.clone();
|
||||
Frame::NONE
|
||||
.fill(t::BG_INTERACTIVE)
|
||||
.corner_radius(t::R_SM)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.inner_margin(egui::Margin::symmetric(10, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut addr)
|
||||
.font(egui::FontId::monospace(12.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.hint_text(
|
||||
egui::RichText::new("ip:port or host:port").color(t::TEXT_DISABLED),
|
||||
)
|
||||
.desired_width(ui.available_width() - 120.0)
|
||||
.frame(false),
|
||||
);
|
||||
let save_btn = egui::Button::new(
|
||||
egui::RichText::new("Save")
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
)
|
||||
.fill(t::BG_INTERACTIVE)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(50.0, 24.0));
|
||||
if ui.add(save_btn).clicked() && !addr.trim().is_empty() {
|
||||
s.connect_input = addr.trim().to_string();
|
||||
s.add_bookmark_from_input();
|
||||
s.add_server_open = false;
|
||||
s.add_server_input = String::new();
|
||||
s.connect_input = String::new();
|
||||
}
|
||||
if t::icon_btn_drawn(ui, crate::theme::icons::close, "cancel", None, 18.0).clicked()
|
||||
{
|
||||
s.add_server_open = false;
|
||||
s.add_server_input = String::new();
|
||||
}
|
||||
});
|
||||
});
|
||||
s.add_server_input = addr;
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
149
src/ui/connect/mod.rs
Normal file
149
src/ui/connect/mod.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use eframe::egui::{self, Frame};
|
||||
use vnox_client::{identity::Identity, net::NetHandle};
|
||||
|
||||
use crate::state::{ConnState, UiState};
|
||||
use crate::theme as t;
|
||||
|
||||
mod action;
|
||||
mod bookmarks;
|
||||
|
||||
pub use action::{connect_to, disconnect};
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, s: &mut UiState, _identity: &Identity, net: &NetHandle) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.set_max_width(520.0);
|
||||
ui.add_space(40.0);
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new("VNOX")
|
||||
.font(egui::FontId::proportional(32.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
egui::RichText::new("self-hosted voice & chat")
|
||||
.font(egui::FontId::proportional(13.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.add_space(32.0);
|
||||
|
||||
bookmarks::server_list(ui, s, net);
|
||||
|
||||
ui.add_space(20.0);
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new("Quick Connect")
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
|
||||
Frame::NONE
|
||||
.fill(t::BG_INTERACTIVE)
|
||||
.corner_radius(t::R_MD)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.inner_margin(egui::Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(">")
|
||||
.font(egui::FontId::monospace(12.0))
|
||||
.color(t::ACCENT_SOFT),
|
||||
);
|
||||
let w = ui.available_width().max(80.0);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.connect_input)
|
||||
.font(egui::FontId::monospace(13.0))
|
||||
.text_color(t::TEXT_PRIMARY)
|
||||
.frame(false)
|
||||
.hint_text(
|
||||
egui::RichText::new("127.0.0.1:7600").color(t::TEXT_DISABLED),
|
||||
)
|
||||
.desired_width(w),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.add_space(8.0);
|
||||
|
||||
let label = match &s.conn {
|
||||
ConnState::Disconnected => "Connect",
|
||||
ConnState::Connecting | ConnState::Reconnecting { .. } => "Cancel",
|
||||
ConnState::Connected { .. } => "Disconnect",
|
||||
};
|
||||
let btn = egui::Button::new(
|
||||
egui::RichText::new(label)
|
||||
.font(egui::FontId::proportional(13.0))
|
||||
.color(t::TEXT_PRIMARY)
|
||||
.strong(),
|
||||
)
|
||||
.fill(t::BG_ELEVATED)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_ACCENT))
|
||||
.corner_radius(t::R_MD)
|
||||
.min_size(egui::vec2(200.0, 38.0));
|
||||
|
||||
if ui.add(btn).clicked() || enter_pressed {
|
||||
match &s.conn {
|
||||
ConnState::Disconnected => {
|
||||
let addr = s.connect_input.trim().to_owned();
|
||||
if !addr.is_empty() {
|
||||
connect_to(s, net, &addr);
|
||||
}
|
||||
}
|
||||
_ => disconnect(s, net),
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut s.auto_reconnect, "");
|
||||
ui.label(
|
||||
egui::RichText::new("auto-reconnect on drop")
|
||||
.font(egui::FontId::proportional(12.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
});
|
||||
|
||||
if let Some(ref msg) = s.status_msg {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
egui::RichText::new(msg)
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
.color(action::msg_color(msg)),
|
||||
);
|
||||
}
|
||||
|
||||
ui.add_space(28.0);
|
||||
let sep = ui.available_rect_before_wrap();
|
||||
let painter = ui.painter();
|
||||
painter.hline(
|
||||
sep.x_range(),
|
||||
sep.top(),
|
||||
egui::Stroke::new(1.0, t::BORDER_SUBTLE),
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("💡").font(egui::FontId::proportional(13.0)));
|
||||
ui.label(
|
||||
egui::RichText::new("Host your own —")
|
||||
.font(egui::FontId::proportional(12.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new("github.com/loki5512344/Vnox")
|
||||
.font(egui::FontId::proportional(12.0))
|
||||
.color(t::ACCENT_SOFT),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(" · self-host in 5 min")
|
||||
.font(egui::FontId::proportional(12.0))
|
||||
.color(t::TEXT_DISABLED),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(20.0);
|
||||
});
|
||||
}
|
||||
133
src/ui/keybind/bindings.rs
Normal file
133
src/ui/keybind/bindings.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use eframe::egui::Key;
|
||||
|
||||
pub(crate) fn name_from_key(key: Key) -> Option<&'static str> {
|
||||
match key {
|
||||
Key::Space => Some("space"),
|
||||
Key::A => Some("a"),
|
||||
Key::B => Some("b"),
|
||||
Key::C => Some("c"),
|
||||
Key::D => Some("d"),
|
||||
Key::E => Some("e"),
|
||||
Key::F => Some("f"),
|
||||
Key::G => Some("g"),
|
||||
Key::H => Some("h"),
|
||||
Key::I => Some("i"),
|
||||
Key::J => Some("j"),
|
||||
Key::K => Some("k"),
|
||||
Key::L => Some("l"),
|
||||
Key::M => Some("m"),
|
||||
Key::N => Some("n"),
|
||||
Key::O => Some("o"),
|
||||
Key::P => Some("p"),
|
||||
Key::Q => Some("q"),
|
||||
Key::R => Some("r"),
|
||||
Key::S => Some("s"),
|
||||
Key::T => Some("t"),
|
||||
Key::U => Some("u"),
|
||||
Key::V => Some("v"),
|
||||
Key::W => Some("w"),
|
||||
Key::X => Some("x"),
|
||||
Key::Y => Some("y"),
|
||||
Key::Z => Some("z"),
|
||||
Key::F1 => Some("f1"),
|
||||
Key::F2 => Some("f2"),
|
||||
Key::F3 => Some("f3"),
|
||||
Key::F4 => Some("f4"),
|
||||
Key::F5 => Some("f5"),
|
||||
Key::F6 => Some("f6"),
|
||||
Key::F7 => Some("f7"),
|
||||
Key::F8 => Some("f8"),
|
||||
Key::F9 => Some("f9"),
|
||||
Key::F10 => Some("f10"),
|
||||
Key::F11 => Some("f11"),
|
||||
Key::F12 => Some("f12"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key_from_name(name: &str) -> Option<Key> {
|
||||
match name {
|
||||
"a" => Some(Key::A),
|
||||
"b" => Some(Key::B),
|
||||
"c" => Some(Key::C),
|
||||
"d" => Some(Key::D),
|
||||
"e" => Some(Key::E),
|
||||
"f" => Some(Key::F),
|
||||
"g" => Some(Key::G),
|
||||
"h" => Some(Key::H),
|
||||
"i" => Some(Key::I),
|
||||
"j" => Some(Key::J),
|
||||
"k" => Some(Key::K),
|
||||
"l" => Some(Key::L),
|
||||
"m" => Some(Key::M),
|
||||
"n" => Some(Key::N),
|
||||
"o" => Some(Key::O),
|
||||
"p" => Some(Key::P),
|
||||
"q" => Some(Key::Q),
|
||||
"r" => Some(Key::R),
|
||||
"s" => Some(Key::S),
|
||||
"t" => Some(Key::T),
|
||||
"u" => Some(Key::U),
|
||||
"v" => Some(Key::V),
|
||||
"w" => Some(Key::W),
|
||||
"x" => Some(Key::X),
|
||||
"y" => Some(Key::Y),
|
||||
"z" => Some(Key::Z),
|
||||
"space" => Some(Key::Space),
|
||||
"f1" => Some(Key::F1),
|
||||
"f2" => Some(Key::F2),
|
||||
"f3" => Some(Key::F3),
|
||||
"f4" => Some(Key::F4),
|
||||
"f5" => Some(Key::F5),
|
||||
"f6" => Some(Key::F6),
|
||||
"f7" => Some(Key::F7),
|
||||
"f8" => Some(Key::F8),
|
||||
"f9" => Some(Key::F9),
|
||||
"f10" => Some(Key::F10),
|
||||
"f11" => Some(Key::F11),
|
||||
"f12" => Some(Key::F12),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const ALL_KEYS: &[Key] = &[
|
||||
Key::A,
|
||||
Key::B,
|
||||
Key::C,
|
||||
Key::D,
|
||||
Key::E,
|
||||
Key::F,
|
||||
Key::G,
|
||||
Key::H,
|
||||
Key::I,
|
||||
Key::J,
|
||||
Key::K,
|
||||
Key::L,
|
||||
Key::M,
|
||||
Key::N,
|
||||
Key::O,
|
||||
Key::P,
|
||||
Key::Q,
|
||||
Key::R,
|
||||
Key::S,
|
||||
Key::T,
|
||||
Key::U,
|
||||
Key::V,
|
||||
Key::W,
|
||||
Key::X,
|
||||
Key::Y,
|
||||
Key::Z,
|
||||
Key::Space,
|
||||
Key::F1,
|
||||
Key::F2,
|
||||
Key::F3,
|
||||
Key::F4,
|
||||
Key::F5,
|
||||
Key::F6,
|
||||
Key::F7,
|
||||
Key::F8,
|
||||
Key::F9,
|
||||
Key::F10,
|
||||
Key::F11,
|
||||
Key::F12,
|
||||
];
|
||||
114
src/ui/keybind/mod.rs
Normal file
114
src/ui/keybind/mod.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Global hotkey detection (egui input snapshot).
|
||||
|
||||
mod bindings;
|
||||
|
||||
use eframe::egui::{self, Key, PointerButton};
|
||||
|
||||
use bindings::{ALL_KEYS, key_from_name, name_from_key};
|
||||
|
||||
/// True while the binding is held (push-to-talk).
|
||||
pub fn binding_held(ctx: &egui::Context, binding: &str) -> bool {
|
||||
let b = binding.trim().to_lowercase();
|
||||
ctx.input(|i| match b.as_str() {
|
||||
"mouse4" | "mb4" | "x1" => i.pointer.button_down(PointerButton::Extra1),
|
||||
"mouse5" | "mb5" | "x2" => i.pointer.button_down(PointerButton::Extra2),
|
||||
"lmb" | "mouse1" => i.pointer.button_down(PointerButton::Primary),
|
||||
"rmb" | "mouse2" => i.pointer.button_down(PointerButton::Secondary),
|
||||
"mmb" | "mouse3" => i.pointer.button_down(PointerButton::Middle),
|
||||
"space" => i.key_down(Key::Space),
|
||||
_ => combo_down(&b, i),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rising edge (toggle mute/deafen, etc.).
|
||||
pub fn binding_pressed(ctx: &egui::Context, binding: &str) -> bool {
|
||||
let b = binding.trim().to_lowercase();
|
||||
ctx.input(|i| match b.as_str() {
|
||||
"mouse4" | "mb4" | "x1" => i.pointer.button_pressed(PointerButton::Extra1),
|
||||
"mouse5" | "mb5" | "x2" => i.pointer.button_pressed(PointerButton::Extra2),
|
||||
"ctrl+m" => i.modifiers.ctrl && i.key_pressed(Key::M),
|
||||
"ctrl+d" => i.modifiers.ctrl && i.key_pressed(Key::D),
|
||||
"ctrl+shift+o" => i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(Key::O),
|
||||
_ => combo_pressed(&b, i),
|
||||
})
|
||||
}
|
||||
|
||||
/// Listen for any key/mouse press and return a binding string like "ctrl+m" or "mouse4".
|
||||
pub fn listen_pressed(ctx: &egui::Context) -> Option<String> {
|
||||
ctx.input(|i| {
|
||||
for &key in ALL_KEYS {
|
||||
if i.key_pressed(key) {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if i.modifiers.ctrl {
|
||||
parts.push("ctrl");
|
||||
}
|
||||
if i.modifiers.shift {
|
||||
parts.push("shift");
|
||||
}
|
||||
if i.modifiers.alt {
|
||||
parts.push("alt");
|
||||
}
|
||||
if let Some(name) = name_from_key(key) {
|
||||
parts.push(name);
|
||||
return Some(parts.join("+"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if i.pointer.button_pressed(PointerButton::Extra1) {
|
||||
return Some("mouse4".into());
|
||||
}
|
||||
if i.pointer.button_pressed(PointerButton::Extra2) {
|
||||
return Some("mouse5".into());
|
||||
}
|
||||
if i.pointer.button_pressed(PointerButton::Middle) {
|
||||
return Some("mouse3".into());
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn combo_down(binding: &str, i: &egui::InputState) -> bool {
|
||||
let Some((mods, key)) = parse_combo(binding) else {
|
||||
return false;
|
||||
};
|
||||
mods.ctrl == i.modifiers.ctrl
|
||||
&& mods.shift == i.modifiers.shift
|
||||
&& mods.alt == i.modifiers.alt
|
||||
&& i.key_down(key)
|
||||
}
|
||||
|
||||
fn combo_pressed(binding: &str, i: &egui::InputState) -> bool {
|
||||
let Some((mods, key)) = parse_combo(binding) else {
|
||||
return false;
|
||||
};
|
||||
mods.ctrl == i.modifiers.ctrl
|
||||
&& mods.shift == i.modifiers.shift
|
||||
&& mods.alt == i.modifiers.alt
|
||||
&& i.key_pressed(key)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mods {
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
alt: bool,
|
||||
}
|
||||
|
||||
fn parse_combo(binding: &str) -> Option<(Mods, Key)> {
|
||||
let parts: Vec<&str> = binding.split('+').map(str::trim).collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut mods = Mods::default();
|
||||
let key_name = *parts.last()?;
|
||||
for p in &parts[..parts.len().saturating_sub(1)] {
|
||||
match *p {
|
||||
"ctrl" | "control" => mods.ctrl = true,
|
||||
"shift" => mods.shift = true,
|
||||
"alt" => mods.alt = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = key_from_name(key_name)?;
|
||||
Some((mods, key))
|
||||
}
|
||||
161
src/ui/members/mod.rs
Normal file
161
src/ui/members/mod.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Right-hand member list panel — shows online/offline members
|
||||
//! of the currently active channel.
|
||||
|
||||
use eframe::egui::{
|
||||
self, Color32, Context, FontId, Frame, RichText, ScrollArea, SidePanel, Stroke,
|
||||
};
|
||||
|
||||
use crate::state::{Channel, UiState};
|
||||
use crate::theme as t;
|
||||
|
||||
const PANEL_WIDTH: f32 = 220.0;
|
||||
|
||||
pub fn show(ctx: &Context, s: &mut UiState) {
|
||||
// Only show for guild text channels (not DMs, not voice-only view, not home/friends).
|
||||
let ch_id = match &s.active_channel {
|
||||
Some(id) => id.clone(),
|
||||
None => return,
|
||||
};
|
||||
if s.active_guild_id.is_none() {
|
||||
return;
|
||||
}
|
||||
if s.active_dm_id.is_some() {
|
||||
return;
|
||||
}
|
||||
let channel = match s.channels.iter().find(|c| c.id == ch_id) {
|
||||
Some(c) => c.clone(),
|
||||
None => return,
|
||||
};
|
||||
if channel.kind == "voice" {
|
||||
return;
|
||||
}
|
||||
|
||||
SidePanel::right("members")
|
||||
.exact_width(PANEL_WIDTH)
|
||||
.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.left(), r.y_range(), Stroke::new(1.0, t::BORDER_SUBTLE));
|
||||
|
||||
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
|
||||
ui.add_space(12.0);
|
||||
render_section(ui, s, &channel, true);
|
||||
ui.add_space(8.0);
|
||||
render_section(ui, s, &channel, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_section(ui: &mut egui::Ui, s: &UiState, channel: &Channel, online: bool) {
|
||||
// Members currently in this channel
|
||||
let mut members: Vec<&str> = channel.members.iter().map(|m| s.nick_for(m)).collect();
|
||||
|
||||
// Add current user if not present
|
||||
let self_nick = "you";
|
||||
if !members.contains(&self_nick) {
|
||||
members.push(self_nick);
|
||||
}
|
||||
|
||||
// Sort alphabetically (case-insensitive)
|
||||
members.sort_by_key(|a| a.to_lowercase());
|
||||
|
||||
let visible: Vec<&str> = if online {
|
||||
members.clone()
|
||||
} else {
|
||||
Vec::new() // we don't track offline members per channel yet
|
||||
};
|
||||
|
||||
if online && visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let header = if online {
|
||||
format!("ONLINE — {}", visible.len())
|
||||
} else {
|
||||
"OFFLINE — 0".to_string()
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
RichText::new(header)
|
||||
.font(FontId::proportional(10.0))
|
||||
.color(t::TEXT_MUTED)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
for nick in &visible {
|
||||
member_row(ui, nick, online);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn member_row(ui: &mut egui::Ui, nick: &str, online: bool) {
|
||||
let (dot_color, text_color) = if online {
|
||||
(t::SUCCESS, t::TEXT_PRIMARY)
|
||||
} else {
|
||||
(t::TEXT_DISABLED, t::TEXT_DISABLED)
|
||||
};
|
||||
|
||||
Frame::NONE
|
||||
.inner_margin(egui::Margin {
|
||||
left: 12,
|
||||
right: 8,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let init = nick
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string();
|
||||
let avatar_color = if online {
|
||||
nick_avatar_color(nick)
|
||||
} else {
|
||||
t::BG_ELEVATED
|
||||
};
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 12.0, avatar_color);
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&init,
|
||||
egui::FontId::proportional(11.0),
|
||||
Color32::from_rgb(15, 15, 15),
|
||||
);
|
||||
// Status dot
|
||||
let (sd, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
|
||||
ui.painter().circle_filled(sd.center(), 4.0, dot_color);
|
||||
|
||||
ui.label(
|
||||
RichText::new(nick)
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(text_color),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn nick_avatar_color(nick: &str) -> egui::Color32 {
|
||||
let colors = [
|
||||
t::ACCENT,
|
||||
t::SUCCESS,
|
||||
t::WARNING,
|
||||
t::INFO,
|
||||
Color32::from_rgb(200, 100, 180),
|
||||
Color32::from_rgb(120, 190, 180),
|
||||
];
|
||||
colors[nick.bytes().fold(0usize, |a, b| a.wrapping_add(b as usize)) % colors.len()]
|
||||
}
|
||||
12
src/ui/mod.rs
Normal file
12
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
mod app;
|
||||
pub mod chat;
|
||||
pub mod connect;
|
||||
pub mod keybind;
|
||||
pub mod members;
|
||||
pub mod settings;
|
||||
pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod titlebar;
|
||||
|
||||
pub use app::VnoxApp;
|
||||
78
src/ui/settings/mod.rs
Normal file
78
src/ui/settings/mod.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
mod nav;
|
||||
mod network;
|
||||
mod tabs;
|
||||
mod widgets;
|
||||
|
||||
use crate::state::{SettingsPage, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Align, Context, Frame, Layout, ScrollArea, Vec2, Window};
|
||||
use vnox_client::identity::Identity;
|
||||
|
||||
pub use nav::show_nav;
|
||||
|
||||
pub fn show_window(ctx: &Context, s: &mut UiState, identity: &Identity) {
|
||||
let open_id = egui::Id::new("settings_open_flag");
|
||||
if !s.settings_open {
|
||||
ctx.data_mut(|d| d.remove_temp::<bool>(open_id));
|
||||
return;
|
||||
}
|
||||
if ctx.data_mut(|d| d.get_temp::<bool>(open_id)) != Some(true) {
|
||||
ctx.data_mut(|d| d.insert_temp(open_id, true));
|
||||
s.force_refresh_audio_devices();
|
||||
}
|
||||
s.refresh_audio_devices();
|
||||
Window::new("Settings")
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_size([720.0, 520.0])
|
||||
.min_size([520.0, 380.0])
|
||||
.frame(
|
||||
Frame::window(&ctx.style())
|
||||
.fill(t::BG_ELEVATED)
|
||||
.stroke(egui::Stroke::new(1.0, t::BORDER_DEFAULT))
|
||||
.corner_radius(t::R_LG)
|
||||
.shadow(egui::epaint::Shadow::NONE),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.visuals_mut().override_text_color = Some(t::TEXT_PRIMARY);
|
||||
let panel_h = ui.available_height().max(200.0);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(168.0, panel_h),
|
||||
Layout::top_down(Align::LEFT),
|
||||
|ui| nav::nav_panel(ui, s),
|
||||
);
|
||||
ui.separator();
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(ui.available_width().max(280.0), panel_h),
|
||||
Layout::top_down(Align::LEFT),
|
||||
|ui| {
|
||||
ScrollArea::vertical()
|
||||
.id_salt("settings_content")
|
||||
.max_height(panel_h)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_width(320.0);
|
||||
ui.add_space(12.0);
|
||||
show_content(ui, s, identity);
|
||||
ui.add_space(12.0);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_content(ui: &mut egui::Ui, s: &mut UiState, id: &Identity) {
|
||||
match &s.settings_page.clone() {
|
||||
SettingsPage::Voice => tabs::voice(ui, s),
|
||||
SettingsPage::AudioOutput => tabs::output(ui, s),
|
||||
SettingsPage::Identity => tabs::show(ui, id, s),
|
||||
SettingsPage::Network => network::network(ui, s),
|
||||
SettingsPage::Overlay => network::overlay(ui, s),
|
||||
SettingsPage::Appearance => tabs::appearance(ui, s),
|
||||
SettingsPage::Keybinds => tabs::keybinds(ui, s),
|
||||
SettingsPage::Plugins => tabs::plugins(ui),
|
||||
SettingsPage::Advanced => tabs::advanced(ui, s),
|
||||
}
|
||||
}
|
||||
125
src/ui/settings/nav.rs
Normal file
125
src/ui/settings/nav.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
use crate::state::{SettingsPage, UiState};
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Align, Context, FontId, Frame, Layout, ScrollArea, Vec2};
|
||||
|
||||
use super::widgets;
|
||||
|
||||
pub(super) fn nav_panel(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
Frame::NONE
|
||||
.fill(t::BG_SURFACE)
|
||||
.inner_margin(egui::Margin::symmetric(8, 12))
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ScrollArea::vertical()
|
||||
.id_salt("settings_nav")
|
||||
.max_height((ui.available_height() - 30.0).max(80.0))
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"audio",
|
||||
&[
|
||||
(SettingsPage::Voice, "voice"),
|
||||
(SettingsPage::AudioOutput, "audio output"),
|
||||
],
|
||||
);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"network",
|
||||
&[
|
||||
(SettingsPage::Network, "network"),
|
||||
(SettingsPage::Overlay, "overlay"),
|
||||
],
|
||||
);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"app",
|
||||
&[
|
||||
(SettingsPage::Appearance, "appearance"),
|
||||
(SettingsPage::Keybinds, "keybinds"),
|
||||
(SettingsPage::Plugins, "plugins"),
|
||||
],
|
||||
);
|
||||
nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]);
|
||||
});
|
||||
if back_btn(ui) {
|
||||
s.settings_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_nav(ctx: &Context, s: &mut UiState) {
|
||||
egui::SidePanel::left("settings_nav")
|
||||
.exact_width(160.0)
|
||||
.resizable(false)
|
||||
.frame(Frame::new().fill(t::BG_STRIP))
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().spacing.item_spacing = Vec2::new(0.0, 1.0);
|
||||
ui.add_space(10.0);
|
||||
nav_group(ui, s, "account", &[(SettingsPage::Identity, "identity")]);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"audio",
|
||||
&[
|
||||
(SettingsPage::Voice, "voice"),
|
||||
(SettingsPage::AudioOutput, "audio output"),
|
||||
],
|
||||
);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"network",
|
||||
&[
|
||||
(SettingsPage::Network, "network"),
|
||||
(SettingsPage::Overlay, "overlay"),
|
||||
],
|
||||
);
|
||||
nav_group(
|
||||
ui,
|
||||
s,
|
||||
"app",
|
||||
&[
|
||||
(SettingsPage::Appearance, "appearance"),
|
||||
(SettingsPage::Keybinds, "keybinds"),
|
||||
(SettingsPage::Plugins, "plugins"),
|
||||
],
|
||||
);
|
||||
nav_group(ui, s, "debug", &[(SettingsPage::Advanced, "advanced")]);
|
||||
ui.with_layout(Layout::bottom_up(Align::LEFT), |ui| {
|
||||
ui.add_space(8.0);
|
||||
if back_btn(ui) {
|
||||
s.settings_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn nav_group(ui: &mut egui::Ui, s: &mut UiState, section: &str, items: &[(SettingsPage, &str)]) {
|
||||
ui.add_space(4.0);
|
||||
widgets::nav_section(ui, section);
|
||||
for (page, label) in items {
|
||||
if widgets::nav_item(ui, &s.settings_page == page, label) {
|
||||
s.settings_page = page.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn back_btn(ui: &mut egui::Ui) -> bool {
|
||||
ui.add_sized(
|
||||
[ui.available_width().max(120.0), 28.0],
|
||||
egui::Button::new(
|
||||
egui::RichText::new("← back")
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::TEXT_GHOST),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
}
|
||||
132
src/ui/settings/network.rs
Normal file
132
src/ui/settings/network.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use super::widgets::{badge, group_label, page_header, s_row, toggle};
|
||||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use eframe::egui::{self, Align, FontId, Frame, Layout, Margin, RichText, Stroke};
|
||||
|
||||
pub fn network(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "network", "LNEx protocol · relay · connection");
|
||||
|
||||
// Live status card
|
||||
Frame::new()
|
||||
.fill(t::BG_STRIP)
|
||||
.stroke(Stroke::new(1.0, t::BORDER_FAINT))
|
||||
.corner_radius(6u8)
|
||||
.inner_margin(Margin::symmetric(12, 10))
|
||||
.show(ui, |ui| {
|
||||
status_row(ui, "protocol", |ui| badge(ui, "LNEx v1", t::ACCENT_SOFT));
|
||||
status_row(ui, "voice transport", |ui| badge(ui, "UDP", t::BLUE));
|
||||
status_row(ui, "control transport", |ui| badge(ui, "TCP", t::WARN));
|
||||
status_row(ui, "encryption", |ui| {
|
||||
badge(ui, "on · chacha20", t::SUCCESS)
|
||||
});
|
||||
status_row(ui, "rtt", |ui| {
|
||||
let rtt = s
|
||||
.rtt_ms
|
||||
.map(|ms| format!("{ms}ms"))
|
||||
.unwrap_or_else(|| "—".into());
|
||||
let color = s
|
||||
.rtt_ms
|
||||
.map(|ms| {
|
||||
if ms < 50 {
|
||||
t::SUCCESS
|
||||
} else if ms < 120 {
|
||||
t::WARN
|
||||
} else {
|
||||
t::DANGER
|
||||
}
|
||||
})
|
||||
.unwrap_or(t::TEXT_GHOST);
|
||||
ui.label(
|
||||
RichText::new(rtt)
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(color),
|
||||
);
|
||||
});
|
||||
status_row(ui, "packet loss", |ui| {
|
||||
ui.label(
|
||||
RichText::new("0.1%")
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::SUCCESS),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
|
||||
group_label(ui, "relay");
|
||||
s_row(
|
||||
ui,
|
||||
"use relay node",
|
||||
Some("route through relay if direct fails"),
|
||||
|ui| {
|
||||
toggle(ui, &mut s.relay_enabled);
|
||||
},
|
||||
);
|
||||
s_row(ui, "relay address", None, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.relay_address)
|
||||
.font(FontId::monospace(10.0))
|
||||
.text_color(t::TEXT_MUTED)
|
||||
.desired_width(140.0),
|
||||
);
|
||||
});
|
||||
s_row(
|
||||
ui,
|
||||
"auto relay selection",
|
||||
Some("pick nearest by latency"),
|
||||
|ui| {
|
||||
toggle(ui, &mut s.auto_relay);
|
||||
},
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
group_label(ui, "connection");
|
||||
s_row(
|
||||
ui,
|
||||
"auto reconnect",
|
||||
Some("retry with backoff after disconnect"),
|
||||
|ui| {
|
||||
toggle(ui, &mut s.auto_reconnect);
|
||||
},
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
group_label(ui, "advanced");
|
||||
s_row(ui, "UDP port", None, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.udp_port)
|
||||
.font(FontId::monospace(10.0))
|
||||
.text_color(t::TEXT_MUTED)
|
||||
.desired_width(70.0),
|
||||
);
|
||||
});
|
||||
s_row(ui, "force relay only", Some("disable direct p2p"), |ui| {
|
||||
toggle(ui, &mut s.force_relay);
|
||||
});
|
||||
}
|
||||
|
||||
fn status_row(ui: &mut egui::Ui, key: &str, val: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(key)
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), val);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
pub fn overlay(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "overlay", "in-game HUD · speaking indicators");
|
||||
|
||||
group_label(ui, "general");
|
||||
s_row(ui, "enable overlay", None, |ui| {
|
||||
toggle(ui, &mut s.overlay_enabled);
|
||||
});
|
||||
s_row(ui, "show speaking users", None, |ui| {
|
||||
toggle(ui, &mut s.overlay_speakers);
|
||||
});
|
||||
s_row(ui, "show latency", None, |ui| {
|
||||
toggle(ui, &mut s.overlay_latency);
|
||||
});
|
||||
}
|
||||
167
src/ui/settings/tabs/app.rs
Normal file
167
src/ui/settings/tabs/app.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use crate::ui::settings::widgets::{
|
||||
badge, group_label, page_header, s_row, slider_row, small_select, toggle,
|
||||
};
|
||||
use eframe::egui::{self, FontId, RichText};
|
||||
|
||||
pub fn appearance(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "appearance", "theme · font · ui scale");
|
||||
group_label(ui, "theme");
|
||||
s_row(ui, "color scheme", None, |ui| {
|
||||
let mut v = 0usize;
|
||||
small_select(
|
||||
ui,
|
||||
"scheme",
|
||||
&mut v,
|
||||
&["hifi dark", "terminal green", "void"],
|
||||
);
|
||||
});
|
||||
s_row(ui, "ui scale", None, |ui| {
|
||||
slider_row(ui, &mut s.ui_scale, 80.0, 130.0, |v| {
|
||||
format!("{}%", v as u32)
|
||||
});
|
||||
});
|
||||
s_row(ui, "font", None, |ui| {
|
||||
let mut v = 0usize;
|
||||
small_select(
|
||||
ui,
|
||||
"font",
|
||||
&mut v,
|
||||
&["IBM Plex Mono", "Geist Mono", "Fira Code"],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn plugins(ui: &mut egui::Ui) {
|
||||
page_header(ui, "plugins", "installed · runtime: deno");
|
||||
group_label(ui, "installed");
|
||||
s_row(ui, "relay-switcher", Some("v0.1.2 · raven"), |ui| {
|
||||
let mut v = true;
|
||||
toggle(ui, &mut v);
|
||||
});
|
||||
s_row(ui, "music-bot", Some("v0.3.0 · community"), |ui| {
|
||||
let mut v = false;
|
||||
toggle(ui, &mut v);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn advanced(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "advanced", "debug · logging · protocol internals");
|
||||
group_label(ui, "logging");
|
||||
s_row(ui, "log level", None, |ui| {
|
||||
small_select(
|
||||
ui,
|
||||
"log_level",
|
||||
&mut s.log_level,
|
||||
&["info", "debug", "trace"],
|
||||
);
|
||||
});
|
||||
s_row(ui, "log voice packets", Some("verbose · high cpu"), |ui| {
|
||||
toggle(ui, &mut s.log_voice_packets);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "protocol");
|
||||
s_row(ui, "LNEx version", None, |ui| {
|
||||
badge(ui, "v1", t::ACCENT_SOFT);
|
||||
});
|
||||
s_row(ui, "show packet stats", None, |ui| {
|
||||
toggle(ui, &mut s.show_packet_stats);
|
||||
});
|
||||
s_row(
|
||||
ui,
|
||||
"disable encryption",
|
||||
Some("debug only · dangerous"),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new("!")
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::DANGER),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
toggle(ui, &mut s.disable_encryption);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn keybinds(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "keybinds", "global hotkeys");
|
||||
group_label(ui, "voice");
|
||||
kb_row(
|
||||
ui,
|
||||
"push-to-talk",
|
||||
&mut s.kb_ptt,
|
||||
&mut s.kb_listening,
|
||||
"ptt",
|
||||
);
|
||||
kb_row(
|
||||
ui,
|
||||
"mute toggle",
|
||||
&mut s.kb_mute,
|
||||
&mut s.kb_listening,
|
||||
"mute",
|
||||
);
|
||||
kb_row(
|
||||
ui,
|
||||
"deafen toggle",
|
||||
&mut s.kb_deafen,
|
||||
&mut s.kb_listening,
|
||||
"deafen",
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "overlay");
|
||||
kb_row(
|
||||
ui,
|
||||
"toggle overlay",
|
||||
&mut s.kb_overlay,
|
||||
&mut s.kb_listening,
|
||||
"overlay",
|
||||
);
|
||||
}
|
||||
|
||||
fn kb_row(
|
||||
ui: &mut egui::Ui,
|
||||
label: &str,
|
||||
val: &mut String,
|
||||
listening: &mut Option<String>,
|
||||
listen_key: &str,
|
||||
) {
|
||||
s_row(ui, label, None, |ui| {
|
||||
let is_listening = listening.as_deref() == Some(listen_key);
|
||||
if is_listening {
|
||||
if let Some(binding) = crate::keybind::listen_pressed(ui.ctx()) {
|
||||
*val = binding;
|
||||
*listening = None;
|
||||
}
|
||||
let btn = egui::Button::new(
|
||||
egui::RichText::new("Listening… press a key")
|
||||
.font(egui::FontId::monospace(10.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(crate::theme::ACCENT)
|
||||
.corner_radius(crate::theme::R_SM)
|
||||
.min_size(egui::vec2(120.0, 20.0));
|
||||
if ui.add(btn).clicked() {
|
||||
*listening = None;
|
||||
}
|
||||
} else {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(val)
|
||||
.font(egui::FontId::monospace(10.0))
|
||||
.text_color(crate::theme::TEXT_MUTED)
|
||||
.desired_width(90.0),
|
||||
);
|
||||
let rec = egui::Button::new(
|
||||
egui::RichText::new("●")
|
||||
.font(egui::FontId::monospace(9.0))
|
||||
.color(crate::theme::ERROR),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false)
|
||||
.min_size(egui::vec2(16.0, 16.0));
|
||||
if ui.add(rec).on_hover_text("record keybind").clicked() {
|
||||
*listening = Some(listen_key.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
150
src/ui/settings/tabs/audio.rs
Normal file
150
src/ui/settings/tabs/audio.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
use crate::state::UiState;
|
||||
use crate::theme as t;
|
||||
use crate::ui::settings::widgets::{
|
||||
badge, device_meta_row, group_label, page_header, s_row, slider_row, small_select,
|
||||
small_select_labels, toggle,
|
||||
};
|
||||
use eframe::egui::{self, FontId, RichText, Stroke, Vec2};
|
||||
use vnox_client::audio::devices::DeviceLabel;
|
||||
fn selected_meta(index: usize, metas: &[DeviceLabel]) -> Option<&DeviceLabel> {
|
||||
if index == 0 {
|
||||
return None;
|
||||
}
|
||||
metas.get(index.saturating_sub(1))
|
||||
}
|
||||
|
||||
pub fn voice(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "voice", "input device, encoding, push-to-talk");
|
||||
group_label(ui, "input device");
|
||||
s_row(ui, "microphone", None, |ui| {
|
||||
small_select_labels(ui, "mic_dev", &mut s.mic_device, &s.input_device_labels);
|
||||
});
|
||||
device_meta_row(ui, selected_meta(s.mic_device, &s.input_device_metas));
|
||||
s_row(ui, "input volume", None, |ui| {
|
||||
slider_row(ui, &mut s.input_volume, 0.0, 100.0, |v| {
|
||||
format!("{}%", v as u32)
|
||||
});
|
||||
});
|
||||
s_row(ui, "noise suppression", Some("rnnoise · cpu ~2%"), |ui| {
|
||||
toggle(ui, &mut s.noise_suppress);
|
||||
});
|
||||
s_row(ui, "echo cancellation", None, |ui| {
|
||||
toggle(ui, &mut s.echo_cancel);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "activation");
|
||||
s_row(ui, "mode", None, |ui| {
|
||||
small_select(
|
||||
ui,
|
||||
"vad_mode",
|
||||
&mut s.vad_mode,
|
||||
&["push-to-talk", "voice activity", "always on"],
|
||||
);
|
||||
});
|
||||
if s.vad_mode == 1 {
|
||||
s_row(
|
||||
ui,
|
||||
"vad threshold",
|
||||
Some("voice activity sensitivity"),
|
||||
|ui| {
|
||||
slider_row(ui, &mut s.vad_threshold, 0.0, 100.0, |v| {
|
||||
format!("{}%", v as u32)
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "codec");
|
||||
s_row(ui, "encoder", Some("opus · low latency mode"), |ui| {
|
||||
badge(ui, "opus", t::ACCENT_SOFT);
|
||||
});
|
||||
s_row(ui, "bitrate", Some("opus 8–128 kbps"), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
slider_row(ui, &mut s.voice_bitrate, 8.0, 128.0, |v| {
|
||||
format!("{}k", v as u32)
|
||||
});
|
||||
let presets = [32, 64, 96, 128usize];
|
||||
for &p in &presets {
|
||||
let pf = p as f32;
|
||||
let selected = (s.voice_bitrate - pf).abs() < 1.0;
|
||||
let resp = ui.add_sized(
|
||||
Vec2::new(28.0, 20.0),
|
||||
egui::Button::new(
|
||||
RichText::new(format!("{}", p))
|
||||
.font(FontId::monospace(8.0))
|
||||
.color(if selected { t::ACCENT } else { t::TEXT_MUTED }),
|
||||
)
|
||||
.fill(if selected {
|
||||
egui::Color32::from_rgba_premultiplied(
|
||||
t::ACCENT.r(),
|
||||
t::ACCENT.g(),
|
||||
t::ACCENT.b(),
|
||||
25,
|
||||
)
|
||||
} else {
|
||||
egui::Color32::from_rgb(23, 23, 23)
|
||||
})
|
||||
.stroke(Stroke::new(1.0, t::BORDER_SUBTLE))
|
||||
.corner_radius(3u8),
|
||||
);
|
||||
if resp.clicked() {
|
||||
s.voice_bitrate = pf;
|
||||
}
|
||||
}
|
||||
});
|
||||
let effective = (s.voice_bitrate as u32).clamp(8, 128);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!("effective: {} kbps", effective))
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
});
|
||||
});
|
||||
s_row(
|
||||
ui,
|
||||
"packet interval",
|
||||
Some("lower = less latency, more cpu"),
|
||||
|ui| {
|
||||
small_select(
|
||||
ui,
|
||||
"pkt_interval",
|
||||
&mut s.packet_interval,
|
||||
&["10ms", "20ms", "40ms"],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn output(ui: &mut egui::Ui, s: &mut UiState) {
|
||||
page_header(ui, "audio output", "output device · volume · jitter buffer");
|
||||
group_label(ui, "output device");
|
||||
s_row(ui, "device", None, |ui| {
|
||||
small_select_labels(ui, "out_dev", &mut s.output_device, &s.output_device_labels);
|
||||
});
|
||||
device_meta_row(ui, selected_meta(s.output_device, &s.output_device_metas));
|
||||
s_row(ui, "output volume", None, |ui| {
|
||||
slider_row(ui, &mut s.output_volume, 0.0, 100.0, |v| {
|
||||
format!("{}%", v as u32)
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "jitter buffer");
|
||||
s_row(
|
||||
ui,
|
||||
"buffer size",
|
||||
Some("lower = less delay, more glitches"),
|
||||
|ui| {
|
||||
small_select(ui, "jitter", &mut s.jitter_size, &["20ms", "40ms", "80ms"]);
|
||||
},
|
||||
);
|
||||
s_row(
|
||||
ui,
|
||||
"adaptive buffer",
|
||||
Some("auto-adjust on packet loss"),
|
||||
|ui| {
|
||||
toggle(ui, &mut s.adaptive_buffer);
|
||||
},
|
||||
);
|
||||
}
|
||||
629
src/ui/settings/tabs/identity.rs
Normal file
629
src/ui/settings/tabs/identity.rs
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
use crate::identity::{self, Identity};
|
||||
use crate::theme as t;
|
||||
use crate::ui::settings::widgets::{group_label, id_button, page_header, s_row, toggle};
|
||||
use crate::ui::state::UiState;
|
||||
use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Stroke, Vec2};
|
||||
|
||||
pub fn show(ui: &mut egui::Ui, identity: &Identity, s: &mut UiState) {
|
||||
page_header(ui, "identity", "your local keypair · no account required");
|
||||
Frame::new()
|
||||
.fill(t::BG_PANEL)
|
||||
.stroke(Stroke::new(1.0, t::BORDER))
|
||||
.corner_radius(7u8)
|
||||
.inner_margin(Margin::same(12))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let init: String = identity
|
||||
.nickname
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
let av = egui::Button::new(
|
||||
RichText::new(&init)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::ACCENT),
|
||||
)
|
||||
.fill(Color32::from_rgba_premultiplied(255, 107, 53, 18))
|
||||
.stroke(Stroke::new(
|
||||
1.0,
|
||||
Color32::from_rgba_premultiplied(255, 107, 53, 38),
|
||||
))
|
||||
.corner_radius(8u8)
|
||||
.min_size(Vec2::splat(36.0));
|
||||
ui.add_enabled(false, av);
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(&identity.nickname)
|
||||
.font(FontId::monospace(12.0))
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(format!("pub: {}…", &identity.pubkey_hex[..16]))
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_GHOST),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 5.0;
|
||||
if id_button(ui, "copy pubkey") {
|
||||
ui.ctx().copy_text(identity.pubkey_hex.clone());
|
||||
}
|
||||
if id_button(ui, "set passphrase") {
|
||||
s.vault_set_open = true;
|
||||
s.vault_passphrase_input.clear();
|
||||
s.vault_passphrase_confirm.clear();
|
||||
s.vault_error = None;
|
||||
}
|
||||
if id_button(ui, "remove passphrase") {
|
||||
s.vault_remove_open = true;
|
||||
s.vault_passphrase_input.clear();
|
||||
s.vault_error = None;
|
||||
}
|
||||
if id_button(ui, "export keyfile") {
|
||||
s.export_keyfile_open = true;
|
||||
s.export_keyfile_pass.clear();
|
||||
s.export_keyfile_confirm.clear();
|
||||
s.export_keyfile_output.clear();
|
||||
s.export_keyfile_error = None;
|
||||
}
|
||||
if id_button(ui, "import keyfile") {
|
||||
s.import_keyfile_open = true;
|
||||
s.import_keyfile_input.clear();
|
||||
s.import_keyfile_pass.clear();
|
||||
s.import_keyfile_error = None;
|
||||
}
|
||||
id_button(ui, "change nickname");
|
||||
id_button(ui, "rotate keypair");
|
||||
});
|
||||
});
|
||||
|
||||
// Vault status block.
|
||||
ui.add_space(10.0);
|
||||
group_label(ui, "vault status");
|
||||
let is_encrypted = identity::is_vault_encrypted();
|
||||
Frame::new()
|
||||
.fill(if is_encrypted { t::BG_PANEL } else { t::BG_ELEVATED })
|
||||
.stroke(Stroke::new(
|
||||
1.0,
|
||||
if is_encrypted { t::SUCCESS } else { t::BORDER },
|
||||
))
|
||||
.corner_radius(7u8)
|
||||
.inner_margin(Margin::same(12))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (label, color) = if is_encrypted {
|
||||
("encrypted (Argon2id + ChaCha20-Poly1305)", t::SUCCESS)
|
||||
} else {
|
||||
("plain JSON — passphrase not set", t::WARNING)
|
||||
};
|
||||
// Drawn lock/unlock icon.
|
||||
let (icon_rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(18.0, 18.0),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
let icon_fn = if is_encrypted {
|
||||
crate::theme::icons::lock
|
||||
} else {
|
||||
crate::theme::icons::unlock
|
||||
};
|
||||
icon_fn(ui.painter(), icon_rect, color);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(color)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(if is_encrypted {
|
||||
"Your private key is encrypted at rest. The vault is unlocked in memory while VNOX is running."
|
||||
} else {
|
||||
"Anyone with filesystem access can read your private key. Set a passphrase to encrypt it."
|
||||
})
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
group_label(ui, "backup");
|
||||
s_row(
|
||||
ui,
|
||||
"seed phrase backup",
|
||||
Some("24-word recovery · never share"),
|
||||
|ui| {
|
||||
let mut v = false;
|
||||
toggle(ui, &mut v);
|
||||
},
|
||||
);
|
||||
s_row(
|
||||
ui,
|
||||
"export to file",
|
||||
Some(".vnox keyfile · encrypted"),
|
||||
|ui| {
|
||||
let _ = id_button(ui, "export");
|
||||
},
|
||||
);
|
||||
|
||||
ui.add_space(8.0);
|
||||
group_label(ui, "permissions");
|
||||
s_row(
|
||||
ui,
|
||||
"show identity to strangers",
|
||||
Some("pubkey visible on federated nodes"),
|
||||
|ui| {
|
||||
let mut v = true;
|
||||
toggle(ui, &mut v);
|
||||
},
|
||||
);
|
||||
|
||||
// Set-passphrase modal.
|
||||
if s.vault_set_open {
|
||||
show_set_passphrase_modal(ui.ctx(), identity, s);
|
||||
}
|
||||
// Remove-passphrase modal.
|
||||
if s.vault_remove_open {
|
||||
show_remove_passphrase_modal(ui.ctx(), identity, s);
|
||||
}
|
||||
// Export keyfile modal.
|
||||
if s.export_keyfile_open {
|
||||
show_export_keyfile_modal(ui.ctx(), identity, s);
|
||||
}
|
||||
// Import keyfile modal.
|
||||
if s.import_keyfile_open {
|
||||
show_import_keyfile_modal(ui.ctx(), s);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_set_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
|
||||
egui::Window::new("Set Identity Passphrase")
|
||||
.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(360.0);
|
||||
ui.label(
|
||||
RichText::new("Pick a strong passphrase. It will be used to encrypt your identity keypair at rest (Argon2id + ChaCha20-Poly1305).")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("! There is no recovery — if you forget the passphrase, the keypair is lost.")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::WARNING),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.label(RichText::new("Passphrase").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
|
||||
let p1 = ui.add(
|
||||
egui::TextEdit::singleline(&mut s.vault_passphrase_input)
|
||||
.password(true)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
|
||||
let p2 = ui.add(
|
||||
egui::TextEdit::singleline(&mut s.vault_passphrase_confirm)
|
||||
.password(true)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let mismatch = !s.vault_passphrase_input.is_empty()
|
||||
&& s.vault_passphrase_input != s.vault_passphrase_confirm;
|
||||
if mismatch {
|
||||
ui.label(
|
||||
RichText::new("passphrases do not match")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ERROR),
|
||||
);
|
||||
}
|
||||
if s.vault_passphrase_input.len() < 8 && !s.vault_passphrase_input.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("! use at least 8 characters")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::WARNING),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.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.vault_set_open = false;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let can_apply = !s.vault_passphrase_input.is_empty()
|
||||
&& s.vault_passphrase_input == s.vault_passphrase_confirm
|
||||
&& s.vault_passphrase_input.len() >= 8;
|
||||
let btn = egui::Button::new(
|
||||
RichText::new("Encrypt & Save")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(t::ACCENT)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(120.0, 28.0));
|
||||
if ui.add_enabled(can_apply, btn).clicked() {
|
||||
match identity::save(identity, Some(&s.vault_passphrase_input)) {
|
||||
Ok(()) => {
|
||||
s.vault_set_open = false;
|
||||
s.vault_passphrase_input.clear();
|
||||
s.vault_passphrase_confirm.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
s.vault_error = Some(format!("save failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(ref err) = s.vault_error {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
|
||||
}
|
||||
if (p1.lost_focus() || p2.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
{
|
||||
s.vault_set_open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_remove_passphrase_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
|
||||
egui::Window::new("Remove Passphrase")
|
||||
.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(360.0);
|
||||
ui.label(
|
||||
RichText::new("! This will store your identity as plain JSON on disk. Anyone with filesystem access will be able to read your private key.")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::WARNING),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Type REMOVE to confirm.")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut s.vault_passphrase_input)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
ui.add_space(10.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.vault_remove_open = false;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let can_apply = s.vault_passphrase_input == "REMOVE";
|
||||
let btn = egui::Button::new(
|
||||
RichText::new("Remove Passphrase")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(t::ERROR)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(140.0, 28.0));
|
||||
if ui.add_enabled(can_apply, btn).clicked() {
|
||||
match identity::save(identity, None) {
|
||||
Ok(()) => {
|
||||
s.vault_remove_open = false;
|
||||
s.vault_passphrase_input.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
s.vault_error = Some(format!("save failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(ref err) = s.vault_error {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
|
||||
}
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
s.vault_remove_open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_export_keyfile_modal(ctx: &egui::Context, identity: &Identity, s: &mut UiState) {
|
||||
egui::Window::new("Export Identity Keyfile")
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_size([440.0, 380.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(420.0);
|
||||
ui.label(
|
||||
RichText::new("Pick a passphrase to encrypt the keyfile. Anyone with the keyfile and the passphrase can impersonate you — share carefully.")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Passphrase (optional — empty = plain JSON)").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.export_keyfile_pass)
|
||||
.password(true)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(380.0),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new("Confirm").font(FontId::proportional(12.0)).color(t::TEXT_PRIMARY));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.export_keyfile_confirm)
|
||||
.password(true)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(380.0),
|
||||
);
|
||||
let mismatch = !s.export_keyfile_pass.is_empty()
|
||||
&& s.export_keyfile_pass != s.export_keyfile_confirm;
|
||||
if mismatch {
|
||||
ui.label(
|
||||
RichText::new("passphrases do not match")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ERROR),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let can_generate = !mismatch;
|
||||
let gen_btn = egui::Button::new(
|
||||
RichText::new("Generate keyfile")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(t::ACCENT)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(120.0, 28.0));
|
||||
if ui.add_enabled(can_generate, gen_btn).clicked() {
|
||||
let pass = if s.export_keyfile_pass.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.export_keyfile_pass.as_str())
|
||||
};
|
||||
match identity::export_keyfile(identity, pass) {
|
||||
Ok(text) => s.export_keyfile_output = text,
|
||||
Err(e) => s.export_keyfile_error = Some(format!("export failed: {e}")),
|
||||
}
|
||||
}
|
||||
if let Some(ref err) = s.export_keyfile_error {
|
||||
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::ERROR));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if !s.export_keyfile_output.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("Keyfile (copy and save to a .vnoxkey file):")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
let mut buf = s.export_keyfile_output.clone();
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.font(FontId::monospace(10.0))
|
||||
.desired_width(380.0)
|
||||
.interactive(true),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Copy")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ACCENT),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
ui.ctx().copy_text(s.export_keyfile_output.clone());
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Save to disk")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::ACCENT),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let path = dirs::document_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join(format!("vnox-{}-{}.vnoxkey", identity.nickname, &identity.pubkey_hex[..8]));
|
||||
match std::fs::write(&path, &s.export_keyfile_output) {
|
||||
Ok(()) => {
|
||||
s.export_keyfile_error = Some(format!("saved to: {}", path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
s.export_keyfile_error = Some(format!("save failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Close")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
s.export_keyfile_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn show_import_keyfile_modal(ctx: &egui::Context, s: &mut UiState) {
|
||||
egui::Window::new("Import Identity Keyfile")
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_size([440.0, 360.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(420.0);
|
||||
ui.label(
|
||||
RichText::new("! Importing a keyfile will replace your current identity. Back up your current keypair first if you want to keep it.")
|
||||
.font(FontId::proportional(11.0))
|
||||
.color(t::WARNING),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Paste keyfile JSON here:")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(140.0)
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut s.import_keyfile_input)
|
||||
.font(FontId::monospace(10.0))
|
||||
.desired_width(380.0)
|
||||
.hint_text(RichText::new(r#"{"version":1,"scheme":"..."}"#).color(t::TEXT_DISABLED)),
|
||||
);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new("Passphrase (leave empty for plain keyfiles)")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_PRIMARY),
|
||||
);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut s.import_keyfile_pass)
|
||||
.password(true)
|
||||
.font(FontId::monospace(12.0))
|
||||
.desired_width(380.0),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let can_import = !s.import_keyfile_input.trim().is_empty();
|
||||
let import_btn = egui::Button::new(
|
||||
RichText::new("Import & Replace Identity")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(egui::Color32::WHITE),
|
||||
)
|
||||
.fill(t::ERROR)
|
||||
.stroke(egui::Stroke::NONE)
|
||||
.corner_radius(t::R_SM)
|
||||
.min_size(egui::vec2(180.0, 28.0));
|
||||
if ui.add_enabled(can_import, import_btn).clicked() {
|
||||
let pass = if s.import_keyfile_pass.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.import_keyfile_pass.as_str())
|
||||
};
|
||||
match identity::import_keyfile(s.import_keyfile_input.trim(), pass) {
|
||||
Ok(identity) => {
|
||||
// Persist imported identity (without passphrase for now —
|
||||
// the user can set one separately).
|
||||
match identity::save(&identity, None) {
|
||||
Ok(()) => {
|
||||
s.import_keyfile_error = Some(
|
||||
"imported — restart VNOX to use the new identity".into(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
s.import_keyfile_error = Some(format!("save failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
s.import_keyfile_error = Some(format!("import failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref err) = s.import_keyfile_error {
|
||||
ui.label(RichText::new(err).font(FontId::proportional(11.0)).color(t::TEXT_MUTED));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new("Close")
|
||||
.font(FontId::proportional(12.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
s.import_keyfile_open = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
7
src/ui/settings/tabs/mod.rs
Normal file
7
src/ui/settings/tabs/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod app;
|
||||
mod audio;
|
||||
mod identity;
|
||||
|
||||
pub use app::{advanced, appearance, keybinds, plugins};
|
||||
pub use audio::{output, voice};
|
||||
pub use identity::show;
|
||||
128
src/ui/settings/widgets/controls.rs
Normal file
128
src/ui/settings/widgets/controls.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use crate::theme as t;
|
||||
use eframe::egui::{self, Color32, FontId, Frame, Margin, RichText, Sense, Stroke, Vec2};
|
||||
|
||||
pub fn toggle(ui: &mut egui::Ui, value: &mut bool) {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::new(30.0, 16.0), Sense::click());
|
||||
if resp.clicked() {
|
||||
*value = !*value;
|
||||
}
|
||||
let painter = ui.painter();
|
||||
let bg = if *value {
|
||||
Color32::from_rgba_premultiplied(255, 107, 53, 38)
|
||||
} else {
|
||||
t::BG_ELEVATED
|
||||
};
|
||||
let bdr = if *value {
|
||||
Color32::from_rgb(100, 50, 25)
|
||||
} else {
|
||||
t::BORDER_SUBTLE
|
||||
};
|
||||
painter.rect(
|
||||
rect,
|
||||
8u8,
|
||||
bg,
|
||||
Stroke::new(1.0, bdr),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let kx = if *value {
|
||||
rect.max.x - 7.0
|
||||
} else {
|
||||
rect.min.x + 7.0
|
||||
};
|
||||
let kc = if *value {
|
||||
t::ACCENT
|
||||
} else {
|
||||
Color32::from_rgb(45, 45, 45)
|
||||
};
|
||||
painter.circle_filled(egui::pos2(kx, rect.center().y), 5.0, kc);
|
||||
}
|
||||
|
||||
pub fn small_select_labels(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[String]) {
|
||||
let fallback = "?".to_string();
|
||||
let selected = options.get(*value).unwrap_or(&fallback);
|
||||
egui::ComboBox::from_id_salt(id)
|
||||
.selected_text(
|
||||
RichText::new(selected.as_str())
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
ui.selectable_value(
|
||||
value,
|
||||
i,
|
||||
RichText::new(opt.as_str())
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_SECONDARY),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn small_select(ui: &mut egui::Ui, id: &str, value: &mut usize, options: &[&str]) {
|
||||
egui::ComboBox::from_id_salt(id)
|
||||
.selected_text(
|
||||
RichText::new(options.get(*value).copied().unwrap_or("?"))
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.width(110.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, opt) in options.iter().enumerate() {
|
||||
ui.selectable_value(
|
||||
value,
|
||||
i,
|
||||
RichText::new(*opt)
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_SECONDARY),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn slider_row(
|
||||
ui: &mut egui::Ui,
|
||||
val: &mut f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
fmt: impl Fn(f32) -> String,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().slider_width = 90.0;
|
||||
ui.add(
|
||||
egui::Slider::new(val, min..=max)
|
||||
.show_value(false)
|
||||
.trailing_fill(true),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(fmt(*val))
|
||||
.font(FontId::monospace(10.0))
|
||||
.color(t::TEXT_DIM),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn badge(ui: &mut egui::Ui, text: &str, color: Color32) {
|
||||
Frame::new()
|
||||
.fill(Color32::from_rgba_premultiplied(
|
||||
color.r(),
|
||||
color.g(),
|
||||
color.b(),
|
||||
25,
|
||||
))
|
||||
.stroke(Stroke::new(
|
||||
1.0,
|
||||
Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), 50),
|
||||
))
|
||||
.corner_radius(3u8)
|
||||
.inner_margin(Margin::symmetric(6, 2))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(color),
|
||||
);
|
||||
});
|
||||
}
|
||||
90
src/ui/settings/widgets/layout.rs
Normal file
90
src/ui/settings/widgets/layout.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use crate::theme as t;
|
||||
use eframe::egui::{self, Align, Color32, FontId, Layout, RichText, Stroke};
|
||||
|
||||
pub fn page_header(ui: &mut egui::Ui, title: &str, sub: &str) {
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.font(FontId::monospace(13.0))
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(sub)
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
|
||||
pub fn group_label(ui: &mut egui::Ui, label: &str) {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(label.to_uppercase())
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
ui.add(egui::Separator::default().spacing(6.0));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
pub fn s_row(
|
||||
ui: &mut egui::Ui,
|
||||
label: &str,
|
||||
desc: Option<&str>,
|
||||
content: impl FnOnce(&mut egui::Ui),
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(t::TEXT_DIM),
|
||||
);
|
||||
if let Some(d) = desc {
|
||||
ui.label(
|
||||
RichText::new(d)
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), content);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::Separator::default().horizontal().spacing(0.0));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
pub fn device_meta_row(ui: &mut egui::Ui, meta: Option<&vnox_client::audio::devices::DeviceLabel>) {
|
||||
if let Some(m) = meta {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(4.0);
|
||||
super::controls::badge(ui, &m.host_api, Color32::from_rgb(120, 180, 255));
|
||||
if m.channels > 0 {
|
||||
super::controls::badge(
|
||||
ui,
|
||||
&format!("{}ch", m.channels),
|
||||
Color32::from_rgb(180, 200, 120),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id_button(ui: &mut egui::Ui, label: &str) -> bool {
|
||||
ui.add(
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.fill(Color32::from_rgb(23, 23, 23))
|
||||
.stroke(Stroke::new(1.0, t::BORDER_SUBTLE))
|
||||
.corner_radius(4u8),
|
||||
)
|
||||
.clicked()
|
||||
}
|
||||
7
src/ui/settings/widgets/mod.rs
Normal file
7
src/ui/settings/widgets/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod controls;
|
||||
mod layout;
|
||||
mod nav;
|
||||
|
||||
pub use controls::{badge, slider_row, small_select, small_select_labels, toggle};
|
||||
pub use layout::{device_meta_row, group_label, id_button, page_header, s_row};
|
||||
pub use nav::{nav_item, nav_section};
|
||||
46
src/ui/settings/widgets/nav.rs
Normal file
46
src/ui/settings/widgets/nav.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use crate::theme as t;
|
||||
use eframe::egui::{self, FontId, RichText};
|
||||
|
||||
pub fn nav_item(ui: &mut egui::Ui, is_active: bool, label: &str) -> bool {
|
||||
let text_color = if is_active {
|
||||
t::ACCENT_SOFT
|
||||
} else {
|
||||
t::TEXT_GHOST
|
||||
};
|
||||
let fill = if is_active {
|
||||
t::ACCENT_10
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let stroke = if is_active {
|
||||
egui::Stroke::new(1.0, t::BORDER_ACCENT)
|
||||
} else {
|
||||
egui::Stroke::NONE
|
||||
};
|
||||
|
||||
ui.add_sized(
|
||||
[ui.available_width().max(120.0), 26.0],
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(FontId::monospace(11.0))
|
||||
.color(text_color),
|
||||
)
|
||||
.fill(fill)
|
||||
.stroke(stroke)
|
||||
.corner_radius(t::R_SM),
|
||||
)
|
||||
.clicked()
|
||||
}
|
||||
|
||||
pub fn nav_section(ui: &mut egui::Ui, label: &str) {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(label.to_uppercase())
|
||||
.font(FontId::monospace(9.0))
|
||||
.color(t::TEXT_DEAD),
|
||||
);
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue