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;
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" }
}
},
}
}
#[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);
let mut current_room = use_signal(|| room_id.clone());
if *current_room.read() != room_id {
current_room.set(room_id.clone());
timeline_items.set(Vec::new());
pagination_token.set(None);
is_loading.set(true);
}
let sync_gen = use_memo(move || state.read().sync_generation);
let has_client = use_memo(move || state.read().client.is_some());
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);
});
});
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)) => {
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",
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"
}
}
}
}
if *is_loading.read() {
div {
class: "timeline-panel__loading",
div { class: "spinner" }
span { "Loading messages..." }
}
}
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)}
}
}
}
}
}
TypingIndicator {
room_id: current_room.read().clone(),
}
{
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 |_| {
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}"
}
}
}
}
}
}
}
}
}