feat(leptos): messages module skeleton + use_messages hook

This commit is contained in:
asepharyana
2026-07-03 20:14:59 +07:00
parent 1b970a4c51
commit 9322434daa
9 changed files with 242 additions and 0 deletions
@@ -0,0 +1,8 @@
use leptos::prelude::*;
#[component]
pub fn ImageGrid() -> impl IntoView {
view! {
<div>"Image Grid (placeholder)"</div>
}
}
@@ -0,0 +1,8 @@
use leptos::prelude::*;
#[component]
pub fn MessageCard() -> impl IntoView {
view! {
<div>"Message Card (placeholder)"</div>
}
}
@@ -0,0 +1,8 @@
use leptos::prelude::*;
#[component]
pub fn MessageFeed() -> impl IntoView {
view! {
<div>"Message Feed (placeholder)"</div>
}
}
@@ -0,0 +1,3 @@
pub mod message_feed;
pub mod message_card;
pub mod image_grid;
@@ -0,0 +1 @@
pub mod use_messages;
@@ -0,0 +1,201 @@
use leptos::prelude::*;
use shared_types::message::{MessageRecord, PageResult};
use crate::api::messages::{get_messages, reanalyze_message, reanalyze_batch};
use std::collections::HashMap;
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Merges current messages with incoming messages, deduplicating by ID and sorting
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
let mut by_id: HashMap<String, MessageRecord> = current.iter().map(|m| (m.id.clone(), m.clone())).collect();
for msg in incoming {
by_id.insert(msg.id.clone(), msg.clone());
}
let mut merged: Vec<MessageRecord> = by_id.into_values().collect();
merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
merged
}
/// Callback type for fetch_messages
pub type FetchMessagesCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for load_more
pub type LoadMoreCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for reanalyze
pub type ReanalyzeCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for reanalyze_all_errors
pub type ReanalyzeAllErrorsCallback = Arc<dyn Fn() + Send + Sync>;
/// State returned by use_messages hook
#[derive(Clone)]
pub struct MessagesState {
/// Current list of messages
pub messages: RwSignal<Vec<MessageRecord>>,
/// Whether the initial fetch is in progress
pub loading: ReadSignal<bool>,
/// Whether we're loading more messages
pub loading_more: RwSignal<bool>,
/// Pagination cursor for next page
pub cursor: RwSignal<Option<String>>,
/// Derived: whether there are more messages to load
pub has_more: Memo<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Current guild ID
pub current_guild: RwSignal<Option<String>>,
/// Fetch initial messages for a guild
pub fetch_messages: FetchMessagesCallback,
/// Load next page of messages
pub load_more: LoadMoreCallback,
/// Reanalyze a single message
pub reanalyze: ReanalyzeCallback,
/// Reanalyze all error messages in current batch
pub reanalyze_all_errors: ReanalyzeAllErrorsCallback,
}
/// Hook to manage message data fetching and state
#[component]
pub fn use_messages() -> MessagesState {
// Core signals
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
let (loading, set_loading) = create_signal(false);
let loading_more_signal = RwSignal::new(false);
let cursor_signal = RwSignal::new(None::<String>);
let error_signal = RwSignal::new(None::<String>);
let current_guild_signal = RwSignal::new(None::<String>);
// Derived signal: has_more is true if cursor is Some
let has_more_signal = create_memo(move |_| cursor_signal.get().is_some());
// Fetch initial messages for a guild
let fetch_messages_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
set_loading.set(true);
match get_messages(&guild_id, Some(30), None, None).await {
Ok(PageResult { data, next_cursor }) => {
messages_signal.set(data);
cursor_signal.set(next_cursor);
current_guild_signal.set(Some(guild_id));
set_loading.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to fetch messages: {}", e)));
set_loading.set(false);
}
}
}
});
});
// Load more messages (append next page)
let load_more_impl = Arc::new(move || {
spawn_local(async move {
let guild_id = match current_guild_signal.get() {
Some(id) => id,
None => {
error_signal.set(Some("No guild selected".to_string()));
return;
}
};
let cursor = match cursor_signal.get() {
Some(c) => c,
None => {
error_signal.set(Some("No more messages to load".to_string()));
return;
}
};
loading_more_signal.set(true);
error_signal.set(None);
match get_messages(&guild_id, Some(30), None, Some(&cursor)).await {
Ok(PageResult { data, next_cursor }) => {
let current = messages_signal.get();
messages_signal.set(merge_messages(&current, &data));
cursor_signal.set(next_cursor);
loading_more_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load more: {}", e)));
loading_more_signal.set(false);
}
}
});
});
// Reanalyze single message with optimistic update
let reanalyze_impl = Arc::new(move |message_id: String| {
spawn_local({
let message_id = message_id.clone();
async move {
// Optimistic: flip status to Processing
let mut msgs = messages_signal.get();
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
if let Some(ref mut msg) = msgs.get_mut(pos) {
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
}
}
messages_signal.set(msgs);
// Call API
match reanalyze_message(&message_id).await {
Ok(_) => {
// Success: keep the Processing status (will be updated via WS)
}
Err(e) => {
// Revert to Error status on failure
let mut msgs = messages_signal.get();
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
if let Some(ref mut msg) = msgs.get_mut(pos) {
msg.ai_status = Some(shared_types::message::AiStatus::Error);
msg.ai_error = Some(e.to_string());
}
}
messages_signal.set(msgs);
error_signal.set(Some(format!("Reanalyze failed: {}", e)));
}
}
}
});
});
// Reanalyze all error messages
let reanalyze_all_errors_impl = Arc::new(move || {
spawn_local(async move {
match reanalyze_batch().await {
Ok(_count) => {
error_signal.set(None);
// Optimistically mark all error messages as Processing
let mut msgs = messages_signal.get();
for msg in msgs.iter_mut() {
if msg.ai_status == Some(shared_types::message::AiStatus::Error) {
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
}
}
messages_signal.set(msgs);
}
Err(e) => {
error_signal.set(Some(format!("Batch reanalyze failed: {}", e)));
}
}
});
});
MessagesState {
messages: messages_signal,
loading,
loading_more: loading_more_signal,
cursor: cursor_signal,
has_more: has_more_signal,
error: error_signal,
current_guild: current_guild_signal,
fetch_messages: fetch_messages_impl,
load_more: load_more_impl,
reanalyze: reanalyze_impl,
reanalyze_all_errors: reanalyze_all_errors_impl,
}
}
@@ -0,0 +1,11 @@
use leptos::prelude::*;
pub mod components;
pub mod hooks;
#[component]
pub fn MessagesPanel() -> impl IntoView {
view! {
<div>"Messages Panel (loading...)"</div>
}
}
@@ -0,0 +1 @@
pub mod messages;
@@ -1,6 +1,7 @@
pub mod api;
pub mod app;
pub mod auth;
pub mod features;
pub mod layout;
pub mod ui;
pub mod ws;