Merge pull request #10 from recurse-chat/ui-improvements

UI improvements
This commit is contained in:
2026-08-28 19:02:40 -04:00
committed by GitHub
13 changed files with 227 additions and 29 deletions
+45 -7
View File
@@ -21,7 +21,10 @@ use ringbuf::{
}; };
use tauri::State; use tauri::State;
use crate::{commands::config::ConfigState, crypto::SessionCipher}; use crate::{
commands::config::{BackendConfig, ConfigState},
crypto::SessionCipher,
};
const TARGET_SAMPLE_RATE: u32 = 48_000; const TARGET_SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: usize = 960; const PACKET_SAMPLES: usize = 960;
@@ -57,6 +60,8 @@ pub struct VoiceSession {
pub producer_in: AudioProducer, pub producer_in: AudioProducer,
pub consumer_out: AudioConsumer, pub consumer_out: AudioConsumer,
pub backend_config: Arc<Mutex<BackendConfig>>,
} }
fn resolve_input_device(device_name: &Option<String>) -> Result<cpal::Device, String> { fn resolve_input_device(device_name: &Option<String>) -> Result<cpal::Device, String> {
@@ -72,9 +77,9 @@ fn resolve_input_device(device_name: &Option<String>) -> Result<cpal::Device, St
.unwrap_or(false) .unwrap_or(false)
}) })
.ok_or_else(|| format!("Input device not found: {name}")), .ok_or_else(|| format!("Input device not found: {name}")),
None => host.default_input_device().ok_or_else(|| { None => host
format!("No default input device") .default_input_device()
}), .ok_or_else(|| format!("No default input device")),
} }
} }
@@ -91,9 +96,9 @@ fn resolve_output_device(device_name: &Option<String>) -> Result<cpal::Device, S
.unwrap_or(false) .unwrap_or(false)
}) })
.ok_or_else(|| format!("Output device not found: {name}")), .ok_or_else(|| format!("Output device not found: {name}")),
None => host.default_output_device().ok_or_else(|| { None => host
format!("No default output device") .default_output_device()
}), .ok_or_else(|| format!("No default output device")),
} }
} }
@@ -561,6 +566,11 @@ pub fn connect_to_vc(
current_output_device: config.output_device_name.clone(), current_output_device: config.output_device_name.clone(),
producer_in: Arc::clone(&shared_producer_in), producer_in: Arc::clone(&shared_producer_in),
consumer_out: Arc::clone(&shared_consumer_out), consumer_out: Arc::clone(&shared_consumer_out),
backend_config: Arc::new(Mutex::new(BackendConfig {
is_muted: false,
is_deaf: false,
})),
}; };
session.setup_input(config.input_device_name.clone(), Arc::clone(&state_inner))?; session.setup_input(config.input_device_name.clone(), Arc::clone(&state_inner))?;
@@ -571,6 +581,7 @@ pub fn connect_to_vc(
let input_socket = socket.clone(); let input_socket = socket.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
let cipher = cipher.clone(); let cipher = cipher.clone();
let backend_config = session.backend_config.clone();
thread::spawn(move || { thread::spawn(move || {
let mut sequence = 0u32; let mut sequence = 0u32;
@@ -581,6 +592,22 @@ pub fn connect_to_vc(
let mut noise_floor: f32 = 0.0; let mut noise_floor: f32 = 0.0;
while !shutdown.load(Ordering::Relaxed) { while !shutdown.load(Ordering::Relaxed) {
let (is_muted, is_deaf) = {
let cfg = backend_config.lock().unwrap();
(cfg.is_muted, cfg.is_deaf)
};
if is_muted || is_deaf {
// Clear audio recorded while muted to avoid stale transmission on unmute
let available = consumer_in.occupied_len();
if available > 0 {
consumer_in.skip(available);
}
thread::sleep(Duration::from_millis(50));
continue;
}
if consumer_in.occupied_len() >= PACKET_SAMPLES { if consumer_in.occupied_len() >= PACKET_SAMPLES {
let _ = consumer_in.pop_slice(&mut frame_buf); let _ = consumer_in.pop_slice(&mut frame_buf);
@@ -654,6 +681,7 @@ pub fn connect_to_vc(
let socket = socket.clone(); let socket = socket.clone();
let shutdown = shutdown.clone(); let shutdown = shutdown.clone();
let cipher = cipher.clone(); let cipher = cipher.clone();
let backend_config = session.backend_config.clone();
thread::spawn(move || { thread::spawn(move || {
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new(); let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
@@ -666,6 +694,16 @@ pub fn connect_to_vc(
let mut loop_count: u64 = 0; let mut loop_count: u64 = 0;
while !shutdown.load(Ordering::Relaxed) { while !shutdown.load(Ordering::Relaxed) {
let is_deaf = { backend_config.lock().unwrap().is_deaf };
if is_deaf {
packets.clear();
is_prebuffering = true;
std::thread::sleep(Duration::from_secs(1));
continue;
}
loop_count += 1; loop_count += 1;
if let Ok(len) = socket.recv(&mut udp_buffer) { if let Ok(len) = socket.recv(&mut udp_buffer) {
recv_count += 1; recv_count += 1;
+19
View File
@@ -102,3 +102,22 @@ pub fn get_config(app: AppHandle, state: State<ConfigState>) -> Result<Config, S
Ok(config) Ok(config)
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BackendConfig {
pub is_muted: bool,
pub is_deaf: bool,
}
#[tauri::command]
pub fn update_backend_config(
voice_state: State<'_, VoiceState>,
config: BackendConfig,
) -> Result<(), String> {
if let Some(v) = &mut *voice_state.inner.session.lock().unwrap() {
*v.backend_config.lock().unwrap() = config
}
Ok(())
}
+4 -3
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs}; use std::fs;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::types::ServerMeta; use crate::types::ServerMeta;
@@ -7,12 +7,13 @@ use crate::types::ServerMeta;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KnownServer { pub struct KnownServer {
pub meta: ServerMeta, pub hostname: String,
pub public_key: String, pub public_key: String,
pub is_secure: bool, pub is_secure: bool,
pub meta: ServerMeta,
} }
type ServerList = HashMap<String, KnownServer>; type ServerList = Vec<KnownServer>;
fn servers_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> { fn servers_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
let dir = app let dir = app
+1
View File
@@ -24,6 +24,7 @@ pub fn run() {
commands::config::update_config, commands::config::update_config,
commands::config::save_config, commands::config::save_config,
commands::config::get_config, commands::config::get_config,
commands::config::update_backend_config,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+63 -9
View File
@@ -10,7 +10,8 @@ import { Page } from "@/components/page/PageView";
import { Channel } from "@/lib/types"; import { Channel } from "@/lib/types";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { ClientMethod } from "./protocol"; import { ClientMethod } from "./protocol";
import { Config, getConfig } from "@/lib/config"; import { BackendConfig, Config, getConfig } from "@/lib/config";
import * as sfx from "@/lib/soundEffects";
ed.hashes.sha512 = sha512; ed.hashes.sha512 = sha512;
@@ -29,17 +30,23 @@ ed.hashes.sha512 = sha512;
*/ */
export default class Enclave<P = Page> { export default class Enclave<P = Page> {
public accounts?: AccountsFile; public accounts?: AccountsFile;
public publicKey?: string;
public server?: EnclaveServer; public server?: EnclaveServer;
public serverList: ServerList; public serverList: ServerList;
public isSettingsOpen: boolean; public isSettingsOpen: boolean;
public forceRender: () => void; public forceRender: () => void;
public page?: P; public page?: P;
public config?: Config; public config?: Config;
public backendConfig: BackendConfig;
public constructor() { public constructor() {
this.serverList = {}; this.serverList = [];
this.forceRender = () => {}; this.forceRender = () => {};
this.isSettingsOpen = false; this.isSettingsOpen = false;
this.backendConfig = {
isMuted: false,
isDeaf: false,
};
} }
public async init() { public async init() {
@@ -49,7 +56,15 @@ export default class Enclave<P = Page> {
} }
public getAccount(): Account | null { public getAccount(): Account | null {
return this.accounts?.accounts[this.accounts.activeAccount] || null; const account = this.accounts?.accounts[this.accounts.activeAccount];
if (account) {
this.publicKey = base58.encode(
ed.getPublicKey(base58.decode(account.privateKey)),
);
}
return account || null;
} }
public getClientSecretKey() { public getClientSecretKey() {
@@ -113,13 +128,17 @@ export default class Enclave<P = Page> {
return; return;
} }
const serveridx = this.serverList.findIndex(
(server) => server.hostname === hostname,
);
if ( if (
this.serverList[hostname] && serveridx >= 0 &&
this.serverList[hostname].publicKey !== this.serverList[serveridx].publicKey !==
base58.encode(this.server.serverPublicKey) base58.encode(this.server.serverPublicKey)
) { ) {
console.error("Server's new public key doesn't match old one", { console.error("Server's new public key doesn't match old one", {
old: this.serverList[hostname].publicKey, old: this.serverList[serveridx].publicKey,
new: base58.encode(this.server.serverPublicKey), new: base58.encode(this.server.serverPublicKey),
}); });
return; return;
@@ -156,12 +175,19 @@ export default class Enclave<P = Page> {
this.server.meta = metaResponse.data as any; this.server.meta = metaResponse.data as any;
this.serverList[hostname] = { const serverItem = {
meta: metaResponse.data as any, hostname,
isSecure,
publicKey: base58.encode(this.server.serverPublicKey), publicKey: base58.encode(this.server.serverPublicKey),
isSecure,
meta: metaResponse.data as any,
}; };
if (serveridx >= 0) {
this.serverList[serveridx] = serverItem;
} else {
this.serverList.push(serverItem);
}
this.forceRender(); this.forceRender();
} }
@@ -204,6 +230,8 @@ export default class Enclave<P = Page> {
server.voiceChatSpeakers = {}; server.voiceChatSpeakers = {};
this.forceRender(); this.forceRender();
sfx.playLeave();
} }
public async onMessage(msg: ClientMethod) { public async onMessage(msg: ClientMethod) {
@@ -224,13 +252,28 @@ export default class Enclave<P = Page> {
return; return;
case "Messages": case "Messages":
const currentTimestamp = Date.now();
let shouldPlaySfx = false;
const authors = Object.entries(msg.messages).flatMap( const authors = Object.entries(msg.messages).flatMap(
([channelId, messages]) => { ([channelId, messages]) => {
if (!server.messages[channelId]) { if (!server.messages[channelId]) {
server.messages[channelId] = {}; server.messages[channelId] = {};
} }
const page = this.page as Page | undefined;
const isInvisibleChannel = page?.channel.id !== channelId;
return messages.map((message) => { return messages.map((message) => {
if (
Math.abs(currentTimestamp - message.timestamp) < 1000 &&
(isInvisibleChannel || document.visibilityState === "hidden") &&
message.author !== this.publicKey
) {
shouldPlaySfx = true;
}
server.messages[channelId][message.id] = message; server.messages[channelId][message.id] = message;
return message.author; return message.author;
}); });
@@ -243,6 +286,8 @@ export default class Enclave<P = Page> {
this.forceRender(); this.forceRender();
if (shouldPlaySfx) sfx.playMessage();
return; return;
case "Users": case "Users":
@@ -274,6 +319,11 @@ export default class Enclave<P = Page> {
server.voiceChatUsers[msg.channel_id].push(msg.pubkey); server.voiceChatUsers[msg.channel_id].push(msg.pubkey);
this.server?.getUsers([msg.pubkey]); this.server?.getUsers([msg.pubkey]);
if (msg.channel_id === this.server?.voiceChannelId) {
sfx.playJoin();
}
return; return;
case "UserLeftVoice": case "UserLeftVoice":
@@ -285,6 +335,10 @@ export default class Enclave<P = Page> {
this.forceRender(); this.forceRender();
if (msg.channel_id === this.server?.voiceChannelId) {
sfx.playLeave();
}
return; return;
case "Speaking": case "Speaking":
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -12,14 +12,14 @@ export default function ServerList({
return ( return (
<aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0"> <aside className="w-16 h-screen p-2 flex flex-col gap-2.5 border-r border-r-border shrink-0">
{Object.entries(appRef.current.serverList).map(([hostname, server]) => { {appRef.current.serverList.map((server) => {
return ( return (
<img <img
src={getHTTPUrl(hostname, server.isSecure, "/icon")} src={getHTTPUrl(server.hostname, server.isSecure, "/icon")}
key={hostname} key={server.hostname}
className={cn( className={cn(
"rounded-lg", "rounded-lg",
hostname === appRef.current?.server?.hostname && server.hostname === appRef.current?.server?.hostname &&
"border-primary border border-2", "border-primary border border-2",
)} )}
@@ -29,7 +29,7 @@ export default function ServerList({
return; return;
} }
appRef.current.connectToServer(hostname, server.isSecure); appRef.current.connectToServer(server.hostname, server.isSecure);
}} }}
/> />
); );
+52 -3
View File
@@ -1,11 +1,20 @@
import { Card } from "../ui/card"; import { Card } from "../ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { CopyIcon, PhoneOff, Settings2Icon, Signal } from "lucide-react"; import {
CopyIcon,
Headset,
Mic,
MicOff,
PhoneOff,
Settings2Icon,
Signal,
} from "lucide-react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import Enclave from "@/app/app"; import Enclave from "@/app/app";
import { base58 } from "@scure/base"; import { base58 } from "@scure/base";
import * as ed from "@noble/ed25519"; import * as ed from "@noble/ed25519";
import { Channel } from "@/lib/types"; import { Channel } from "@/lib/types";
import { updateBackendConfig } from "@/lib/config";
function findChannel(channels: Channel[], id: string): Channel | undefined { function findChannel(channels: Channel[], id: string): Channel | undefined {
for (const channel of channels) { for (const channel of channels) {
@@ -38,9 +47,9 @@ export default function StatusCard({
return ( return (
<div className="absolute -left-16 right-16 bottom-0 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden"> <div className="absolute -left-16 right-16 bottom-0 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
<Card className="px-3 py-3 h-full w-full flex flex-col gap-0.5"> <Card className="px-3 py-3 h-full w-full flex flex-col gap-1.5">
{voiceChannel && ( {voiceChannel && (
<div className="text-emerald-500 flex flex-row items-center gap-2 border-b pb-2.5"> <div className="text-emerald-500 flex flex-row items-center gap-2 border-b pb-2">
<Signal className="h-5" /> <Signal className="h-5" />
<div className="flex flex-col"> <div className="flex flex-col">
<span>Voice Connected</span> <span>Voice Connected</span>
@@ -82,6 +91,46 @@ export default function StatusCard({
</div> </div>
</div> </div>
<div className="flex items-center text-muted-foreground"> <div className="flex items-center text-muted-foreground">
<Button
variant={
appRef.current?.backendConfig.isDeaf ? "destructive" : "ghost"
}
className="size-10"
onClick={() => {
if (!appRef.current) return;
appRef.current.backendConfig.isDeaf =
!appRef.current.backendConfig.isDeaf;
updateBackendConfig(appRef.current.backendConfig);
appRef.current.forceRender();
}}
>
<Headset className="size-5.5" />
</Button>
<Button
variant={
appRef.current?.backendConfig.isMuted ? "destructive" : "ghost"
}
className="size-10"
onClick={() => {
if (!appRef.current) return;
appRef.current.backendConfig.isMuted =
!appRef.current.backendConfig.isMuted;
updateBackendConfig(appRef.current.backendConfig);
appRef.current.forceRender();
}}
>
{appRef.current?.backendConfig.isMuted ? (
<MicOff className="size-5.5" />
) : (
<Mic className="size-5.5" />
)}
</Button>
<Button <Button
variant="ghost" variant="ghost"
className="size-10" className="size-10"
+11
View File
@@ -7,6 +7,11 @@ export type Config = {
outputVolume: number; outputVolume: number;
}; };
export type BackendConfig = {
isMuted: boolean;
isDeaf: boolean;
};
export async function updateConfig(config: Config): Promise<void> { export async function updateConfig(config: Config): Promise<void> {
await invoke("update_config", { config }); await invoke("update_config", { config });
} }
@@ -18,3 +23,9 @@ export async function saveConfig(): Promise<void> {
export async function getConfig(): Promise<Config> { export async function getConfig(): Promise<Config> {
return await invoke("get_config"); return await invoke("get_config");
} }
export async function updateBackendConfig(
config: BackendConfig,
): Promise<void> {
await invoke("update_backend_config", { config });
}
+3 -2
View File
@@ -2,12 +2,13 @@ import { invoke } from "@tauri-apps/api/core";
import { ServerMeta } from "./types"; import { ServerMeta } from "./types";
export interface KnownServer { export interface KnownServer {
meta: ServerMeta; hostname: string;
publicKey: string; publicKey: string;
isSecure: boolean; isSecure: boolean;
meta: ServerMeta;
} }
export type ServerList = Record<string, KnownServer>; export type ServerList = KnownServer[];
export async function saveServerList(servers: ServerList): Promise<void> { export async function saveServerList(servers: ServerList): Promise<void> {
await invoke("save_server_list", { servers }); await invoke("save_server_list", { servers });
+24
View File
@@ -0,0 +1,24 @@
import joinSfx from "@/assets/join.wav";
import leaveSfx from "@/assets/leave.wav";
import messageSfx from "@/assets/message.mp3";
export function playJoin() {
const audio = new Audio(joinSfx);
audio.currentTime = 0;
audio.play();
}
export function playLeave() {
const audio = new Audio(leaveSfx);
audio.currentTime = 0;
audio.play();
}
export function playMessage() {
const audio = new Audio(messageSfx);
audio.currentTime = 0;
audio.play();
}