From a0f93a669435163d8bb10681a83015268055bb91 Mon Sep 17 00:00:00 2001 From: boxdot Date: Sat, 18 Mar 2023 09:22:39 +0100 Subject: [PATCH] Implement storing and rendering of mentions (#215) The placeholder obj tag sometimes has a space behind it and sometimes not. For now, we replace just the obj tag, which might have several spaces after it but it definitely works. Closes #136 --- CHANGELOG.md | 5 +- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 16 +++- src/data.rs | 55 ++++++++++++- src/signal/impl.rs | 2 + src/signal/test.rs | 1 + src/storage/json.rs | 3 + ..._json__tests__json_storage_data_model.snap | 6 +- src/ui/draw.rs | 81 +++++++++++++++++-- 10 files changed, 157 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9decbf5..88f1f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Added -- Copy selected message to clipboard ([#210]) +- Copy selected message to clipboard ([#210]) +- Implement storing and rendering of mentions ([#215, #136]) ### Changed @@ -17,6 +18,8 @@ [#203]: https://github.com/boxdot/gurk-rs/pull/203 [#204]: https://github.com/boxdot/gurk-rs/pull/204 [#210]: https://github.com/boxdot/gurk-rs/pull/210 +[#136]: https://github.com/boxdot/gurk-rs/pull/136 +[#215]: https://github.com/boxdot/gurk-rs/pull/215 ## 0.3.0 diff --git a/Cargo.lock b/Cargo.lock index 1166418..03b2124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1330,6 +1330,7 @@ dependencies = [ name = "gurk" version = "0.4.0-dev" dependencies = [ + "aho-corasick", "anyhow", "arboard", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index e46f634..cc1f6e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ tracing-subscriber = "0.3.16" futures-channel = "0.3.25" qr2term = "0.3.1" clap = { version = "4.0.19", features = ["derive"] } +aho-corasick = "0.7.19" # dev feature dependencies prost = { version = "0.10.4", optional = true } diff --git a/src/app.rs b/src/app.rs index 5078ba3..5116f22 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::channels::SelectChannel; use crate::config::Config; -use crate::data::{Channel, ChannelId, Message, TypingAction, TypingSet}; +use crate::data::{BodyRange, Channel, ChannelId, Message, TypingAction, TypingSet}; use crate::input::Input; use crate::receipt::{Receipt, ReceiptEvent, ReceiptHandler}; use crate::signal::{ @@ -440,6 +440,7 @@ impl App { mut body, attachments: attachment_pointers, sticker, + body_ranges, .. }), .. @@ -451,7 +452,9 @@ impl App { let attachments = self.save_attachments(attachment_pointers).await; add_emoji_from_sticker(&mut body, sticker); - let message = Message::new(user_id, body, timestamp, attachments); + let body_ranges = body_ranges.into_iter().filter_map(BodyRange::from_proto); + + let message = Message::new(user_id, body, body_ranges, timestamp, attachments); (channel_idx, message) } // Direct/group message by us from a different device @@ -477,6 +480,7 @@ impl App { quote, attachments: attachment_pointers, sticker, + body_ranges, .. }), .. @@ -514,9 +518,10 @@ impl App { add_emoji_from_sticker(&mut body, sticker); let quote = quote.and_then(Message::from_quote).map(Box::new); let attachments = self.save_attachments(attachment_pointers).await; + let body_ranges = body_ranges.into_iter().filter_map(BodyRange::from_proto); let message = Message { quote, - ..Message::new(user_id, body, timestamp, attachments) + ..Message::new(user_id, body, body_ranges, timestamp, attachments) }; (channel_idx, message) @@ -538,6 +543,7 @@ impl App { quote, attachments: attachment_pointers, sticker, + body_ranges, .. }), ) => { @@ -594,9 +600,10 @@ impl App { self.add_receipt_event(ReceiptEvent::new(uuid, timestamp, Receipt::Delivered)); let quote = quote.and_then(Message::from_quote).map(Box::new); + let body_ranges = body_ranges.into_iter().filter_map(BodyRange::from_proto); let message = Message { quote, - ..Message::new(uuid, body, timestamp, attachments) + ..Message::new(uuid, body, body_ranges, timestamp, attachments) }; if message.is_empty() { @@ -1395,6 +1402,7 @@ mod tests { attachments: Default::default(), reactions: Default::default(), receipt: Default::default(), + body_ranges: Default::default(), }, ); diff --git a/src/data.rs b/src/data.rs index a841f90..c12b8f6 100644 --- a/src/data.rs +++ b/src/data.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use anyhow::anyhow; -use presage::prelude::proto::data_message::Quote; +use presage::prelude::proto::data_message::{self, Quote}; use presage::prelude::{GroupMasterKey, GroupSecretParams}; use serde::{Deserialize, Serialize}; use tracing::error; @@ -128,12 +128,57 @@ pub struct Message { pub reactions: Vec<(Uuid, String)>, #[serde(default)] pub receipt: Receipt, + #[serde(default)] + pub(crate) body_ranges: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct BodyRange { + pub(crate) start: u16, + pub(crate) end: u16, + pub(crate) value: AssociatedValue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum AssociatedValue { + MentionUuid(Uuid), +} + +impl From<&BodyRange> for data_message::BodyRange { + fn from(range: &BodyRange) -> Self { + match range.value { + AssociatedValue::MentionUuid(id) => Self { + start: Some(range.start.into()), + length: Some((range.end - range.start).into()), + associated_value: Some(data_message::body_range::AssociatedValue::MentionUuid( + id.to_string(), + )), + }, + } + } +} + +impl BodyRange { + pub(crate) fn from_proto(proto: data_message::BodyRange) -> Option { + let value = match proto.associated_value? { + data_message::body_range::AssociatedValue::MentionUuid(uuid) => { + let uuid = uuid.parse().ok()?; + AssociatedValue::MentionUuid(uuid) + } + }; + Some(Self { + start: proto.start?.try_into().ok()?, + end: (proto.start? + proto.length?).try_into().ok()?, + value, + }) + } } impl Message { - pub fn new( + pub(crate) fn new( from_id: Uuid, message: Option, + body_ranges: impl IntoIterator, arrived_at: u64, attachments: Vec, ) -> Self { @@ -145,6 +190,7 @@ impl Message { attachments, reactions: Default::default(), receipt: Receipt::Sent, + body_ranges: body_ranges.into_iter().collect(), } } @@ -157,6 +203,11 @@ impl Message { attachments: Default::default(), reactions: Default::default(), receipt: Receipt::Sent, + body_ranges: quote + .body_ranges + .into_iter() + .filter_map(BodyRange::from_proto) + .collect(), }) } diff --git a/src/signal/impl.rs b/src/signal/impl.rs index ec64b76..c6db38a 100644 --- a/src/signal/impl.rs +++ b/src/signal/impl.rs @@ -144,6 +144,7 @@ impl SignalManager for PresageManager { id: Some(message.arrived_at), author_uuid: Some(message.from_id.to_string()), text: message.message.clone(), + body_ranges: message.body_ranges.iter().map(From::from).collect(), ..Default::default() }); let quote_message = quote.clone().and_then(Message::from_quote).map(Box::new); @@ -213,6 +214,7 @@ impl SignalManager for PresageManager { attachments: Default::default(), reactions: Default::default(), receipt: Receipt::Sent, + body_ranges: Default::default(), } } diff --git a/src/signal/test.rs b/src/signal/test.rs index 5316339..02f4ef4 100644 --- a/src/signal/test.rs +++ b/src/signal/test.rs @@ -103,6 +103,7 @@ impl SignalManager for SignalManagerMock { reactions: Default::default(), // TODO make sure the message sending procedure did not fail receipt: Receipt::Sent, + body_ranges: Default::default(), }; self.sent_messages.borrow_mut().push(message.clone()); message diff --git a/src/storage/json.rs b/src/storage/json.rs index 9e7af2b..f8ea91b 100644 --- a/src/storage/json.rs +++ b/src/storage/json.rs @@ -333,6 +333,7 @@ mod tests { attachments: Default::default(), reactions: Default::default(), receipt: Default::default(), + body_ranges: Default::default(), }], unread_messages: 1, typing: Some(TypingSet::SingleTyping(false)), @@ -349,6 +350,7 @@ mod tests { attachments: Default::default(), reactions: Default::default(), receipt: Default::default(), + body_ranges: Default::default(), }], unread_messages: 2, typing: Some(TypingSet::GroupTyping(Default::default())), @@ -496,6 +498,7 @@ mod tests { attachments: Default::default(), reactions: Default::default(), receipt: Default::default(), + body_ranges: Default::default(), }, ); diff --git a/src/storage/snapshots/gurk__storage__json__tests__json_storage_data_model.snap b/src/storage/snapshots/gurk__storage__json__tests__json_storage_data_model.snap index c5e35d7..281ce3f 100644 --- a/src/storage/snapshots/gurk__storage__json__tests__json_storage_data_model.snap +++ b/src/storage/snapshots/gurk__storage__json__tests__json_storage_data_model.snap @@ -19,7 +19,8 @@ expression: data "quote": null, "attachments": [], "reactions": [], - "receipt": "Nothing" + "receipt": "Nothing", + "body_ranges": [] } ], "unread_messages": 1 @@ -71,7 +72,8 @@ expression: data "quote": null, "attachments": [], "reactions": [], - "receipt": "Nothing" + "receipt": "Nothing", + "body_ranges": [] } ], "unread_messages": 2 diff --git a/src/ui/draw.rs b/src/ui/draw.rs index b6e1ae4..e2ac716 100644 --- a/src/ui/draw.rs +++ b/src/ui/draw.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::app::App; use crate::channels::SelectChannel; use crate::cursor::Cursor; -use crate::data::Message; +use crate::data::{AssociatedValue, Message}; use crate::receipt::{Receipt, ReceiptEvent}; use crate::shortcuts::{ShortCut, SHORTCUTS}; use crate::storage::MessageId; @@ -474,7 +474,8 @@ fn display_message( .subsequent_indent(prefix); // collect message text - let mut text = msg.message.clone().unwrap_or_default(); + let text = msg.message.clone().unwrap_or_default(); + let mut text = replace_mentions(msg, names, text); add_attachments(msg, &mut text); if text.is_empty() { return None; // no text => nothing to render @@ -543,6 +544,32 @@ fn display_message( Some(ListItem::new(Text::from(spans))) } +fn replace_mentions(msg: &Message, names: &NameResolver, text: String) -> String { + if msg.body_ranges.is_empty() { + return text; + } + + let ac = aho_corasick::AhoCorasickBuilder::new() + .build(std::iter::repeat("").take(msg.body_ranges.len())); + let mut buf = String::with_capacity(text.len()); + let mut ranges = msg.body_ranges.iter(); + ac.replace_all_with(&text, &mut buf, |_, _, dst| { + // TODO: check ranges? + if let Some(range) = ranges.next() { + let (name, _color) = match range.value { + AssociatedValue::MentionUuid(id) => names.resolve(id), + }; + dst.push('@'); + dst.push_str(&name); + true + } else { + false + } + }); + + buf +} + fn display_date_line( msg_timestamp: u64, previous_msg_day: &mut i32, @@ -668,7 +695,8 @@ fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { fn displayed_quote(names: &NameResolver, quote: &Message) -> Option { let (name, _) = names.resolve(quote.from_id); - Some(format!("({}) {}", name, quote.message.as_ref()?)) + let text = format!("({}) {}", name, quote.message.as_ref()?); + Some(replace_mentions(quote, names, text)) } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { @@ -699,6 +727,7 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { #[cfg(test)] mod tests { + use crate::data::{AssociatedValue, BodyRange}; use crate::signal::Attachment; use super::*; @@ -729,9 +758,10 @@ mod tests { message: None, arrived_at: 1642334397421, quote: None, - attachments: vec![], - reactions: vec![], + attachments: Default::default(), + reactions: Default::default(), receipt: Receipt::Sent, + body_ranges: Default::default(), } } @@ -914,4 +944,45 @@ mod tests { ])])); assert_eq!(rendered, Some(expected)); } + + #[test] + fn test_display_mention() { + let user_id = Uuid::from_u128(1); + let names = NameResolver::single_user(user_id, "boxdot".to_string(), Color::Green); + let msg = Message { + from_id: user_id, + message: Some("Mention  and even more  . End".into()), + receipt: Receipt::Read, + body_ranges: vec![ + BodyRange { + start: 8, + end: 9, + value: AssociatedValue::MentionUuid(user_id), + }, + BodyRange { + start: 25, + end: 26, + value: AssociatedValue::MentionUuid(user_id), + }, + ], + ..test_message() + }; + let show_receipt = ShowReceipt::from_msg(&msg, USER_ID, true); + let rendered = display_message(&names, &msg, PREFIX, WIDTH, HEIGHT, show_receipt); + + let expected = ListItem::new(Text::from(vec![ + Spans(vec![ + Span::styled(" ", Style::default().fg(Color::Yellow)), + Span::styled( + display_time(msg.arrived_at), + Style::default().fg(Color::Yellow), + ), + Span::styled("boxdot", Style::default().fg(Color::Green)), + Span::raw(": "), + Span::raw("Mention @boxdot and even more @boxdot ."), + ]), + Spans(vec![Span::raw(" End")]), + ])); + assert_eq!(rendered, Some(expected)); + } }