[fractal] room-history: Implement mention of users in the message entry



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(&not_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) -> &gtk::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: &gtk::TextIter, word_end: &gtk::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]