multiverse/widgets/room_view/
timeline.rs

1use 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    /// An index from a rendered list item to the original timeline item index
31    /// (since some timeline items may not be rendered).
32    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        }
147
148        TimelineItemKind::Virtual(virt) => match virt {
149            VirtualTimelineItem::DateDivider(unix_ts) => format!("Date: {unix_ts:?}").into(),
150            VirtualTimelineItem::ReadMarker => "Read marker".to_owned().into(),
151            VirtualTimelineItem::TimelineStart => "🥳 Timeline start! 🥳".to_owned().into(),
152        },
153    };
154
155    Some(item)
156}
157
158fn format_text_message(
159    sender: &str,
160    message: &Message,
161    thread_summary: Option<ThreadSummary>,
162    read_receipts: &IndexMap<OwnedUserId, Receipt>,
163) -> Option<ListItem<'static>> {
164    if let MessageType::Text(text) = message.msgtype() {
165        let mut lines = Vec::new();
166        let first_line = Line::from(format!("{}: {}", sender, text.body));
167
168        lines.push(first_line);
169
170        if let Some(thread_summary) = thread_summary {
171            match thread_summary.latest_event {
172                TimelineDetails::Unavailable | TimelineDetails::Pending => {
173                    let thread_line = Line::from("  💬 ...");
174                    lines.push(thread_line);
175                }
176                TimelineDetails::Ready(e) => {
177                    let profile_name = match e.sender_profile {
178                        TimelineDetails::Ready(profile) => profile.display_name,
179                        _ => None,
180                    };
181                    let sender = profile_name.as_deref().unwrap_or_else(|| e.sender.as_str());
182                    let content = e.content.as_message().map(|m| m.msgtype());
183
184                    if let Some(MessageType::Text(text)) = content {
185                        let replies = if thread_summary.num_replies == 1 {
186                            "1 reply".to_owned()
187                        } else {
188                            format!("{} replies", { thread_summary.num_replies })
189                        };
190                        let thread_line =
191                            Line::from(format!("  💬 {replies} {sender}: {}", text.body));
192
193                        lines.push(thread_line);
194                    }
195                }
196                TimelineDetails::Error(_) => {}
197            }
198        }
199
200        if !read_receipts.is_empty() {
201            // Read by [5 first users who read it], optionally followed by "and X others".
202            let mut read_by = read_receipts
203                .iter()
204                .take(5)
205                .map(|(user_id, _)| user_id.as_str())
206                .collect::<Vec<_>>()
207                .join(", ");
208            if read_receipts.len() > 5 {
209                let others_count = read_receipts.len() - 5;
210                if others_count == 1 {
211                    read_by.push_str(" and 1 other");
212                } else {
213                    read_by = format!("{read_by} and {others_count} others");
214                }
215            }
216            lines.push(Line::from(format!("  👀 read by {read_by}")));
217        }
218
219        Some(ListItem::from(lines))
220    } else {
221        None
222    }
223}
224
225fn format_membership_change(membership: &RoomMembershipChange) -> Option<ListItem<'static>> {
226    if let Some(change) = membership.change() {
227        let display_name =
228            membership.display_name().unwrap_or_else(|| membership.user_id().to_string());
229
230        let change = match change {
231            MembershipChange::Joined => "has joined the room",
232            MembershipChange::Left => "has left the room",
233            MembershipChange::Banned => "has been banned",
234            MembershipChange::Unbanned => "has been unbanned",
235            MembershipChange::Kicked => "has been kicked from the room",
236            MembershipChange::Invited => "has been invited to the room",
237            MembershipChange::KickedAndBanned => "has been kicked and banned from the room",
238            MembershipChange::InvitationAccepted => "has accepted the invitation to the room",
239            MembershipChange::InvitationRejected => "has rejected the invitation to the room",
240            MembershipChange::Knocked => "knocked on the room",
241            MembershipChange::KnockAccepted => "has accepted a knock on the room",
242            MembershipChange::KnockRetracted => "has retracted a knock on the room",
243            MembershipChange::KnockDenied => "has denied a knock",
244            MembershipChange::None
245            | MembershipChange::Error
246            | MembershipChange::InvitationRevoked
247            | MembershipChange::NotImplemented => "has changed its membership status",
248        };
249
250        Some(format!("{display_name} {change}").into())
251    } else {
252        None
253    }
254}