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 client;
pub mod config;
pub mod dashboard;
pub mod mascot;
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::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<String>,
pub monitor_guild_id: RwSignal<Option<String>>,
}
// ── 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! {
<div data-theme=move || theme.theme.get()>
<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_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);
@@ -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;
}
@@ -9,9 +9,19 @@ pub fn AudioVisualizer(
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
) -> 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);
@@ -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;
}
@@ -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<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
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 |_| {
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::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| {
for entry in entries {
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() {
if entry.is_intersecting() {
if let Some(ref cb) = on_load_more {
cb();
let cb = on_load_more_io.clone();
let observer_cb = Closure::<dyn Fn(Vec<JsValue>, IntersectionObserver)>::new(
move |entries: Vec<JsValue>, _observer: IntersectionObserver| {
for entry in entries {
if let Some(entry) =
entry.dyn_ref::<web_sys::IntersectionObserverEntry>()
{
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::<Vec<_>>()}
{/* Infinite scroll sentinel */}
{/* Infinite scroll sentinel + fallback load more button */}
{has_more_val.then(|| {
view! {
<div node_ref=sentinel_ref class="h-4">
{loading_more_val.then(|| {
use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
<>
<div node_ref=sentinel_ref class="h-4">
{loading_more_val.then(|| {
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>
@@ -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::<crate::app::AppConfig>() {
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);
}
}
});
+1 -1
View File
@@ -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));
},