[fractal] room-history: Implement mention of users in the message entry
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal] room-history: Implement mention of users in the message entry
- Date: Thu, 15 Sep 2022 10:23:17 +0000 (UTC)
commit f8e9147f7da5594836655fcb08e5afaa14556c7e
Author: Kévin Commaille <zecakeh tedomum fr>
Date: Fri Jul 15 11:06:14 2022 +0200
room-history: Implement mention of users in the message entry
Show a popover triggered by the character `@` or the `Tab` key.
Cargo.lock | 21 +
Cargo.toml | 2 +
data/resources/resources.gresource.xml | 2 +
data/resources/style.css | 26 +
data/resources/ui/content-completion-popover.ui | 24 +
data/resources/ui/content-completion-row.ui | 42 +
.../room_history/completion/completion_popover.rs | 849 +++++++++++++++++++++
.../room_history/completion/completion_row.rs | 125 +++
src/session/content/room_history/completion/mod.rs | 5 +
src/session/content/room_history/mod.rs | 48 +-
src/session/room/member_list.rs | 5 +-
src/session/room/mod.rs | 2 +-
12 files changed, 1146 insertions(+), 5 deletions(-)
---
diff --git a/Cargo.lock b/Cargo.lock
index 40a965786..6964af93d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1062,11 +1062,13 @@ dependencies = [
"mime_guess",
"num_enum",
"once_cell",
+ "pulldown-cmark",
"qrcode",
"rand 0.8.5",
"regex",
"rqrr",
"ruma",
+ "secular",
"serde",
"serde_json",
"sourceview5",
@@ -1380,6 +1382,15 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "getopts"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
+dependencies = [
+ "unicode-width",
+]
+
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -3365,6 +3376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63"
dependencies = [
"bitflags",
+ "getopts",
"memchr",
"unicase",
]
@@ -3761,6 +3773,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+[[package]]
+name = "secular"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3dc3eccdf599b53eba8a34a1190bd47394948258d1c43dca9cceb2426e25bb5"
+dependencies = [
+ "unicode-normalization",
+]
+
[[package]]
name = "security-framework"
version = "2.7.0"
diff --git a/Cargo.toml b/Cargo.toml
index d053f37ea..561777613 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -50,6 +50,8 @@ mime_guess = "2.0.3"
num_enum = "0.5.6"
thiserror = "1.0.25"
rqrr = "0.4.0"
+secular = { version = "1.0.1", features = ["bmp", "normalization"] }
+pulldown-cmark = "0.9.2"
[dependencies.sourceview]
package = "sourceview5"
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 2346a57a2..2339f435d 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -43,6 +43,8 @@
<file compressed="true" preprocess="xml-stripblanks"
alias="components-password-entry-row.ui">ui/components-password-entry-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-reaction-chooser.ui">ui/components-reaction-chooser.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-video-player.ui">ui/components-video-player.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="content-completion-popover.ui">ui/content-completion-popover.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="content-completion-row.ui">ui/content-completion-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-divider-row.ui">ui/content-divider-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-explore-item.ui">ui/content-explore-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-explore.ui">ui/content-explore.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index 73ee84b23..c5727594a 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -496,6 +496,32 @@ message-reactions .reaction-count {
color: @view_fg_color;
}
+.completion-popover contents {
+ padding: 0;
+}
+
+.completion-popover viewport {
+ padding: 8px;
+}
+
+.completion-popover list {
+ background-color: transparent;
+}
+
+.completion-popover .completion-row {
+ border-radius: 6px;
+ margin: 3px 0px;
+ padding: 6px;
+}
+
+.completion-popover .completion-row:first-child {
+ margin-top: 0px;
+}
+
+.completion-popover .completion-row:last-child {
+ margin-bottom: 0px;
+}
+
/* Event Source Dialog */
diff --git a/data/resources/ui/content-completion-popover.ui b/data/resources/ui/content-completion-popover.ui
new file mode 100644
index 000000000..b18caba84
--- /dev/null
+++ b/data/resources/ui/content-completion-popover.ui
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="ContentCompletionPopover" parent="GtkPopover">
+ <style>
+ <class name="completion-popover"/>
+ </style>
+ <property name="autohide">false</property>
+ <property name="has-arrow">false</property>
+ <property name="position">top</property>
+ <property name="halign">start</property>
+ <property name="valign">center</property>
+ <property name="width-request">260</property>
+ <property name="child">
+ <object class="GtkScrolledWindow" id="scrolled_window">
+ <property name="propagate-natural-height">true</property>
+ <property name="hscrollbar-policy">never</property>
+ <property name="max-content-height">280</property>
+ <property name="child">
+ <object class="GtkListBox" id="list"/>
+ </property>
+ </object>
+ </property>
+ </template>
+</interface>
diff --git a/data/resources/ui/content-completion-row.ui b/data/resources/ui/content-completion-row.ui
new file mode 100644
index 000000000..ff77e0e6b
--- /dev/null
+++ b/data/resources/ui/content-completion-row.ui
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="ContentCompletionRow" parent="GtkListBoxRow">
+ <style>
+ <class name="completion-row"/>
+ </style>
+ <child>
+ <object class="GtkBox">
+ <property name="spacing">10</property>
+ <child>
+ <object class="ComponentsAvatar" id="avatar">
+ <property name="size">40</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="spacing">3</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="display_name">
+ <property name="xalign">0.0</property>
+ <property name="hexpand">True</property>
+ <property name="ellipsize">end</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkLabel" id="id">
+ <property name="xalign">0.0</property>
+ <property name="hexpand">True</property>
+ <property name="ellipsize">end</property>
+ <style>
+ <class name="dim-label"/>
+ <class name="caption"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </template>
+</interface>
diff --git a/src/session/content/room_history/completion/completion_popover.rs
b/src/session/content/room_history/completion/completion_popover.rs
new file mode 100644
index 000000000..bd99545e5
--- /dev/null
+++ b/src/session/content/room_history/completion/completion_popover.rs
@@ -0,0 +1,849 @@
+use gtk::{
+ gdk, glib,
+ glib::{clone, closure},
+ prelude::*,
+ subclass::prelude::*,
+ CompositeTemplate,
+};
+use pulldown_cmark::{Event, Parser, Tag};
+use ruma::OwnedUserId;
+use secular::lower_lay_string;
+
+use super::CompletionRow;
+use crate::{
+ components::Pill,
+ prelude::*,
+ session::room::{Member, MemberList, Membership},
+};
+
+const MAX_MEMBERS: usize = 32;
+
+#[derive(Debug, Default)]
+pub struct MemberWatch {
+ handlers: Vec<glib::SignalHandlerId>,
+ // The position of the member in the list model.
+ position: u32,
+}
+
+mod imp {
+ use std::{
+ cell::{Cell, RefCell},
+ collections::HashMap,
+ };
+
+ use glib::subclass::InitializingObject;
+ use once_cell::sync::Lazy;
+
+ use super::*;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/Fractal/content-completion-popover.ui")]
+ pub struct CompletionPopover {
+ #[template_child]
+ pub list: TemplateChild<gtk::ListBox>,
+ /// The user ID of the current session.
+ pub user_id: RefCell<Option<String>>,
+ /// The sorted and filtered room members.
+ pub filtered_members: gtk::FilterListModel,
+ /// The rows in the popover.
+ pub rows: [CompletionRow; MAX_MEMBERS],
+ /// The selected row in the popover.
+ pub selected: Cell<Option<usize>>,
+ /// The current autocompleted word.
+ pub current_word: RefCell<Option<(gtk::TextIter, gtk::TextIter, String)>>,
+ /// Whether the popover is inhibited for the current word.
+ pub inhibit: Cell<bool>,
+ /// The buffer to complete with its cursor position signal handler ID.
+ pub buffer_handler: RefCell<Option<(gtk::TextBuffer, glib::SignalHandlerId)>>,
+ /// The signal handler ID for when them members change.
+ pub members_changed_handler: RefCell<Option<glib::SignalHandlerId>>,
+ /// List of signal handler IDs for properties of members of the current
+ /// list model with their position in it.
+ pub members_watch: RefCell<HashMap<OwnedUserId, MemberWatch>>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for CompletionPopover {
+ const NAME: &'static str = "ContentCompletionPopover";
+ type Type = super::CompletionPopover;
+ type ParentType = gtk::Popover;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for CompletionPopover {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![
+ glib::ParamSpecObject::new(
+ "view",
+ "View",
+ "The parent GtkTextView to autocomplete",
+ gtk::TextView::static_type(),
+ glib::ParamFlags::READABLE,
+ ),
+ glib::ParamSpecString::new(
+ "user-id",
+ "User ID",
+ "The user ID of the current session",
+ None,
+ glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+ ),
+ glib::ParamSpecObject::new(
+ "members",
+ "Members",
+ "The room members used for completion",
+ MemberList::static_type(),
+ glib::ParamFlags::READWRITE,
+ ),
+ glib::ParamSpecObject::new(
+ "filtered-members",
+ "Filtered Members",
+ "The sorted and filtered room members",
+ gtk::FilterListModel::static_type(),
+ glib::ParamFlags::READWRITE,
+ ),
+ ]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "user-id" => obj.set_user_id(value.get().unwrap()),
+ "members" => obj.set_members(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "view" => obj.view().to_value(),
+ "user-id" => obj.user_id().to_value(),
+ "members" => obj.members().to_value(),
+ "filtered-members" => obj.filtered_members().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn constructed(&self, obj: &Self::Type) {
+ self.parent_constructed(obj);
+
+ // Filter the members that are joined and that are not our user.
+ let joined =
+ gtk::BoolFilter::builder()
+ .expression(Member::this_expression("membership").chain_closure::<bool>(
+ closure!(|_obj: Option<glib::Object>, membership: Membership| {
+ membership == Membership::Join
+ }),
+ ))
+ .build();
+ let not_user = gtk::BoolFilter::builder()
+ .expression(gtk::ClosureExpression::new::<bool, _, _>(
+ &[
+ Member::this_expression("user-id"),
+ obj.property_expression("user-id"),
+ ],
+ closure!(
+ |_obj: Option<glib::Object>, user_id: &str, my_user_id: &str| {
+ user_id != my_user_id
+ }
+ ),
+ ))
+ .build();
+ let filter = gtk::EveryFilter::new();
+ filter.append(&joined);
+ filter.append(¬_user);
+ let first_model = gtk::FilterListModel::builder().filter(&filter).build();
+
+ // Sort the members list by activity, then display name.
+ let activity = gtk::NumericSorter::builder()
+ .sort_order(gtk::SortType::Descending)
+ .expression(Member::this_expression("latest-activity"))
+ .build();
+ let display_name = gtk::StringSorter::builder()
+ .ignore_case(true)
+ .expression(Member::this_expression("display-name"))
+ .build();
+ let sorter = gtk::MultiSorter::new();
+ sorter.append(&activity);
+ sorter.append(&display_name);
+ let second_model = gtk::SortListModel::builder()
+ .sorter(&sorter)
+ .model(&first_model)
+ .build();
+
+ // Setup the search filter.
+ let search = gtk::StringFilter::builder()
+ .ignore_case(true)
+ .match_mode(gtk::StringFilterMatchMode::Substring)
+ .expression(gtk::ClosureExpression::new::<String, _, _>(
+ &[
+ Member::this_expression("user-id"),
+ Member::this_expression("display-name"),
+ ],
+ closure!(
+ |_: Option<glib::Object>, user_id: &str, display_name: &str| {
+ lower_lay_string(&format!("{display_name} {user_id}"))
+ }
+ ),
+ ))
+ .build();
+ self.filtered_members.set_filter(Some(&search));
+ self.filtered_members.set_model(Some(&second_model));
+
+ for row in &self.rows {
+ self.list.append(row);
+ }
+
+ obj.connect_parent_notify(|obj| {
+ let priv_ = obj.imp();
+
+ if let Some((buffer, handler_id)) = priv_.buffer_handler.take() {
+ buffer.disconnect(handler_id);
+ }
+
+ if obj.parent().is_some() {
+ let view = obj.view();
+ let buffer = view.buffer();
+ let handler_id =
+ buffer.connect_cursor_position_notify(clone!(@weak obj => move |_| {
+ obj.update_completion(false);
+ }));
+ priv_.buffer_handler.replace(Some((buffer, handler_id)));
+
+ let key_events = gtk::EventControllerKey::new();
+ view.add_controller(&key_events);
+ key_events.connect_key_pressed(clone!(@weak obj => @default-return
glib::signal::Inhibit(false), move |_, key, _, modifier| {
+ if modifier.is_empty() {
+ if obj.is_visible() {
+ let priv_ = obj.imp();
+ if matches!(key, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::Tab) {
+ // Activate completion.
+ obj.activate_selected_row();
+ return glib::signal::Inhibit(true);
+ } else if matches!(key, gdk::Key::Up | gdk::Key::KP_Up) {
+ // Move up, if possible.
+ let idx = obj.selected_row_index().unwrap_or_default();
+ if idx > 0 {
+ obj.select_row_at_index(Some(idx - 1));
+ }
+ return glib::signal::Inhibit(true);
+ } else if matches!(key, gdk::Key::Down | gdk::Key::KP_Down) {
+ // Move down, if possible.
+ let new_idx = if let Some(idx) = obj.selected_row_index() {
+ idx + 1
+ } else {
+ 0
+ };
+ let n_members = priv_.filtered_members.n_items() as usize;
+ let max = MAX_MEMBERS.min(n_members);
+ if new_idx < max {
+ obj.select_row_at_index(Some(new_idx));
+ }
+ return glib::signal::Inhibit(true);
+ } else if matches!(key, gdk::Key::Escape) {
+ // Close.
+ obj.inhibit();
+ return glib::signal::Inhibit(true);
+ }
+ } else if matches!(key, gdk::Key::Tab) {
+ obj.update_completion(true);
+ return glib::signal::Inhibit(true);
+ }
+ }
+ glib::signal::Inhibit(false)
+ }));
+
+ // Close popup when the entry is not focused.
+ view.connect_has_focus_notify(clone!(@weak obj => move |view| {
+ if !view.has_focus() && obj.get_visible() {
+ obj.hide();
+ }
+ }));
+ }
+ });
+
+ self.list
+ .connect_row_activated(clone!(@weak obj => move |_, row| {
+ if let Some(row) = row.downcast_ref::<CompletionRow>() {
+ obj.row_activated(row);
+ }
+ }));
+ }
+ }
+
+ impl WidgetImpl for CompletionPopover {}
+ impl PopoverImpl for CompletionPopover {}
+}
+
+glib::wrapper! {
+ /// A popover to autocomplete Matrix IDs for its parent `gtk::TextView`.
+ pub struct CompletionPopover(ObjectSubclass<imp::CompletionPopover>)
+ @extends gtk::Widget, gtk::Popover;
+}
+
+impl CompletionPopover {
+ pub fn new() -> Self {
+ glib::Object::new(&[]).expect("Failed to create CompletionPopover")
+ }
+
+ pub fn view(&self) -> gtk::TextView {
+ self.parent()
+ .and_then(|parent| parent.downcast::<gtk::TextView>().ok())
+ .unwrap()
+ }
+
+ pub fn user_id(&self) -> Option<String> {
+ self.imp().user_id.borrow().clone()
+ }
+
+ pub fn set_user_id(&self, user_id: Option<String>) {
+ let priv_ = self.imp();
+
+ if priv_.user_id.borrow().as_ref() == user_id.as_ref() {
+ return;
+ }
+
+ priv_.user_id.replace(user_id);
+ self.notify("user-id");
+ }
+
+ fn first_model(&self) -> Option<gtk::FilterListModel> {
+ self.imp()
+ .filtered_members
+ .model()
+ .and_then(|model| model.downcast::<gtk::SortListModel>().ok())
+ .and_then(|second_model| second_model.model())
+ .and_then(|model| model.downcast::<gtk::FilterListModel>().ok())
+ }
+
+ pub fn members(&self) -> Option<MemberList> {
+ self.first_model()
+ .and_then(|first_model| first_model.model())
+ .and_then(|model| model.downcast::<MemberList>().ok())
+ }
+
+ pub fn set_members(&self, members: Option<&MemberList>) {
+ if let Some(first_model) = self.first_model() {
+ let priv_ = self.imp();
+
+ if let Some(old_members) = first_model.model() {
+ // Remove the old handlers.
+ if let Some(handler_id) = priv_.members_changed_handler.take() {
+ old_members.disconnect(handler_id);
+ }
+
+ let mut members_watch = priv_.members_watch.take();
+ for member in old_members
+ .snapshot()
+ .into_iter()
+ .filter_map(|obj| obj.downcast::<Member>().ok())
+ {
+ if let Some(watch) = members_watch.remove(&member.user_id()) {
+ for handler_id in watch.handlers {
+ member.disconnect(handler_id);
+ }
+ }
+ }
+ }
+
+ first_model.set_model(members);
+
+ if let Some(members) = members {
+ self.members_changed(members, 0, 0, members.n_items());
+
+ priv_
+ .members_changed_handler
+ .replace(Some(members.connect_items_changed(
+ clone!(@weak self as obj => move |members, pos, removed, added| {
+ obj.members_changed(members, pos, removed, added);
+ }),
+ )));
+ }
+ }
+ }
+
+ fn members_changed(&self, members: &MemberList, pos: u32, removed: u32, added: u32) {
+ let mut members_watch = self.imp().members_watch.borrow_mut();
+
+ // Remove the old members. We don't care about disconnecting them since
+ // they are gone.
+ for idx in pos..(pos + removed) {
+ let user_id = members_watch
+ .iter()
+ .find_map(|(user_id, watch)| (watch.position == idx).then(|| user_id.to_owned()));
+
+ if let Some(user_id) = user_id {
+ members_watch.remove(&user_id);
+ }
+ }
+
+ let upper_bound = if removed != added {
+ members.n_items()
+ } else {
+ // If there are as many removed as added, we don't need to update
+ // the position of the following members.
+ pos + added
+ };
+ for idx in pos..upper_bound {
+ if let Some(member) = members
+ .item(idx)
+ .and_then(|obj| obj.downcast::<Member>().ok())
+ {
+ let watch = members_watch.entry(member.user_id()).or_default();
+
+ // Update the position of all members after pos.
+ watch.position = idx;
+
+ // Listen to property changes for added members because the list
+ // models don't reevaluate the expressions when the membership
+ // or latest-activity change.
+ if idx < (pos + added) {
+ watch.handlers.push(member.connect_notify_local(
+ Some("membership"),
+ clone!(@weak self as obj => move |member, _| {
+ obj.reevaluate_member(member);
+ }),
+ ));
+ watch.handlers.push(member.connect_notify_local(
+ Some("latest-activity"),
+ clone!(@weak self as obj => move |member, _| {
+ obj.reevaluate_member(member);
+ }),
+ ));
+ }
+ }
+ }
+ }
+
+ /// Force the given member to be reevaluated.
+ fn reevaluate_member(&self, member: &Member) {
+ if let Some(members) = self.members() {
+ let pos = self
+ .imp()
+ .members_watch
+ .borrow()
+ .get(&member.user_id())
+ .map(|watch| watch.position);
+
+ if let Some(pos) = pos {
+ // We pretend the item changed to get it reevaluated.
+ members.items_changed(pos, 1, 1)
+ }
+ }
+ }
+
+ pub fn filtered_members(&self) -> >k::FilterListModel {
+ &self.imp().filtered_members
+ }
+
+ fn current_word(&self) -> Option<(gtk::TextIter, gtk::TextIter, String)> {
+ self.imp().current_word.borrow().clone()
+ }
+
+ fn set_current_word(&self, word: Option<(gtk::TextIter, gtk::TextIter, String)>) {
+ if self.current_word() == word {
+ return;
+ }
+
+ self.imp().current_word.replace(word);
+ }
+
+ /// Update completion.
+ ///
+ /// If trigger is `true`, the search term will not look for `@` at the start
+ /// of the word.
+ fn update_completion(&self, trigger: bool) {
+ let search = self.find_search_term(trigger);
+
+ if self.is_inhibited() && search.is_none() {
+ self.imp().inhibit.set(false);
+ } else if !self.is_inhibited() {
+ if let Some((start, end, term)) = search {
+ self.set_current_word(Some((start, end, term)));
+ self.search_members();
+ } else {
+ self.hide();
+ self.select_row_at_index(None);
+ self.set_current_word(None);
+ }
+ }
+ }
+
+ /// Find the current search term in the underlying buffer.
+ ///
+ /// Returns the start and end of the search word and the term to search for.
+ ///
+ /// If trigger is `true`, the search term will not look for `@` at the start
+ /// of the word.
+ fn find_search_term(&self, trigger: bool) -> Option<(gtk::TextIter, gtk::TextIter, String)> {
+ // Vocabular used in this method:
+ // - `word`: sequence of characters that form a valid ID or display name. This
+ // includes characters that are usually not considered to be in words because
+ // of the grammar of Matrix IDs.
+ // - `trigger`: character used to trigger the popover, usually the first
+ // character of the corresponding ID.
+
+ #[derive(Default)]
+ struct SearchContext {
+ localpart: String,
+ is_outside_ascii: bool,
+ has_id_separator: bool,
+ server_name: ServerNameContext,
+ has_port_separator: bool,
+ port: String,
+ }
+
+ enum ServerNameContext {
+ Ipv6(String),
+ // According to the Matrix spec definition, the IPv4 grammar is a
+ // subset of the domain name grammar.
+ Ipv4OrDomain(String),
+ Unknown,
+ }
+ impl Default for ServerNameContext {
+ fn default() -> Self {
+ Self::Unknown
+ }
+ }
+
+ fn is_possible_word_char(c: char) -> bool {
+ c.is_alphanumeric() || matches!(c, '.' | '_' | '=' | '-' | '/' | ':' | '[' | ']' | '@')
+ }
+
+ let buffer = self.view().buffer();
+ let cursor = buffer.iter_at_mark(&buffer.get_insert());
+
+ let mut word_start = cursor;
+ // Search for the beginning of the word.
+ while word_start.backward_cursor_position() {
+ let c = word_start.char();
+ if !is_possible_word_char(c) {
+ word_start.forward_cursor_position();
+ break;
+ }
+ }
+
+ if word_start.char() != '@'
+ && !trigger
+ && (cursor == word_start || self.current_word().is_none())
+ {
+ // No trigger or not updating the word.
+ return None;
+ }
+
+ let mut ctx = SearchContext::default();
+ let mut word_end = word_start;
+ while word_end.forward_cursor_position() {
+ let c = word_end.char();
+ if !ctx.has_id_separator {
+ // Localpart or display name.
+ if !ctx.is_outside_ascii
+ && (c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '=' | '-' | '/'))
+ {
+ ctx.localpart.push(c);
+ } else if c.is_alphanumeric() {
+ ctx.is_outside_ascii = true;
+ } else if !ctx.is_outside_ascii && c == ':' {
+ ctx.has_id_separator = true;
+ } else {
+ break;
+ }
+ } else {
+ // The server name of an ID.
+ if !ctx.has_port_separator {
+ // An IPv6 address, IPv4 address, or a domain name.
+ if matches!(ctx.server_name, ServerNameContext::Unknown) {
+ if c == '[' {
+ ctx.server_name = ServerNameContext::Ipv6(c.into())
+ } else if c.is_alphanumeric() {
+ ctx.server_name = ServerNameContext::Ipv4OrDomain(c.into())
+ } else {
+ break;
+ }
+ } else if let ServerNameContext::Ipv6(address) = &mut ctx.server_name {
+ if address.ends_with(']') {
+ if c == ':' {
+ ctx.has_port_separator = true;
+ } else {
+ break;
+ }
+ } else if address.len() > 46 {
+ break;
+ } else if c.is_ascii_hexdigit() || matches!(c, ':' | '.' | ']') {
+ address.push(c);
+ } else {
+ break;
+ }
+ } else if let ServerNameContext::Ipv4OrDomain(address) = &mut ctx.server_name {
+ if c == ':' {
+ ctx.has_port_separator = true;
+ } else if c.is_ascii_alphanumeric() || matches!(c, '-' | '.') {
+ address.push(c);
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ } else {
+ // The port number
+ if ctx.port.len() <= 5 && c.is_ascii_digit() {
+ ctx.port.push(c);
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ if cursor != word_end && !cursor.in_range(&word_start, &word_end) {
+ return None;
+ }
+
+ if self.in_escaped_markdown(&word_start, &word_end) {
+ return None;
+ }
+
+ // Remove the starting `@` for searching.
+ let mut term_start = word_start;
+ if term_start.char() == '@' {
+ term_start.forward_cursor_position();
+ }
+
+ let term = buffer.text(&term_start, &word_end, true);
+
+ // If the cursor jumped to another word, abort the completion.
+ if let Some((_, _, prev_term)) = self.current_word() {
+ if !term.contains(&prev_term) && !prev_term.contains(term.as_str()) {
+ return None;
+ }
+ }
+
+ Some((word_start, word_end, term.into()))
+ }
+
+ /// Check if the text is in markdown that would be escaped.
+ ///
+ /// This includes:
+ /// - Inline code
+ /// - Block code
+ /// - Links (because nested links are not allowed in HTML)
+ /// - Images
+ fn in_escaped_markdown(&self, word_start: >k::TextIter, word_end: >k::TextIter) -> bool {
+ let buffer = self.view().buffer();
+ let (buf_start, buf_end) = buffer.bounds();
+
+ // If the word is at the start or the end of the buffer, it cannot be escaped.
+ if *word_start == buf_start || *word_end == buf_end {
+ return false;
+ }
+
+ let text = buffer.slice(&buf_start, &buf_end, true);
+
+ // Find the word string slice indexes, because GtkTextIter only gives us
+ // the char offset but the parser gives us indexes.
+ let word_start_offset = word_start.offset() as usize;
+ let word_end_offset = word_end.offset() as usize;
+ let mut word_start_index = 0;
+ let mut word_end_index = 0;
+ if word_start_offset != 0 && word_end_offset != 0 {
+ for (offset, (index, _char)) in text.char_indices().enumerate() {
+ if word_start_offset == offset {
+ word_start_index = index;
+ }
+ if word_end_offset == offset {
+ word_end_index = index;
+ }
+
+ if word_start_index != 0 && word_end_index != 0 {
+ break;
+ }
+ }
+ }
+
+ // Look if word is in escaped markdown.
+ let mut in_escaped_tag = false;
+ for (event, range) in Parser::new(&text).into_offset_iter() {
+ match event {
+ Event::Start(tag) => {
+ in_escaped_tag =
+ matches!(tag, Tag::CodeBlock(_) | Tag::Link(..) | Tag::Image(..));
+ }
+ Event::End(_) => {
+ // A link or a code block only contains text so an end tag
+ // always means the end of an escaped part.
+ in_escaped_tag = false;
+ }
+ Event::Code(_) if range.contains(&word_start_index) => {
+ return true;
+ }
+ Event::Text(_) if in_escaped_tag && range.contains(&word_start_index) => {
+ return true;
+ }
+ _ => {}
+ }
+
+ if range.end <= word_end_index {
+ break;
+ }
+ }
+
+ false
+ }
+
+ fn search_members(&self) {
+ let priv_ = self.imp();
+ let filtered_members = self.filtered_members();
+ let filter = filtered_members
+ .filter()
+ .and_then(|filter| filter.downcast::<gtk::StringFilter>().ok())
+ .unwrap();
+ let term = self
+ .current_word()
+ .and_then(|(_, _, term)| (!term.is_empty()).then(|| lower_lay_string(&term)));
+ filter.set_search(term.as_deref());
+
+ let new_len = filtered_members.n_items();
+ if new_len == 0 {
+ self.hide();
+ self.select_row_at_index(None);
+ } else {
+ for (idx, row) in priv_.rows.iter().enumerate() {
+ if let Some(member) = filtered_members
+ .item(idx as u32)
+ .and_then(|obj| obj.downcast::<Member>().ok())
+ {
+ row.set_member(Some(member));
+ row.show();
+ } else if row.get_visible() {
+ row.hide();
+ } else {
+ // All remaining rows should be hidden too.
+ break;
+ }
+ }
+
+ self.update_pointing_to();
+ self.popup();
+ }
+ }
+
+ fn count_visible_rows(&self) -> usize {
+ self.imp()
+ .rows
+ .iter()
+ .filter(|row| row.get_visible())
+ .fuse()
+ .count()
+ }
+
+ fn popup(&self) {
+ if self
+ .selected_row_index()
+ .filter(|index| *index < self.count_visible_rows())
+ .is_none()
+ {
+ self.select_row_at_index(Some(0));
+ }
+ self.show()
+ }
+
+ fn update_pointing_to(&self) {
+ let view = self.view();
+ let (start, ..) = self.current_word().unwrap();
+ let location = view.iter_location(&start);
+ let (x, y) =
+ view.buffer_to_window_coords(gtk::TextWindowType::Widget, location.x(), location.y());
+ self.set_pointing_to(Some(&gdk::Rectangle::new(x - 6, y - 2, 0, 0)));
+ }
+
+ fn selected_row_index(&self) -> Option<usize> {
+ self.imp().selected.get()
+ }
+
+ fn select_row_at_index(&self, idx: Option<usize>) {
+ if self.selected_row_index() == idx || idx >= Some(self.count_visible_rows()) {
+ return;
+ }
+
+ let priv_ = self.imp();
+
+ if let Some(row) = idx.map(|idx| &priv_.rows[idx]) {
+ // Make sure the row is visible.
+ let row_bounds = row.compute_bounds(&*priv_.list).unwrap();
+ let lower = row_bounds.top_left().y() as f64;
+ let upper = row_bounds.bottom_left().y() as f64;
+ priv_.list.adjustment().unwrap().clamp_page(lower, upper);
+
+ priv_.list.select_row(Some(row));
+ } else {
+ priv_.list.select_row(gtk::ListBoxRow::NONE);
+ }
+ priv_.selected.set(idx);
+ }
+
+ fn activate_selected_row(&self) {
+ if let Some(idx) = self.selected_row_index() {
+ self.imp().rows[idx].activate();
+ } else {
+ self.inhibit();
+ }
+ }
+
+ fn row_activated(&self, row: &CompletionRow) {
+ if let Some(member) = row.member() {
+ let priv_ = self.imp();
+
+ if let Some((mut start, mut end, _)) = priv_.current_word.take() {
+ let view = self.view();
+ let buffer = view.buffer();
+
+ buffer.delete(&mut start, &mut end);
+
+ let anchor = match start.child_anchor() {
+ Some(anchor) => anchor,
+ None => buffer.create_child_anchor(&mut start),
+ };
+ let pill = Pill::for_user(member.upcast_ref());
+ view.add_child_at_anchor(&pill, &anchor);
+
+ self.hide();
+ self.select_row_at_index(None);
+ view.grab_focus();
+ }
+ }
+ }
+
+ fn is_inhibited(&self) -> bool {
+ self.imp().inhibit.get()
+ }
+
+ fn inhibit(&self) {
+ if !self.is_inhibited() {
+ self.imp().inhibit.set(true);
+ self.hide();
+ self.select_row_at_index(None);
+ }
+ }
+}
+
+impl Default for CompletionPopover {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src/session/content/room_history/completion/completion_row.rs
b/src/session/content/room_history/completion/completion_row.rs
new file mode 100644
index 000000000..4974cec64
--- /dev/null
+++ b/src/session/content/room_history/completion/completion_row.rs
@@ -0,0 +1,125 @@
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+use crate::{
+ components::Avatar,
+ session::{room::Member, UserExt},
+};
+
+mod imp {
+ use std::cell::RefCell;
+
+ use glib::subclass::InitializingObject;
+ use once_cell::sync::Lazy;
+
+ use super::*;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/Fractal/content-completion-row.ui")]
+ pub struct CompletionRow {
+ #[template_child]
+ pub avatar: TemplateChild<Avatar>,
+ #[template_child]
+ pub display_name: TemplateChild<gtk::Label>,
+ #[template_child]
+ pub id: TemplateChild<gtk::Label>,
+ /// The room member presented by this row.
+ pub member: RefCell<Option<Member>>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for CompletionRow {
+ const NAME: &'static str = "ContentCompletionRow";
+ type Type = super::CompletionRow;
+ type ParentType = gtk::ListBoxRow;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for CompletionRow {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![glib::ParamSpecObject::new(
+ "member",
+ "Member",
+ "The room member presented by this row",
+ Member::static_type(),
+ glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+ )]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ obj: &Self::Type,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "member" => obj.set_member(value.get().unwrap()),
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "member" => obj.member().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+
+ impl WidgetImpl for CompletionRow {}
+ impl ListBoxRowImpl for CompletionRow {}
+}
+
+glib::wrapper! {
+ /// A popover to allow completion for a given text buffer.
+ pub struct CompletionRow(ObjectSubclass<imp::CompletionRow>)
+ @extends gtk::Widget, gtk::ListBoxRow;
+}
+
+impl CompletionRow {
+ pub fn new() -> Self {
+ glib::Object::new(&[]).expect("Failed to create CompletionRow")
+ }
+
+ pub fn member(&self) -> Option<Member> {
+ self.imp().member.borrow().clone()
+ }
+
+ pub fn set_member(&self, member: Option<Member>) {
+ let priv_ = self.imp();
+
+ if priv_.member.borrow().as_ref() == member.as_ref() {
+ return;
+ }
+
+ if let Some(member) = &member {
+ priv_.avatar.set_item(Some(member.avatar().to_owned()));
+ priv_.display_name.set_label(&member.display_name());
+ priv_.id.set_label(member.user_id().as_str());
+ } else {
+ priv_.avatar.set_item(None);
+ priv_.display_name.set_label("");
+ priv_.id.set_label("");
+ }
+
+ priv_.member.replace(member);
+ self.notify("member");
+ }
+}
+
+impl Default for CompletionRow {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src/session/content/room_history/completion/mod.rs
b/src/session/content/room_history/completion/mod.rs
new file mode 100644
index 000000000..0b165989e
--- /dev/null
+++ b/src/session/content/room_history/completion/mod.rs
@@ -0,0 +1,5 @@
+mod completion_popover;
+mod completion_row;
+
+pub use completion_popover::CompletionPopover;
+pub use completion_row::CompletionRow;
diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs
index 39cbeb012..bd0e0eadb 100644
--- a/src/session/content/room_history/mod.rs
+++ b/src/session/content/room_history/mod.rs
@@ -1,4 +1,5 @@
mod attachment_dialog;
+mod completion;
mod divider_row;
mod item_row;
mod message_row;
@@ -29,8 +30,8 @@ use ruma::events::{room::message::LocationMessageEventContent, AnyMessageLikeEve
use sourceview::prelude::*;
use self::{
- attachment_dialog::AttachmentDialog, divider_row::DividerRow, item_row::ItemRow,
- state_row::StateRow, verification_info_bar::VerificationInfoBar,
+ attachment_dialog::AttachmentDialog, completion::CompletionPopover, divider_row::DividerRow,
+ item_row::ItemRow, state_row::StateRow, verification_info_bar::VerificationInfoBar,
};
use crate::{
components::{CustomEntry, DragOverlay, Pill, ReactionChooser, RoomTitle},
@@ -66,6 +67,7 @@ mod imp {
pub sticky: Cell<bool>,
pub item_context_menu: OnceCell<gtk::PopoverMenu>,
pub item_reaction_chooser: ReactionChooser,
+ pub completion: CompletionPopover,
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
#[template_child]
@@ -332,6 +334,17 @@ mod imp {
}));
}
}));
+ self.message_entry
+ .connect_copy_clipboard(clone!(@weak obj => move |entry| {
+ entry.stop_signal_emission_by_name("copy-clipboard");
+ obj.copy_buffer_selection_to_clipboard();
+ }));
+ self.message_entry
+ .connect_cut_clipboard(clone!(@weak obj => move |entry| {
+ entry.stop_signal_emission_by_name("cut-clipboard");
+ obj.copy_buffer_selection_to_clipboard();
+ entry.buffer().delete_selection(true, true);
+ }));
key_events
.connect_key_pressed(clone!(@weak obj => @default-return Inhibit(false), move |_, key, _,
modifier| {
@@ -369,10 +382,16 @@ mod imp {
.bind("markdown-enabled", obj, "markdown-enabled")
.build();
+ self.completion.set_parent(&*self.message_entry);
+
obj.setup_drop_target();
self.parent_constructed(obj);
}
+
+ fn dispose(&self, _obj: &Self::Type) {
+ self.completion.unparent();
+ }
}
impl WidgetImpl for RoomHistory {}
@@ -453,6 +472,7 @@ impl RoomHistory {
self.update_view();
self.start_loading();
self.update_room_state();
+ self.update_completion();
self.notify("room");
self.notify("empty");
}
@@ -924,6 +944,30 @@ impl RoomHistory {
pub fn item_reaction_chooser(&self) -> &ReactionChooser {
&self.imp().item_reaction_chooser
}
+
+ // Update the completion for the current room.
+ fn update_completion(&self) {
+ if let Some(room) = self.room() {
+ let completion = &self.imp().completion;
+ completion.set_user_id(Some(room.session().user().unwrap().user_id().to_string()));
+ completion.set_members(Some(room.members()))
+ }
+ }
+
+ // Copy the selection in the message entry to the clipboard while replacing
+ // mentions.
+ fn copy_buffer_selection_to_clipboard(&self) {
+ if let Some((start, end)) = self.imp().message_entry.buffer().selection_bounds() {
+ let content: String = self
+ .split_buffer_mentions(start, end)
+ .map(|chunk| match chunk {
+ MentionChunk::Text(str) => str,
+ MentionChunk::Mention { name, .. } => name,
+ })
+ .collect();
+ self.clipboard().set_text(&content);
+ }
+ }
}
impl Default for RoomHistory {
diff --git a/src/session/room/member_list.rs b/src/session/room/member_list.rs
index b20cd512b..23668fbab 100644
--- a/src/session/room/member_list.rs
+++ b/src/session/room/member_list.rs
@@ -116,8 +116,9 @@ impl MemberList {
}
let num_members_added = members.len().saturating_sub(prev_len);
- // We can't have the borrow active when members are updated or items_changed is
- // emitted because that will probably cause reads of the members field.
+ // We can't have the mut borrow active when members are updated or items_changed
+ // is emitted because that will probably cause reads of the members
+ // field.
std::mem::drop(members);
{
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index 92f627d61..278a05743 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -48,6 +48,7 @@ pub use self::{
event_actions::EventActions,
highlight_flags::HighlightFlags,
member::{Member, Membership},
+ member_list::MemberList,
member_role::MemberRole,
power_levels::{PowerLevel, PowerLevels, RoomAction, POWER_LEVEL_MAX, POWER_LEVEL_MIN},
reaction_group::ReactionGroup,
@@ -62,7 +63,6 @@ use crate::{
prelude::*,
session::{
avatar::update_room_avatar_from_file,
- room::member_list::MemberList,
sidebar::{SidebarItem, SidebarItemImpl},
Avatar, Session, User,
},
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]