From b0635247595f1ed76f14ef4c8bdcd54bcb8ecde4 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 4 Jul 2026 05:47:47 +0700 Subject: [PATCH] fix(fe): perbaiki infinite loop dashboard, config fetching, infinite scroll, dan visualizer reactivity - Dashboard: ganti Effect::new dengan spawn_local untuk initial fetch agar tidak terjadi infinite loop karena reactive dependency tracking - Config: tambah api/config.rs dan jadikan AppConfig.monitor_guild_id RwSignal supaya di-fetch dari backend (saat startup & setelah login) - Messages: perbaiki messageFetch agar guild_id terbaca dari config reaktif - Infinite scroll: tambah observer_ready signal + spawn_local trigger agar IntersectionObserver setup setelah DOM mount; tambah fallback button - AudioVisualizer & MicLevelMeter: tambah periodic tick signal (100ms) agar efek re-run dan update bars/level dari shared PCM buffer - pcm_decoder: ganti js_sys::eval dengan wasm-bindgen binding langsung ke btoa --- services/frontend/frontend/src/api/config.rs | 13 +++ services/frontend/frontend/src/api/mod.rs | 1 + services/frontend/frontend/src/app.rs | 46 ++++++++++- .../frontend/src/features/dashboard/mod.rs | 3 +- .../src/features/live/audio/pcm_decoder.rs | 21 +++-- .../live/components/audio_visualizer.rs | 12 ++- .../live/components/mic_level_meter.rs | 12 ++- .../messages/components/message_feed.rs | 82 ++++++++++++++----- .../frontend/src/features/messages/mod.rs | 5 +- services/frontend/frontend/src/ui/toast.rs | 2 +- 10 files changed, 158 insertions(+), 39 deletions(-) create mode 100644 services/frontend/frontend/src/api/config.rs diff --git a/services/frontend/frontend/src/api/config.rs b/services/frontend/frontend/src/api/config.rs new file mode 100644 index 0000000..5e6fa68 --- /dev/null +++ b/services/frontend/frontend/src/api/config.rs @@ -0,0 +1,13 @@ +use crate::api::client::{request, ApiError}; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppConfigResponse { + pub monitor_guild_id: Option, +} + +/// GET /api/config +pub async fn get_config() -> Result { + request("GET", "/api/config", None).await +} diff --git a/services/frontend/frontend/src/api/mod.rs b/services/frontend/frontend/src/api/mod.rs index 4e8592a..dfe2f33 100644 --- a/services/frontend/frontend/src/api/mod.rs +++ b/services/frontend/frontend/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod client; +pub mod config; pub mod dashboard; pub mod mascot; pub mod messages; diff --git a/services/frontend/frontend/src/app.rs b/services/frontend/frontend/src/app.rs index 5af4279..a4eda8d 100644 --- a/services/frontend/frontend/src/app.rs +++ b/services/frontend/frontend/src/app.rs @@ -1,3 +1,4 @@ +use crate::api::config as config_api; use crate::features::dashboard::DashboardPanel; use crate::features::live::LivePanel; use crate::features::messages::MessagesPanel; @@ -6,6 +7,7 @@ use crate::features::polish::{initial_theme, ThemeContext}; use crate::ws::context::WsContext; use leptos::prelude::*; use shared_types::ui_state::Tab; +use wasm_bindgen_futures::spawn_local; /// Derive WebSocket URL from the page's own origin. /// In development (serve on :8080, backend on :3001) use the detected host + /ws path. @@ -28,7 +30,7 @@ fn get_ws_url() -> String { #[derive(Clone)] pub struct AppConfig { - pub monitor_guild_id: Option, + pub monitor_guild_id: RwSignal>, } // ── Contexts ──────────────────────────────────────────── @@ -67,15 +69,53 @@ pub fn App() -> impl IntoView { provide_context(theme.clone()); let config = AppConfig { - monitor_guild_id: None, + monitor_guild_id: RwSignal::new(None), }; - provide_context(config); + provide_context(config.clone()); let ws = WsContext::new(&get_ws_url()); provide_context(ws.clone()); ws.connect(); + // Try to fetch config on startup (works if password is already in localStorage) + spawn_local({ + let config = config.clone(); + async move { + match config_api::get_config().await { + Ok(cfg) => { + config.monitor_guild_id.set(cfg.monitor_guild_id); + } + Err(e) => { + web_sys::console::log_1( + &format!("[config] failed to fetch: {}", e).into(), + ); + } + } + } + }); + + // Re-fetch config when user authenticates (handles first-time login) + Effect::new(move |_| { + if auth.authenticated.get() { + spawn_local({ + let config = config.clone(); + async move { + match config_api::get_config().await { + Ok(cfg) => { + config.monitor_guild_id.set(cfg.monitor_guild_id); + } + Err(e) => { + web_sys::console::log_1( + &format!("[config] fetch after auth failed: {}", e).into(), + ); + } + } + } + }); + } + }); + view! {
diff --git a/services/frontend/frontend/src/features/dashboard/mod.rs b/services/frontend/frontend/src/features/dashboard/mod.rs index 3ce90df..ad51cd7 100644 --- a/services/frontend/frontend/src/features/dashboard/mod.rs +++ b/services/frontend/frontend/src/features/dashboard/mod.rs @@ -114,11 +114,12 @@ pub fn DashboardPanel() -> impl IntoView { }); }); + // Initial fetch on mount (use spawn_local to avoid reactive dependency tracking) { let fetch_stats = fetch_stats.clone(); let fetch_users = fetch_users.clone(); let fetch_channels = fetch_channels.clone(); - Effect::new(move |_| { + spawn_local(async move { fetch_stats(); fetch_users(true); fetch_channels(true); diff --git a/services/frontend/frontend/src/features/live/audio/pcm_decoder.rs b/services/frontend/frontend/src/features/live/audio/pcm_decoder.rs index 13ec471..0a6b271 100644 --- a/services/frontend/frontend/src/features/live/audio/pcm_decoder.rs +++ b/services/frontend/frontend/src/features/live/audio/pcm_decoder.rs @@ -1,3 +1,5 @@ +use wasm_bindgen::prelude::*; + /// PCM Frame decoded from binary WebSocket data /// Format: [u32 userId (4 bytes)][i16 samples (N bytes)] pub struct PcmFrame { @@ -53,15 +55,16 @@ pub fn encode_samples_to_base64(samples: &[f32]) -> String { encode_bytes_base64(&bytes) } -/// Encode raw bytes to base64 using JavaScript's btoa +/// Encode raw bytes to base64 using JavaScript's btoa via wasm-bindgen fn encode_bytes_base64(data: &[u8]) -> String { - // Build binary string for btoa - let binary: String = data.iter().map(|&b| b as char).collect(); + // Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint) + let latin1: String = data.iter().map(|&b| b as char).collect(); + js_btoa(&latin1) +} - // Call btoa from JavaScript via js_sys::eval - let js_code = format!("btoa('{}')", binary.replace('\'', "\\'")); - js_sys::eval(&js_code) - .ok() - .and_then(|r| r.as_string()) - .unwrap_or_default() +/// Direct wasm-bindgen binding to the browser's btoa function +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_name = btoa)] + fn js_btoa(input: &str) -> String; } diff --git a/services/frontend/frontend/src/features/live/components/audio_visualizer.rs b/services/frontend/frontend/src/features/live/components/audio_visualizer.rs index eacb97d..cd3e557 100644 --- a/services/frontend/frontend/src/features/live/components/audio_visualizer.rs +++ b/services/frontend/frontend/src/features/live/components/audio_visualizer.rs @@ -9,9 +9,19 @@ pub fn AudioVisualizer( #[prop(optional)] pcm_data: Option>>>, ) -> impl IntoView { let bars = RwSignal::new(vec![0.0; 32]); + let (tick, set_tick) = signal(0u32); - // Periodically update bars from PCM data + // Drive periodic updates: increment tick every 100ms + wasm_bindgen_futures::spawn_local(async move { + loop { + gloo_timers::future::TimeoutFuture::new(100).await; + set_tick.update(|t| *t = t.wrapping_add(1)); + } + }); + + // Effect reacts to tick changes, updating bars from PCM data each frame Effect::new(move |_| { + tick.get(); // Track — Effect re-runs on each tick (every 100ms) if let Some(ref pcm_arc) = pcm_data { if let Ok(pcm_vec) = pcm_arc.lock() { let computed = compute_frequency_bands(&pcm_vec); diff --git a/services/frontend/frontend/src/features/live/components/mic_level_meter.rs b/services/frontend/frontend/src/features/live/components/mic_level_meter.rs index 3d06f4c..adf56d1 100644 --- a/services/frontend/frontend/src/features/live/components/mic_level_meter.rs +++ b/services/frontend/frontend/src/features/live/components/mic_level_meter.rs @@ -11,9 +11,19 @@ pub fn MicLevelMeter( ) -> impl IntoView { let level = RwSignal::new(0.0f32); let peak = RwSignal::new(0.0f32); + let (tick, set_tick) = signal(0u32); - // Update level periodically + // Drive periodic updates: increment tick every 100ms + wasm_bindgen_futures::spawn_local(async move { + loop { + gloo_timers::future::TimeoutFuture::new(100).await; + set_tick.update(|t| *t = t.wrapping_add(1)); + } + }); + + // Effect reacts to tick changes, updating level from PCM data each frame Effect::new(move |_| { + tick.get(); // Track — Effect re-runs on each tick if !active { return; } diff --git a/services/frontend/frontend/src/features/messages/components/message_feed.rs b/services/frontend/frontend/src/features/messages/components/message_feed.rs index d6df69a..17ae6f9 100644 --- a/services/frontend/frontend/src/features/messages/components/message_feed.rs +++ b/services/frontend/frontend/src/features/messages/components/message_feed.rs @@ -3,6 +3,7 @@ use leptos::prelude::*; use shared_types::message::MessageRecord; use std::sync::Arc; use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; use web_sys::IntersectionObserver; const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; @@ -40,31 +41,52 @@ pub fn MessageFeed( on_reanalyze: Arc, ) -> impl IntoView { let sentinel_ref = NodeRef::::new(); - let (_intersecting, _set_intersecting) = signal(false); + let (observer_ready, set_observer_ready) = signal(false); + // Schedule observer setup to run AFTER the DOM is mounted (next microtask). + // With has_more, !loading, and messages present the sentinel div will be in the DOM. + if !loading && !messages.is_empty() && has_more { + wasm_bindgen_futures::spawn_local({ + let setter = set_observer_ready; + async move { + setter.set(true); + } + }); + } + + // Clone before move into Effect closure so it's still available for the view + let on_load_more_io = on_load_more.clone(); Effect::new(move |_| { - let _ = _intersecting.get(); // track signal + let _ready = observer_ready.get(); + if !_ready { + return; + } if let Some(node) = sentinel_ref.get() { - let on_load_more = on_load_more.clone(); - let cb = Closure::)>::new(move |entries: Vec| { - for entry in entries { - if let Some(entry) = entry.dyn_ref::() { - if entry.is_intersecting() { - if let Some(ref cb) = on_load_more { - cb(); + let cb = on_load_more_io.clone(); + let observer_cb = Closure::, IntersectionObserver)>::new( + move |entries: Vec, _observer: IntersectionObserver| { + for entry in entries { + if let Some(entry) = + entry.dyn_ref::() + { + if entry.is_intersecting() { + if let Some(ref cb) = cb { + cb(); + } } } } - } - }); - let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()) - .expect("IntersectionObserver failed"); + }, + ); + let observer = + IntersectionObserver::new(observer_cb.as_ref().unchecked_ref()) + .expect("IntersectionObserver failed"); observer.observe(&node); + // Keep closure alive — forget rather than cleanup since observer owns it + observer_cb.forget(); on_cleanup(move || { observer.disconnect(); }); - // Keep closure alive - cb.forget(); } }); @@ -105,15 +127,33 @@ pub fn MessageFeed( } }).collect::>()} - {/* Infinite scroll sentinel */} + {/* Infinite scroll sentinel + fallback load more button */} {has_more_val.then(|| { view! { -
- {loading_more_val.then(|| { - use super::message_card::MessageCardSkeleton; - view! { } + <> +
+ {loading_more_val.then(|| { + use super::message_card::MessageCardSkeleton; + view! { } + })} +
+ {/* Fallback: visible button in case IntersectionObserver doesn't fire */} + {(!loading_more_val).then(|| { + let load_more_cb = on_load_more.clone(); + view! { +
+ +
+ } })} -
+ } })}
diff --git a/services/frontend/frontend/src/features/messages/mod.rs b/services/frontend/frontend/src/features/messages/mod.rs index 3bdbfd7..1935e35 100644 --- a/services/frontend/frontend/src/features/messages/mod.rs +++ b/services/frontend/frontend/src/features/messages/mod.rs @@ -164,8 +164,9 @@ pub fn MessagesPanel() -> impl IntoView { // Fetch messages on mount if guild is configured Effect::new(move |_| { if let Some(config) = use_context::() { - if let Some(ref guild_id) = config.monitor_guild_id { - (state.fetch_messages)(guild_id.clone()); + if let Some(ref guild_id) = config.monitor_guild_id.get() { + let gid = guild_id.clone(); + (state.fetch_messages)(gid); } } }); diff --git a/services/frontend/frontend/src/ui/toast.rs b/services/frontend/frontend/src/ui/toast.rs index d74ca05..9d9b340 100644 --- a/services/frontend/frontend/src/ui/toast.rs +++ b/services/frontend/frontend/src/ui/toast.rs @@ -52,7 +52,7 @@ impl ToastContext { // Auto-dismiss after 4 seconds let toasts = self.toasts; - leptos::prelude::set_timeout( + let _ = leptos::prelude::set_timeout( move || { toasts.update(|t| t.retain(|m| m.id != id)); },