multiverse/widgets/room_view/
timeline.rs1use std::sync::Arc;
2
3use imbl::Vector;
4use indexmap::IndexMap;
5use matrix_sdk::ruma::{
6 OwnedUserId, UserId,
7 events::{receipt::Receipt, room::message::MessageType},
8};
9use matrix_sdk_ui::timeline::{
10 MembershipChange, Message, MsgLikeContent, MsgLikeKind, RoomMembershipChange, ThreadSummary,
11 TimelineDetails, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem,
12};
13use ratatui::{prelude::*, widgets::*};
14
15use crate::{ALT_ROW_COLOR, NORMAL_ROW_COLOR, SELECTED_STYLE_FG, TEXT_COLOR};
16
17pub struct TimelineView<'a> {
18 items: &'a Vector<Arc<TimelineItem>>,
19 is_thread: bool,
20}
21
22impl<'a> TimelineView<'a> {
23 pub fn new(items: &'a Vector<Arc<TimelineItem>>, is_thread: bool) -> Self {
24 Self { items, is_thread }
25 }
26}
27
28#[derive(Default)]
29pub struct TimelineListState {
30 state: ListState,
31 list_index_to_item_index: Vec<usize>,
34}
35
36impl TimelineListState {
37 pub fn select_next(&mut self) {
38 self.state.select_next();
39 }
40 pub fn select_previous(&mut self) {
41 self.state.select_previous();
42 }
43 pub fn unselect(&mut self) {
44 self.state.select(None);
45 }
46 pub fn selected(&self) -> Option<usize> {
47 let rendered_index = self.state.selected()?;
48 self.list_index_to_item_index.get(rendered_index).copied()
49 }
50}
51
52impl StatefulWidget for &mut TimelineView<'_> {
53 type State = TimelineListState;
54
55 fn render(self, area: Rect, buf: &mut Buffer, timeline_list_state: &mut Self::State)
56 where
57 Self: Sized,
58 {
59 timeline_list_state.list_index_to_item_index.clear();
60
61 let content = self.items.iter().enumerate().filter_map(|(i, item)| {
62 let result = format_timeline_item(item, self.is_thread)?;
63 timeline_list_state.list_index_to_item_index.push(i);
64 Some(result)
65 });
66
67 let list_items = content
68 .enumerate()
69 .map(|(i, line)| {
70 let bg_color = match i % 2 {
71 0 => NORMAL_ROW_COLOR,
72 _ => ALT_ROW_COLOR,
73 };
74
75 line.fg(TEXT_COLOR).bg(bg_color)
76 })
77 .collect::<Vec<_>>();
78
79 let list = List::new(list_items)
80 .highlight_spacing(HighlightSpacing::Always)
81 .highlight_symbol(">")
82 .highlight_style(SELECTED_STYLE_FG);
83
84 StatefulWidget::render(list, area, buf, &mut timeline_list_state.state);
85 }
86}
87
88fn format_timeline_item(item: &Arc<TimelineItem>, is_thread: bool) -> Option<ListItem<'_>> {
89 let item = match item.kind() {
90 TimelineItemKind::Event(ev) => {
91 let sender = ev.sender();
92
93 match ev.content() {
94 TimelineItemContent::MsgLike(MsgLikeContent {
95 kind: MsgLikeKind::Message(message),
96 ..
97 }) => {
98 let thread_summary =
99 if is_thread { None } else { ev.content().thread_summary() };
100 format_text_message(sender, message, thread_summary, ev.read_receipts())?
101 }
102
103 TimelineItemContent::MsgLike(MsgLikeContent {
104 kind: MsgLikeKind::Redacted,
105 ..
106 }) => format!("{sender}: -- redacted --").into(),
107
108 TimelineItemContent::MsgLike(MsgLikeContent {
109 kind: MsgLikeKind::UnableToDecrypt(_),
110 ..
111 }) => format!("{sender}: (UTD)").into(),
112
113 TimelineItemContent::MembershipChange(m) => format_membership_change(m)?,
114
115 TimelineItemContent::MsgLike(MsgLikeContent {
116 kind: MsgLikeKind::Sticker(_),
117 ..
118 })
119 | TimelineItemContent::ProfileChange(_)
120 | TimelineItemContent::OtherState(_)
121 | TimelineItemContent::FailedToParseMessageLike { .. }
122 | TimelineItemContent::FailedToParseState { .. }
123 | TimelineItemContent::MsgLike(MsgLikeContent {
124 kind: MsgLikeKind::Poll(_), ..
125 })
126 | TimelineItemContent::CallInvite
127 | TimelineItemContent::CallNotify => {
128 return None;
129 }
130 }
131 }
132
133 TimelineItemKind::Virtual(virt) => match virt {
134 VirtualTimelineItem::DateDivider(unix_ts) => format!("Date: {unix_ts:?}").into(),
135 VirtualTimelineItem::ReadMarker => "Read marker".to_owned().into(),
136 VirtualTimelineItem::TimelineStart => "🥳 Timeline start! 🥳".to_owned().into(),
137 },
138 };
139
140 Some(item)
141}
142
143fn format_text_message(
144 sender: &UserId,
145 message: &Message,
146 thread_summary: Option<ThreadSummary>,
147 read_receipts: &IndexMap<OwnedUserId, Receipt>,
148) -> Option<ListItem<'static>> {
149 if let MessageType::Text(text) = message.msgtype() {
150 let mut lines = Vec::new();
151 let first_line = Line::from(format!("{}: {}", sender, text.body));
152
153 lines.push(first_line);
154
155 if let Some(thread_summary) = thread_summary {
156 match thread_summary.latest_event {
157 TimelineDetails::Unavailable | TimelineDetails::Pending => {
158 let thread_line = Line::from(" 💬 ...");
159 lines.push(thread_line);
160 }
161 TimelineDetails::Ready(e) => {
162 let sender = e.sender;
163 let content = e.content.as_message().map(|m| m.msgtype());
164
165 if let Some(MessageType::Text(text)) = content {
166 let replies = if thread_summary.num_replies == 1 {
167 "1 reply".to_owned()
168 } else {
169 format!("{} replies", { thread_summary.num_replies })
170 };
171 let thread_line =
172 Line::from(format!(" 💬 {replies} {sender}: {}", text.body));
173
174 lines.push(thread_line);
175 }
176 }
177 TimelineDetails::Error(_) => {}
178 }
179 }
180
181 if !read_receipts.is_empty() {
182 let mut read_by = read_receipts
184 .iter()
185 .take(5)
186 .map(|(user_id, _)| user_id.as_str())
187 .collect::<Vec<_>>()
188 .join(", ");
189 if read_receipts.len() > 5 {
190 let others_count = read_receipts.len() - 5;
191 if others_count == 1 {
192 read_by.push_str(" and 1 other");
193 } else {
194 read_by = format!("{read_by} and {others_count} others");
195 }
196 }
197 lines.push(Line::from(format!(" 👀 read by {read_by}")));
198 }
199
200 Some(ListItem::from(lines))
201 } else {
202 None
203 }
204}
205
206fn format_membership_change(membership: &RoomMembershipChange) -> Option<ListItem<'static>> {
207 if let Some(change) = membership.change() {
208 let display_name =
209 membership.display_name().unwrap_or_else(|| membership.user_id().to_string());
210
211 let change = match change {
212 MembershipChange::Joined => "has joined the room",
213 MembershipChange::Left => "has left the room",
214 MembershipChange::Banned => "has been banned",
215 MembershipChange::Unbanned => "has been unbanned",
216 MembershipChange::Kicked => "has been kicked from the room",
217 MembershipChange::Invited => "has been invited to the room",
218 MembershipChange::KickedAndBanned => "has been kicked and banned from the room",
219 MembershipChange::InvitationAccepted => "has accepted the invitation to the room",
220 MembershipChange::InvitationRejected => "has rejected the invitation to the room",
221 MembershipChange::Knocked => "knocked on the room",
222 MembershipChange::KnockAccepted => "has accepted a knock on the room",
223 MembershipChange::KnockRetracted => "has retracted a knock on the room",
224 MembershipChange::KnockDenied => "has denied a knock",
225 MembershipChange::None
226 | MembershipChange::Error
227 | MembershipChange::InvitationRevoked
228 | MembershipChange::NotImplemented => "has changed its membership status",
229 };
230
231 Some(format!("{display_name} {change}").into())
232 } else {
233 None
234 }
235}