use dioxus::prelude::*;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContentWithoutRelation;
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId};
use crate::state::app_state::{AppState, RightPanelView};
#[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();
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;
};
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) {
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);
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,
});
}
}
}
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());
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 {
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,
"✕"
}
}
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!" }
}
}
}
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}"
}
}
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"
}
}
}
}
}
#[derive(Clone, Debug)]
struct ThreadReplyItem {
event_id: String,
sender: String,
body: String,
timestamp: u64,
is_root: bool,
}
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()
}