synpad 0.1.0

A full-featured Matrix chat client built with Dioxus
use dioxus::prelude::*;
use matrix_sdk::ruma::OwnedRoomId;

use crate::room::timeline::day_separator::DaySeparator;
use crate::room::timeline::event_tile::EventTile;
use crate::room::timeline::typing_indicator::TypingIndicator;
use crate::state::app_state::AppState;
use crate::state::room_state::TimelineItemKind;

/// Render a single timeline item.
fn render_timeline_item(item: &TimelineItemKind, idx: usize, room_id: &str) -> Element {
    match item {
        TimelineItemKind::Event(event) => rsx! {
            EventTile {
                key: "{idx}",
                room_id: room_id.to_string(),
                event_id: event.event_id.as_ref().map(|e| e.to_string()),
                sender: event.sender.to_string(),
                sender_display_name: event.sender_display_name.clone(),
                sender_avatar_url: event.sender_avatar_url.clone(),
                timestamp: event.timestamp,
                content: event.content.clone(),
                reactions: event.reactions.clone(),
                is_edited: event.is_edited,
                reply_to: event.reply_to.clone(),
                is_own_message: event.is_own_message,
            }
        },
        TimelineItemKind::DaySeparator(date) => rsx! {
            DaySeparator {
                key: "sep-{idx}",
                date: date.clone(),
            }
        },
        TimelineItemKind::ReadMarker => rsx! {
            div {
                key: "{idx}-readmarker",
                class: "timeline-panel__read-marker",
                span { "New messages" }
            }
        },
        TimelineItemKind::Loading => rsx! {
            div {
                key: "loading-{idx}",
                class: "timeline-panel__loading-item",
                div { class: "spinner spinner--small" }
            }
        },
    }
}

/// Timeline panel component displaying messages in a room.
#[component]
pub fn TimelinePanel(room_id: String, #[props(default)] search_filter: String) -> Element {
    let state = use_context::<Signal<AppState>>();
    let mut timeline_items = use_signal(Vec::<TimelineItemKind>::new);
    let mut pagination_token = use_signal(|| Option::<String>::None);
    let mut is_loading = use_signal(|| true);
    let mut is_loading_more = use_signal(|| false);
    let _show_jump_to_bottom = use_signal(|| false);

    // Track room_id changes via a signal so use_effect can react
    let mut current_room = use_signal(|| room_id.clone());
    if *current_room.read() != room_id {
        current_room.set(room_id.clone());
        // Clear old timeline when switching rooms
        timeline_items.set(Vec::new());
        pagination_token.set(None);
        is_loading.set(true);
    }

    // Extract sync_generation into a memo so effect only re-runs on sync, not every state change
    let sync_gen = use_memo(move || state.read().sync_generation);
    let has_client = use_memo(move || state.read().client.is_some());

    // Load/reload messages when room changes or new sync arrives
    use_effect(move || {
        let rid = current_room.read().clone();
        let _gen = *sync_gen.read();
        let _has = *has_client.read();

        spawn(async move {
            let client = { state.read().client.clone() };
            let Some(client) = client else {
                is_loading.set(false);
                return;
            };

            let parsed_id: Result<OwnedRoomId, _> = rid.as_str().try_into();
            let Ok(room_id) = parsed_id else {
                tracing::error!("Invalid room ID: {rid}");
                is_loading.set(false);
                return;
            };

            is_loading.set(true);

            match crate::client::timeline::load_room_messages(&client, &room_id, None).await {
                Ok((items, token)) => {
                    timeline_items.set(items);
                    pagination_token.set(token);
                }
                Err(e) => {
                    tracing::error!("Failed to load timeline for {room_id}: {e}");
                }
            }

            is_loading.set(false);
        });
    });

    // Handler: load older messages
    let load_more = move |_| {
        let rid = current_room.read().clone();
        let token = pagination_token.read().clone();
        if token.is_none() {
            return;
        }

        spawn(async move {
            let client = { state.read().client.clone() };
            let Some(client) = client else { return };
            let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(rid.as_str()) else {
                return;
            };

            is_loading_more.set(true);

            match crate::client::timeline::load_room_messages(
                &client,
                &room_id,
                token.as_deref(),
            )
            .await
            {
                Ok((older_items, new_token)) => {
                    // Prepend older items before existing ones
                    let mut combined = older_items;
                    let mut current = timeline_items.write();
                    combined.append(&mut *current);
                    *current = combined;
                    drop(current);
                    pagination_token.set(new_token);
                }
                Err(e) => {
                    tracing::error!("Failed to load older messages: {e}");
                }
            }

            is_loading_more.set(false);
        });
    };

    rsx! {
        div {
            class: "timeline-panel",

            // Load more button at top for backward pagination
            if pagination_token.read().is_some() {
                div {
                    class: "timeline-panel__load-more",
                    if *is_loading_more.read() {
                        div { class: "spinner spinner--small" }
                        span { "Loading older messages..." }
                    } else {
                        button {
                            class: "timeline-panel__load-more-btn",
                            onclick: load_more,
                            "Load older messages"
                        }
                    }
                }
            }

            // Initial loading spinner
            if *is_loading.read() {
                div {
                    class: "timeline-panel__loading",
                    div { class: "spinner" }
                    span { "Loading messages..." }
                }
            }

            // Timeline items
            div {
                class: "timeline-panel__messages",

                if !*is_loading.read() && timeline_items.read().is_empty() {
                    div {
                        class: "timeline-panel__empty",
                        p { "No messages yet" }
                        p {
                            class: "timeline-panel__empty-hint",
                            "Start the conversation!"
                        }
                    }
                }

                {
                    let filter_lower = search_filter.to_lowercase();
                    let has_filter = !filter_lower.is_empty();
                    let items = timeline_items.read();
                    let filtered: Vec<(usize, &TimelineItemKind)> = if has_filter {
                        items.iter().enumerate().filter(|(_, item)| {
                            match item {
                                TimelineItemKind::Event(e) => {
                                    e.content.body_text().to_lowercase().contains(&filter_lower)
                                    || e.sender_display_name.to_lowercase().contains(&filter_lower)
                                }
                                _ => false,
                            }
                        }).collect()
                    } else {
                        items.iter().enumerate().collect()
                    };
                    let room_id_val = current_room.read().clone();
                    if has_filter && filtered.is_empty() && !items.is_empty() {
                        rsx! {
                            div {
                                class: "timeline-panel__no-results",
                                style: "padding: 24px; text-align: center; color: var(--text-secondary, #888);",
                                p { "No messages matching \"{search_filter}\"" }
                            }
                        }
                    } else {
                        rsx! {
                            for (idx, item) in filtered {
                                {render_timeline_item(item, idx, &room_id_val)}
                            }
                        }
                    }
                }
            }

            // Typing indicator
            TypingIndicator {
                room_id: current_room.read().clone(),
            }

            // Jump to bottom floating button
            {
                let unread = {
                    let s = state.read();
                    s.active_room_id.as_ref()
                        .and_then(|id| s.rooms.get(id))
                        .map(|r| r.unread_count)
                        .unwrap_or(0)
                };
                rsx! {
                    div {
                        class: "timeline-panel__jump-bottom-wrapper",
                        button {
                            class: "timeline-panel__jump-bottom",
                            title: "Jump to latest messages",
                            onclick: move |_| {
                                // Scroll to bottom via JS eval
                                spawn(async move {
                                    let _ = dioxus::prelude::document::eval(
                                        r#"
                                        let el = document.querySelector('.timeline-panel__messages');
                                        if (el) el.scrollTop = el.scrollHeight;
                                        "#,
                                    );
                                });
                            },
                            ""
                            if unread > 0 {
                                span {
                                    class: "timeline-panel__jump-badge",
                                    "{unread}"
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}