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