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
This commit is contained in:
asepharyana
2026-07-04 05:47:47 +07:00
parent 5fa97da969
commit b063524759
10 changed files with 158 additions and 39 deletions
@@ -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<String>,
}
/// GET /api/config
pub async fn get_config() -> Result<AppConfigResponse, ApiError> {
request("GET", "/api/config", None).await
}
@@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod client; pub mod client;
pub mod config;
pub mod dashboard; pub mod dashboard;
pub mod mascot; pub mod mascot;
pub mod messages; pub mod messages;
+43 -3
View File
@@ -1,3 +1,4 @@
use crate::api::config as config_api;
use crate::features::dashboard::DashboardPanel; use crate::features::dashboard::DashboardPanel;
use crate::features::live::LivePanel; use crate::features::live::LivePanel;
use crate::features::messages::MessagesPanel; use crate::features::messages::MessagesPanel;
@@ -6,6 +7,7 @@ use crate::features::polish::{initial_theme, ThemeContext};
use crate::ws::context::WsContext; use crate::ws::context::WsContext;
use leptos::prelude::*; use leptos::prelude::*;
use shared_types::ui_state::Tab; use shared_types::ui_state::Tab;
use wasm_bindgen_futures::spawn_local;
/// Derive WebSocket URL from the page's own origin. /// Derive WebSocket URL from the page's own origin.
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path. /// 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)] #[derive(Clone)]
pub struct AppConfig { pub struct AppConfig {
pub monitor_guild_id: Option<String>, pub monitor_guild_id: RwSignal<Option<String>>,
} }
// ── Contexts ──────────────────────────────────────────── // ── Contexts ────────────────────────────────────────────
@@ -67,15 +69,53 @@ pub fn App() -> impl IntoView {
provide_context(theme.clone()); provide_context(theme.clone());
let config = AppConfig { 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()); let ws = WsContext::new(&get_ws_url());
provide_context(ws.clone()); provide_context(ws.clone());
ws.connect(); 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! { view! {
<div data-theme=move || theme.theme.get()> <div data-theme=move || theme.theme.get()>
<ParticleBackground /> <ParticleBackground />
@@ -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_stats = fetch_stats.clone();
let fetch_users = fetch_users.clone(); let fetch_users = fetch_users.clone();
let fetch_channels = fetch_channels.clone(); let fetch_channels = fetch_channels.clone();
Effect::new(move |_| { spawn_local(async move {
fetch_stats(); fetch_stats();
fetch_users(true); fetch_users(true);
fetch_channels(true); fetch_channels(true);
@@ -1,3 +1,5 @@
use wasm_bindgen::prelude::*;
/// PCM Frame decoded from binary WebSocket data /// PCM Frame decoded from binary WebSocket data
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)] /// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
pub struct PcmFrame { pub struct PcmFrame {
@@ -53,15 +55,16 @@ pub fn encode_samples_to_base64(samples: &[f32]) -> String {
encode_bytes_base64(&bytes) 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 { fn encode_bytes_base64(data: &[u8]) -> String {
// Build binary string for btoa // Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint)
let binary: String = data.iter().map(|&b| b as char).collect(); let latin1: String = data.iter().map(|&b| b as char).collect();
js_btoa(&latin1)
}
// Call btoa from JavaScript via js_sys::eval /// Direct wasm-bindgen binding to the browser's btoa function
let js_code = format!("btoa('{}')", binary.replace('\'', "\\'")); #[wasm_bindgen]
js_sys::eval(&js_code) extern "C" {
.ok() #[wasm_bindgen(js_name = btoa)]
.and_then(|r| r.as_string()) fn js_btoa(input: &str) -> String;
.unwrap_or_default()
} }
@@ -9,9 +9,19 @@ pub fn AudioVisualizer(
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>, #[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
) -> impl IntoView { ) -> impl IntoView {
let bars = RwSignal::new(vec![0.0; 32]); 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 |_| { Effect::new(move |_| {
tick.get(); // Track — Effect re-runs on each tick (every 100ms)
if let Some(ref pcm_arc) = pcm_data { if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() { if let Ok(pcm_vec) = pcm_arc.lock() {
let computed = compute_frequency_bands(&pcm_vec); let computed = compute_frequency_bands(&pcm_vec);
@@ -11,9 +11,19 @@ pub fn MicLevelMeter(
) -> impl IntoView { ) -> impl IntoView {
let level = RwSignal::new(0.0f32); let level = RwSignal::new(0.0f32);
let peak = 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 |_| { Effect::new(move |_| {
tick.get(); // Track — Effect re-runs on each tick
if !active { if !active {
return; return;
} }
@@ -3,6 +3,7 @@ use leptos::prelude::*;
use shared_types::message::MessageRecord; use shared_types::message::MessageRecord;
use std::sync::Arc; use std::sync::Arc;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::IntersectionObserver; use web_sys::IntersectionObserver;
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000; const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
@@ -40,31 +41,52 @@ pub fn MessageFeed(
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>, on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView { ) -> impl IntoView {
let sentinel_ref = NodeRef::<html::Div>::new(); let sentinel_ref = NodeRef::<html::Div>::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 |_| { Effect::new(move |_| {
let _ = _intersecting.get(); // track signal let _ready = observer_ready.get();
if !_ready {
return;
}
if let Some(node) = sentinel_ref.get() { if let Some(node) = sentinel_ref.get() {
let on_load_more = on_load_more.clone(); let cb = on_load_more_io.clone();
let cb = Closure::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| { let observer_cb = Closure::<dyn Fn(Vec<JsValue>, IntersectionObserver)>::new(
for entry in entries { move |entries: Vec<JsValue>, _observer: IntersectionObserver| {
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() { for entry in entries {
if entry.is_intersecting() { if let Some(entry) =
if let Some(ref cb) = on_load_more { entry.dyn_ref::<web_sys::IntersectionObserverEntry>()
cb(); {
if entry.is_intersecting() {
if let Some(ref cb) = cb {
cb();
}
} }
} }
} }
} },
}); );
let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref()) let observer =
.expect("IntersectionObserver failed"); IntersectionObserver::new(observer_cb.as_ref().unchecked_ref())
.expect("IntersectionObserver failed");
observer.observe(&node); observer.observe(&node);
// Keep closure alive — forget rather than cleanup since observer owns it
observer_cb.forget();
on_cleanup(move || { on_cleanup(move || {
observer.disconnect(); observer.disconnect();
}); });
// Keep closure alive
cb.forget();
} }
}); });
@@ -105,15 +127,33 @@ pub fn MessageFeed(
} }
}).collect::<Vec<_>>()} }).collect::<Vec<_>>()}
{/* Infinite scroll sentinel */} {/* Infinite scroll sentinel + fallback load more button */}
{has_more_val.then(|| { {has_more_val.then(|| {
view! { view! {
<div node_ref=sentinel_ref class="h-4"> <>
{loading_more_val.then(|| { <div node_ref=sentinel_ref class="h-4">
use super::message_card::MessageCardSkeleton; {loading_more_val.then(|| {
view! { <MessageCardSkeleton /> } use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
})}
</div>
{/* Fallback: visible button in case IntersectionObserver doesn't fire */}
{(!loading_more_val).then(|| {
let load_more_cb = on_load_more.clone();
view! {
<div class="mt-4 text-center">
<button
class="btn btn-outline btn-sm"
on:click=move |_| {
if let Some(ref cb) = load_more_cb { cb(); }
}
>
"Load more"
</button>
</div>
}
})} })}
</div> </>
} }
})} })}
</div> </div>
@@ -164,8 +164,9 @@ pub fn MessagesPanel() -> impl IntoView {
// Fetch messages on mount if guild is configured // Fetch messages on mount if guild is configured
Effect::new(move |_| { Effect::new(move |_| {
if let Some(config) = use_context::<crate::app::AppConfig>() { if let Some(config) = use_context::<crate::app::AppConfig>() {
if let Some(ref guild_id) = config.monitor_guild_id { if let Some(ref guild_id) = config.monitor_guild_id.get() {
(state.fetch_messages)(guild_id.clone()); let gid = guild_id.clone();
(state.fetch_messages)(gid);
} }
} }
}); });
+1 -1
View File
@@ -52,7 +52,7 @@ impl ToastContext {
// Auto-dismiss after 4 seconds // Auto-dismiss after 4 seconds
let toasts = self.toasts; let toasts = self.toasts;
leptos::prelude::set_timeout( let _ = leptos::prelude::set_timeout(
move || { move || {
toasts.update(|t| t.retain(|m| m.id != id)); toasts.update(|t| t.retain(|m| m.id != id));
}, },