Skip to content

Commit

Permalink
feat(chat): User mentioning (#1543)
Browse files Browse the repository at this point in the history
Co-authored-by: Flemmli97 <Flemmli97@users.noreply.github.com>
Co-authored-by: sdwoodbury <stuartwoodbury@protonmail.ch>
Co-authored-by: Phill Wisniewski <93608357+phillsatellite@users.noreply.github.com>
  • Loading branch information
4 people authored Dec 1, 2023
1 parent 6604426 commit 761814f
Show file tree
Hide file tree
Showing 25 changed files with 554 additions and 172 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ humansize = { workspace = true }
zip = "0.6.4"
walkdir = { workspace = true }
extensions = { workspace = true }
regex = { workspace = true }

futures = { workspace = true }
# do we really want to pull in all of tokio?
Expand Down
5 changes: 3 additions & 2 deletions common/locales/en-US/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ messages = Messages
.pinned-max = You reached the maximum amount of pinned messages for this chat
.missing-emoji-picker = Emoji extension is disabled
.unknown-identity = User not found:
.suggested-emoji = Suggested Emoji
.emoji-suggestion = Suggested Emoji
.username-suggestion = Suggested Users
favorites = Favorites
.favorites = Favorites
.add = Add to Favorites
Expand Down
2 changes: 1 addition & 1 deletion common/src/state/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ pub enum Action<'a> {
ClearAllPopoutWindows(DesktopContext),
// Notifications
#[display(fmt = "AddNotification")]
AddNotification(NotificationKind, u32),
AddNotification(NotificationKind, u32, bool),
#[display(fmt = "RemoveNotification")]
RemoveNotification(NotificationKind, u32),
#[display(fmt = "ClearNotification")]
Expand Down
5 changes: 5 additions & 0 deletions common/src/state/chats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct Chat {
// Unread count for this chat, should be cleared when we view the chat.
#[serde(default)]
unreads: HashSet<Uuid>,
// This tracks the messages that mentions the user. For future use
// E.g. displaying a list of mentions to the user in a pop up
#[serde(default, skip)]
pub mentions: VecDeque<ui_adapter::Message>,
// If a value exists, we will render the message we're replying to above the chatbar
#[serde(skip)]
pub replying_to: Option<raygun::Message>,
Expand Down Expand Up @@ -87,6 +91,7 @@ impl Default for Chat {
creator: Default::default(),
messages: Default::default(),
unreads: Default::default(),
mentions: Default::default(),
replying_to: Default::default(),
typing_indicator: Default::default(),
draft: Default::default(),
Expand Down
78 changes: 61 additions & 17 deletions common/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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 @@ -62,7 +63,6 @@ use self::ui::{Font, Layout};
use self::utils::get_available_themes;

pub const MAX_PINNED_MESSAGES: u8 = 100;

// todo: create an Identity cache and only store UUID in state.friends and state.chats
// store the following information in the cache: key: DID, value: { Identity, HashSet<UUID of conversations this identity is participating in> }
// the HashSet would be used to determine when to evict an identity. (they are not participating in any conversations and are not a friend)
Expand Down Expand Up @@ -174,11 +174,11 @@ impl State {
}
}
// ===== Notifications =====
Action::AddNotification(kind, count) => self.ui.notifications.increment(
Action::AddNotification(kind, count, forced) => self.ui.notifications.increment(
&self.configuration,
kind,
count,
!self.ui.metadata.focused,
forced || !self.ui.metadata.focused,
),
Action::RemoveNotification(kind, count) => self.ui.notifications.decrement(kind, count),
Action::ClearNotification(kind) => self.ui.notifications.clear_kind(kind),
Expand Down Expand Up @@ -281,6 +281,7 @@ impl State {
inner: m,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
};
self.add_msg_to_chat(id, m);
}
Expand Down Expand Up @@ -356,6 +357,7 @@ impl State {
self.mutate(Action::AddNotification(
notifications::NotificationKind::FriendRequest,
1,
false,
));

// TODO: Get state available in this scope.
Expand Down Expand Up @@ -436,8 +438,15 @@ impl State {
match event {
MessageEvent::Received {
conversation_id,
message,
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;
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 All @@ -450,6 +459,7 @@ impl State {
self.mutate(Action::AddNotification(
notifications::NotificationKind::Message,
1,
ping,
));

// Dispatch notifications only when we're not already focused on the application.
Expand Down Expand Up @@ -508,15 +518,22 @@ impl State {
}
MessageEvent::Edited {
conversation_id,
message,
mut message,
} => {
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) {
if let Some(msg) = chat
.messages
.iter_mut()
.find(|msg| msg.inner.id() == message.inner.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 @@ -526,12 +543,16 @@ impl State {
}
}

if let Some(msg) = chat
.pinned_messages
.iter_mut()
.find(|m| m.id() == message.inner.id())
{
*msg = message.inner;
if let Some(msg) = chat.pinned_messages.iter_mut().find(|m| m.id() == id) {
*msg = message.inner.clone();
}

if message.is_mention {
if let Some(msg) = chat.mentions.iter_mut().find(|m| m.inner.id() == id) {
*msg = message.clone();
}
} else {
chat.mentions.retain(|m| m.inner.id() != id);
}
}
}
Expand All @@ -548,6 +569,7 @@ impl State {
}
chat.messages.retain(|msg| msg.inner.id() != message_id);
chat.pinned_messages.retain(|msg| msg.id() != message_id);
chat.mentions.retain(|msg| msg.inner.id() != message_id);

if let Some(msg) = most_recent_message {
if chat.messages.is_empty() {
Expand Down Expand Up @@ -888,7 +910,8 @@ impl State {
let is_active_scrolled = self.chats.active_chat_is_scrolled();
if let Some(chat) = self.chats.all.get_mut(&conversation_id) {
chat.typing_indicator.remove(&message.inner.sender());
chat.messages.push_back(message);
chat.messages.push_back(message.clone());
chat.mentions.push_back(message);
// only care about the most recent message, for the sidebar
if chat.messages.len() > 1 {
chat.messages.pop_front();
Expand Down Expand Up @@ -1868,3 +1891,24 @@ 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()
)
}
2 changes: 2 additions & 0 deletions common/src/state/pending_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ impl PendingMessage {
inner,
in_reply_to: None,
key: String::new(),
..Default::default()
};
PendingMessage {
attachments: attachments
Expand Down Expand Up @@ -63,6 +64,7 @@ impl PendingMessage {
inner,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
};
PendingMessage {
attachments: attachments
Expand Down
2 changes: 2 additions & 0 deletions common/src/testing/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ fn generate_fake_chat(participants: Vec<Identity>, conversation: Uuid) -> Chat {
inner: default_message,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
});
}

Expand Down Expand Up @@ -263,6 +264,7 @@ fn generate_fake_message(conversation_id: Uuid, identities: &[Identity]) -> ui_a
inner: default_message,
in_reply_to: None,
key: Uuid::new_v4().to_string(),
..Default::default()
}
}

Expand Down
48 changes: 44 additions & 4 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, MAX_PINNED_MESSAGES};
use crate::state::{self, chats, Identity, MAX_PINNED_MESSAGES};
use futures::{stream::FuturesOrdered, FutureExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::{
Expand All @@ -24,7 +24,7 @@ use warp::{
crypto::DID,
error::Error,
logging::tracing::log,
multipass::identity::{Identifier, Identity, Platform},
multipass::identity::{Identifier, Platform},
raygun::{self, Conversation, MessageOptions},
};

Expand All @@ -34,16 +34,55 @@ use super::{

/// the UI needs additional information for message replies, namely the text of the message being replied to.
/// fetch that before sending the message to the UI.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
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,
/// 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;
}
// 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;
}
result = replaced.to_string();
});
(result, is_mention)
}

#[derive(Clone)]
pub struct ChatAdapter {
pub inner: chats::Chat,
Expand All @@ -70,6 +109,7 @@ pub async fn convert_raygun_message(
)
}),
key: Uuid::new_v4().to_string(),
..Default::default()
}
}

Expand All @@ -91,7 +131,7 @@ pub fn get_uninitialized_identity(did: &DID) -> Result<state::Identity, Error> {
.try_into()
.map_err(|_e| warp::error::Error::OtherWithContext("did to short".into()))?;
default.set_short_id(short);
Ok(state::Identity::from(default))
Ok(default)
}

// this function is used in response to warp events. assuming that the DID from these events is valid.
Expand Down
Loading

0 comments on commit 761814f

Please sign in to comment.