synpad 0.1.0

A full-featured Matrix chat client built with Dioxus
use dioxus::prelude::*;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContentWithoutRelation;
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};

use crate::state::app_state::{AppState, RightPanelView};

/// Panel showing a thread and allowing replies within it.
#[component]
pub fn ThreadPanel(thread_root_event_id: String) -> Element {
    let mut state = use_context::<Signal<AppState>>();
    let mut reply_text = use_signal(|| String::new());
    let mut is_sending = use_signal(|| false);
    let mut send_error = use_signal(|| Option::<String>::None);
    let mut thread_replies = use_signal(Vec::<ThreadReplyItem>::new);
    let mut is_loading = use_signal(|| true);

    let active_room_id = state.read().active_room_id.as_ref().map(|id| id.to_string()).unwrap_or_default();
    let root_eid = thread_root_event_id.clone();

    // Load thread replies on mount
    let load_room_id = active_room_id.clone();
    let load_event_id = thread_root_event_id.clone();
    use_effect(move || {
        let rid = load_room_id.clone();
        let eid = load_event_id.clone();
        spawn(async move {
            let client = { state.read().client.clone() };
            let Some(client) = client else {
                is_loading.set(false);
                return;
            };
            let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(rid.as_str()) else {
                is_loading.set(false);
                return;
            };
            let Some(room) = client.get_room(&room_id) else {
                is_loading.set(false);
                return;
            };

            // Try loading thread messages via room messages API
            // For now, we load recent messages and filter for thread-related ones
            let options = matrix_sdk::room::MessagesOptions::backward();
            let response = room.messages(options).await;
            if let Ok(resp) = response {
                let _own_user_id = client.user_id().map(|u| u.to_string()).unwrap_or_default();
                let mut replies = Vec::new();
                for event in &resp.chunk {
                    if let Some(json) = event_to_json(event) {
                        // Check if this event is part of our thread
                        let relates_to = json.get("content")
                            .and_then(|c| c.get("m.relates_to"));
                        let is_thread_reply = relates_to
                            .and_then(|r| r.get("rel_type"))
                            .and_then(|t| t.as_str())
                            .map_or(false, |t| t == "m.thread")
                            && relates_to
                                .and_then(|r| r.get("event_id"))
                                .and_then(|e| e.as_str())
                                .map_or(false, |e| e == eid);

                        // Also include the root event itself
                        let is_root = json.get("event_id")
                            .and_then(|e| e.as_str())
                            .map_or(false, |e| e == eid);

                        if is_thread_reply || is_root {
                            let sender = json.get("sender").and_then(|s| s.as_str()).unwrap_or("Unknown").to_string();
                            let body = json.get("content")
                                .and_then(|c| c.get("body"))
                                .and_then(|b| b.as_str())
                                .unwrap_or("")
                                .to_string();
                            let timestamp = json.get("origin_server_ts").and_then(|t| t.as_u64()).unwrap_or(0);
                            let event_id = json.get("event_id").and_then(|e| e.as_str()).unwrap_or("").to_string();
                            replies.push(ThreadReplyItem {
                                event_id,
                                sender,
                                body,
                                timestamp,
                                is_root,
                            });
                        }
                    }
                }
                // Sort by timestamp (oldest first)
                replies.sort_by_key(|r| r.timestamp);
                thread_replies.set(replies);
            }
            is_loading.set(false);
        });
    });

    let close_panel = move |_| {
        state.write().right_panel = RightPanelView::Closed;
    };

    let send_room_id = active_room_id.clone();
    let send_root_id = root_eid.clone();
    let on_send = move |_| {
        let text = reply_text.read().clone();
        if text.trim().is_empty() {
            return;
        }
        is_sending.set(true);
        send_error.set(None);
        let rid = send_room_id.clone();
        let root_eid = send_root_id.clone();

        spawn(async move {
            let client = { state.read().client.clone() };
            let Some(client) = client else {
                send_error.set(Some("Not logged in".to_string()));
                is_sending.set(false);
                return;
            };
            let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(rid.as_str()) else {
                send_error.set(Some("Invalid room ID".to_string()));
                is_sending.set(false);
                return;
            };
            let Ok(root_event_id) = <&str as TryInto<OwnedEventId>>::try_into(root_eid.as_str()) else {
                send_error.set(Some("Invalid event ID".to_string()));
                is_sending.set(false);
                return;
            };
            let Some(room) = client.get_room(&room_id) else {
                send_error.set(Some("Room not found".to_string()));
                is_sending.set(false);
                return;
            };

            let content = RoomMessageEventContentWithoutRelation::text_plain(&text);
            let reply = matrix_sdk::room::reply::Reply {
                event_id: root_event_id,
                enforce_thread: matrix_sdk::room::reply::EnforceThread::MaybeThreaded,
            };

            match room.make_reply_event(content, reply).await {
                Ok(reply_content) => {
                    match room.send(reply_content).await {
                        Ok(_) => {
                            tracing::info!("Sent thread reply");
                            reply_text.set(String::new());
                            // Add optimistic reply
                            let sender = client.user_id().map(|u| u.to_string()).unwrap_or_default();
                            let mut replies = thread_replies.write();
                            replies.push(ThreadReplyItem {
                                event_id: String::new(),
                                sender,
                                body: text.clone(),
                                timestamp: std::time::SystemTime::now()
                                    .duration_since(std::time::UNIX_EPOCH)
                                    .map(|d| d.as_millis() as u64)
                                    .unwrap_or(0),
                                is_root: false,
                            });
                        }
                        Err(e) => {
                            send_error.set(Some(format!("Failed to send: {e}")));
                        }
                    }
                }
                Err(e) => {
                    send_error.set(Some(format!("Failed to create reply: {e}")));
                }
            }
            is_sending.set(false);
        });
    };

    let on_keydown = move |evt: Event<KeyboardData>| {
        if evt.key() == Key::Enter && !evt.modifiers().shift() {
            evt.prevent_default();
            let text = reply_text.read().clone();
            if text.trim().is_empty() {
                return;
            }
            is_sending.set(true);
            send_error.set(None);
            let rid = active_room_id.clone();
            let root_eid = root_eid.clone();

            spawn(async move {
                let client = { state.read().client.clone() };
                let Some(client) = client else {
                    is_sending.set(false);
                    return;
                };
                let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(rid.as_str()) else {
                    is_sending.set(false);
                    return;
                };
                let Ok(root_event_id) = <&str as TryInto<OwnedEventId>>::try_into(root_eid.as_str()) else {
                    is_sending.set(false);
                    return;
                };
                let Some(room) = client.get_room(&room_id) else {
                    is_sending.set(false);
                    return;
                };

                let content = RoomMessageEventContentWithoutRelation::text_plain(&text);
                let reply = matrix_sdk::room::reply::Reply {
                    event_id: root_event_id,
                    enforce_thread: matrix_sdk::room::reply::EnforceThread::MaybeThreaded,
                };

                match room.make_reply_event(content, reply).await {
                    Ok(reply_content) => {
                        if let Ok(_) = room.send(reply_content).await {
                            reply_text.set(String::new());
                            let sender = client.user_id().map(|u| u.to_string()).unwrap_or_default();
                            let mut replies = thread_replies.write();
                            replies.push(ThreadReplyItem {
                                event_id: String::new(),
                                sender,
                                body: text.clone(),
                                timestamp: std::time::SystemTime::now()
                                    .duration_since(std::time::UNIX_EPOCH)
                                    .map(|d| d.as_millis() as u64)
                                    .unwrap_or(0),
                                is_root: false,
                            });
                        }
                    }
                    Err(_) => {}
                }
                is_sending.set(false);
            });
        }
    };

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

            // Header
            header {
                class: "thread-panel__header",
                style: "display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-color, #333);",
                h3 {
                    style: "margin: 0; font-size: 16px;",
                    "Thread"
                }
                button {
                    style: "background: none; border: none; color: var(--text-secondary, #888); cursor: pointer; font-size: 18px;",
                    title: "Close thread",
                    onclick: close_panel,
                    ""
                }
            }

            // Thread messages
            div {
                class: "thread-panel__messages",
                style: "flex: 1; overflow-y: auto; padding: 12px 16px;",

                if *is_loading.read() {
                    div {
                        style: "text-align: center; padding: 24px; color: var(--text-secondary, #888);",
                        div { class: "spinner spinner--small" }
                        p { "Loading thread..." }
                    }
                }

                for reply in thread_replies.read().iter() {
                    {
                        let sender = reply.sender.clone();
                        let body = reply.body.clone();
                        let is_root = reply.is_root;
                        let time = crate::utils::time_format::format_time(reply.timestamp);
                        let root_style = if is_root {
                            "padding: 10px 12px; margin-bottom: 8px; background: var(--bg-secondary, #1a1a2e); border-radius: 8px; border-left: 3px solid var(--accent-color, #4a9eff);"
                        } else {
                            "padding: 10px 12px; margin-bottom: 4px; border-radius: 6px;"
                        };
                        rsx! {
                            div {
                                class: "thread-panel__message",
                                style: "{root_style}",
                                div {
                                    style: "display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px;",
                                    span {
                                        style: "font-weight: 600; font-size: 13px; color: var(--text-primary, #e0e0e0);",
                                        "{sender}"
                                    }
                                    span {
                                        style: "font-size: 11px; color: var(--text-secondary, #888);",
                                        "{time}"
                                    }
                                    if is_root {
                                        span {
                                            style: "font-size: 11px; color: var(--accent-color, #4a9eff); font-weight: 500;",
                                            "Thread root"
                                        }
                                    }
                                }
                                p {
                                    style: "margin: 0; font-size: 14px; color: var(--text-primary, #e0e0e0);",
                                    "{body}"
                                }
                            }
                        }
                    }
                }

                if !*is_loading.read() && thread_replies.read().is_empty() {
                    div {
                        style: "text-align: center; padding: 24px; color: var(--text-secondary, #888);",
                        p { "No replies in this thread yet." }
                        p { style: "font-size: 13px;", "Be the first to reply!" }
                    }
                }
            }

            // Error display
            if let Some(ref err) = *send_error.read() {
                div {
                    style: "padding: 8px 16px; background: var(--error-bg, #3a1a1a); color: var(--error-text, #f44336); font-size: 13px;",
                    "{err}"
                }
            }

            // Thread reply input
            div {
                class: "thread-panel__composer",
                style: "display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border-color, #333);",
                textarea {
                    style: "flex: 1; padding: 8px 10px; border: 1px solid var(--border-color, #333); border-radius: 6px; background: var(--bg-primary, #0d0d1a); color: var(--text-primary, #e0e0e0); font-size: 14px; resize: none; font-family: inherit;",
                    placeholder: "Reply in thread...",
                    value: "{reply_text}",
                    oninput: move |evt| reply_text.set(evt.value()),
                    onkeydown: on_keydown,
                    disabled: *is_sending.read(),
                    rows: "2",
                }
                button {
                    style: "padding: 8px 14px; border: none; border-radius: 6px; background: var(--accent-color, #4a9eff); color: white; cursor: pointer; font-size: 14px; align-self: flex-end;",
                    disabled: reply_text.read().trim().is_empty() || *is_sending.read(),
                    onclick: on_send,
                    "Send"
                }
            }
        }
    }
}

/// A thread reply item for display.
#[derive(Clone, Debug)]
struct ThreadReplyItem {
    event_id: String,
    sender: String,
    body: String,
    timestamp: u64,
    is_root: bool,
}

/// Extract raw JSON from a TimelineEvent.
fn event_to_json(
    event: &matrix_sdk::deserialized_responses::TimelineEvent,
) -> Option<serde_json::Value> {
    let raw = event.raw();
    serde_json::from_str(raw.json().get()).ok()
}