Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(chat): Fix user tagging in messages #1765

Merged
merged 22 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 4 additions & 45 deletions common/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ pub use chats::{Chat, Chats};
use dioxus_desktop::tao::window::WindowId;
pub use friends::Friends;
pub use identity::Identity;
use regex::Regex;
pub use route::Route;
pub use settings::Settings;
pub use ui::{Theme, ToastNotification, UI};
Expand Down Expand Up @@ -280,12 +279,7 @@ impl State {
m.set_conversation_id(id);
m.set_sender(sender);
m.set_lines(msg);
let m = ui_adapter::Message {
inner: m,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
};
let m = ui_adapter::Message::new(m, None, Uuid::new_v4().to_string());
self.add_msg_to_chat(id, m);
}
// ===== Media =====
Expand Down Expand Up @@ -452,13 +446,8 @@ impl State {
conversation_id,
mut message,
} => {
if let Some(ids) = self
.get_chat_by_id(conversation_id)
.map(|c| self.chat_participants(&c))
{
message.insert_did(&ids, &self.get_own_identity().did_key());
}
let ping = message.is_mention;
let own = self.get_own_identity().did_key();
let ping = message.is_mention_self(&own);
self.update_identity_status_hack(&message.inner.sender());
let id = self.identities.get(&message.inner.sender()).cloned();
// todo: don't load all the messages by default. if the user scrolled up, for example, this incoming message may not need to be fetched yet.
Expand Down Expand Up @@ -535,15 +524,6 @@ impl State {
self.update_identity_status_hack(&message.inner.sender());
let own = self.get_own_identity().did_key();
if let Some(chat) = self.chats.all.get_mut(&conversation_id) {
message.insert_did(
&chat
.participants
.iter()
.filter_map(|id| self.identities.get(id))
.cloned()
.collect::<Vec<_>>(),
&own,
);
let id = message.inner.id();
if let Some(msg) = chat.messages.iter_mut().find(|msg| msg.inner.id() == id) {
*msg = message.clone();
Expand All @@ -559,7 +539,7 @@ impl State {
*msg = message.inner.clone();
}

if message.is_mention {
if message.is_mention_self(&own) {
if let Some(msg) = chat.mentions.iter_mut().find(|m| m.inner.id() == id) {
*msg = message.clone();
}
Expand Down Expand Up @@ -1928,24 +1908,3 @@ pub fn pending_group_messages<'a>(
messages,
})
}

pub fn mention_regex_pattern(id: &Identity, username: bool) -> Regex {
Regex::new(&format!(
"(^| )@{}( |$)",
if username {
id.username()
} else {
id.did_key().to_string()
}
))
.unwrap()
}

pub fn mention_replacement_pattern(id: &Identity, visual: bool) -> String {
format!(
r#"<div class="message-user-tag {}" value="{}">@{}</div>"#,
if visual { "visual-only" } else { "" },
id.did_key(),
id.username()
)
}
14 changes: 2 additions & 12 deletions common/src/state/pending_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,7 @@ impl PendingMessage {
inner.set_id(m_id);
}
inner.set_lines(text);
let message = Message {
inner,
in_reply_to: None,
key: String::new(),
..Default::default()
};
let message = Message::new(inner, None, Uuid::new_v4().to_string());
PendingMessage {
attachments: attachments
.iter()
Expand Down Expand Up @@ -60,12 +55,7 @@ impl PendingMessage {
.cloned()
.collect::<Vec<_>>();

let message = Message {
inner,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
};
let message = Message::new(inner, None, Uuid::new_v4().to_string());
PendingMessage {
attachments: attachments
.iter()
Expand Down
113 changes: 112 additions & 1 deletion common/src/state/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ use std::{
path::{Path, PathBuf},
};

use once_cell::sync::Lazy;
use regex::{Captures, Regex, Replacer};
use titlecase::titlecase;
use tracing::log;
use uuid::Uuid;
use walkdir::WalkDir;
use warp::crypto::DID;

use crate::{get_extras_dir, STATIC_ARGS};

use super::{ui::Font, Theme};
use super::{ui::Font, Identity, State, Theme};

pub static USER_NAME_TAGS_REGEX: Lazy<Regex> =
Lazy::new(|| mention_regex_epattern("[A-z0-9]+#[A-z0-9]{8}"));
pub static USER_DID_TAGS_REGEX: Lazy<Regex> =
Lazy::new(|| mention_regex_epattern("did:key:[A-z0-9]{48}"));

pub fn get_available_themes() -> Vec<Theme> {
let mut themes = vec![];
Expand Down Expand Up @@ -96,6 +105,108 @@ pub fn get_available_fonts() -> Vec<Font> {
fonts
}

struct TagReplacer<'a, F: Fn(&Identity) -> String> {
participants: &'a [Identity],
own: &'a DID,
is_mention: bool,
is_username: bool,
replacement: F,
}

impl<F: Fn(&Identity) -> String> Replacer for TagReplacer<'_, F> {
fn replace_append(&mut self, caps: &Captures<'_>, dst: &mut String) {
if !caps[0].starts_with('`') {
let value = &caps[2];
let key = &value[1..];
if key.eq(&self.own.to_string()) {
self.is_mention = true;
}
dst.push_str(&caps[1]);
if let Some(id) = self.participants.iter().find(|id| {
if self.is_username {
let name = format!("{}#{}", id.username(), id.short_id());
name.eq(key)
} else {
id.did_key().to_string().eq(key)
}
}) {
dst.push_str(&(self.replacement)(id))
} else {
dst.push_str(value);
};
dst.push_str(&caps[3]);
} else {
dst.push_str(&caps[0]);
}
}
}

pub fn mention_regex_epattern(value: &str) -> Regex {
// This detects codeblocks
// When replacing this needs to be explicitly checked
let mut pattern = String::from(r#"(?:`{3}|`{1,2})+[^`]*(?:`{3}|`{1,2})"#);
// Second capture group contains the mention
// Since codeblocks are checked before they are basically "excluded"
// First and third are any leading/trailing whitespaces
pattern.push_str(&format!(r#"|(^|\s)(@{})($|\s)"#, value));
Regex::new(&pattern).unwrap()
}

pub fn parse_mention_state(
message: &str,
state: &State,
chat: Uuid,
replacement: impl Fn(&Identity) -> String,
) -> (String, bool) {
parse_mentions(
message,
&state
.get_chat_by_id(chat)
.map(|c| state.chat_participants(&c))
.unwrap_or_default(),
&state.did_key(),
false,
replacement,
)
}

// Parse a message replacing mentions with a given function
pub fn parse_mentions(
message: &str,
participants: &[Identity],
own: &DID,
is_username: bool,
replacement: impl Fn(&Identity) -> String,
) -> (String, bool) {
let mut replacer = TagReplacer {
participants,
own,
is_username,
is_mention: false,
replacement,
};
let result = if is_username {
USER_NAME_TAGS_REGEX.replace_all(message, replacer.by_ref())
} else {
USER_DID_TAGS_REGEX.replace_all(message, replacer.by_ref())
};
(result.to_string(), replacer.is_mention)
}

pub fn mention_to_did_key(id: &Identity) -> String {
format!("@{}", id.did_key())
}

// Replacement pattern converting a user tag to a highlight div
pub fn mention_replacement_pattern(id: &Identity, visual: bool) -> String {
format!(
r#"<div class="message-user-tag {}" value="{}">@{}</div>"#,
if visual { "visual-only" } else { "" },
id.did_key(),
id.username()
)
}

#[cfg(test)]
mod test {
use super::*;
Expand Down
18 changes: 6 additions & 12 deletions common/src/testing/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,11 @@ fn generate_fake_chat(participants: Vec<Identity>, conversation: Uuid) -> Chat {
default_message.set_sender(sender.did_key());
default_message.set_lines(vec![lipsum(word_count)]);

messages.push_back(ui_adapter::Message {
inner: default_message,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
});
messages.push_back(ui_adapter::Message::new(
default_message,
None,
Uuid::new_v4().to_string(),
));
}

let pinned_messages: Vec<_> = messages
Expand Down Expand Up @@ -258,12 +257,7 @@ fn generate_fake_message(conversation_id: Uuid, identities: &[Identity]) -> ui_a
default_message.set_replied(None);
default_message.set_lines(vec![text.into()]);

ui_adapter::Message {
inner: default_message,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
}
ui_adapter::Message::new(default_message, None, Uuid::new_v4().to_string())
}

fn generate_fake_storage() -> Storage {
Expand Down
56 changes: 23 additions & 33 deletions common/src/warp_runner/ui_adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub use multipass_event::{convert_multipass_event, MultiPassEvent};
pub use raygun_event::{convert_raygun_event, RayGunEvent};
use uuid::Uuid;

use crate::state::{self, chats, Identity, MAX_PINNED_MESSAGES};
use crate::state::{self, chats, utils::mention_regex_epattern, Identity, MAX_PINNED_MESSAGES};
use futures::{stream::FuturesOrdered, FutureExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::{
Expand All @@ -39,49 +39,39 @@ use super::{
pub struct Message {
pub inner: warp::raygun::Message,
pub in_reply_to: Option<(String, Vec<File>, DID)>,
pub lines_to_render: Option<String>,
pub is_mention: bool,
is_mention: Option<bool>,
/// this field exists so that the UI can tell Dioxus when a message has been edited and thus
/// needs to be re-rendered. Before the addition of this field, the compose view was
/// using the message Uuid, but this doesn't change when a message is edited.
pub key: String,
}

impl Message {
// resolve the message lines to e.g. format user mentions correctly
pub fn insert_did(&mut self, participants: &[state::Identity], own: &DID) {
if self.lines_to_render.is_some() {
return;
pub fn new(
inner: warp::raygun::Message,
in_reply_to: Option<(String, Vec<File>, DID)>,
key: String,
) -> Message {
Message {
inner,
in_reply_to,
key,
..Default::default()
}
// Better if warp provides a way of saving mentions in the message
// so dont need to loop over all participants
let lines = self.inner.lines().join("\n");
let (lines, is_mention) = format_mentions(lines, participants, own, false);
self.is_mention = is_mention;
self.lines_to_render = Some(lines);
}
}

pub fn format_mentions(
message: String,
participants: &[state::Identity],
own: &DID,
visual: bool,
) -> (String, bool) {
if !message.contains('@') {
return (message, false);
}
let mut result = message;
let mut is_mention = false;
participants.iter().for_each(|id| {
let reg = state::mention_regex_pattern(id, false);
let replaced = reg.replace_all(&result, state::mention_replacement_pattern(id, visual));
if own.eq(&id.did_key()) && !replaced.eq(&result) {
is_mention = true;
// Lazily evaluate if the user is mentioned
pub fn is_mention_self(&mut self, own: &DID) -> bool {
if self.is_mention.is_none() {
let reg = mention_regex_epattern(&own.to_string());
self.is_mention = Some(
reg.find(&self.inner.lines().join("\n"))
.map(|c| !c.as_str().starts_with('`'))
.unwrap_or_default(),
);
dariusc93 marked this conversation as resolved.
Show resolved Hide resolved
}
result = replaced.to_string();
});
(result, is_mention)
self.is_mention.unwrap()
}
}

#[derive(Clone)]
Expand Down
Loading
Loading