@mention autocomplete UI

Stephen Paul Weber created

Change summary

build.gradle                                                      |   1 
src/main/java/eu/siacs/conversations/entities/MucOptions.java     |  32 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java | 123 
src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java  |  17 
4 files changed, 147 insertions(+), 26 deletions(-)

Detailed changes

build.gradle 🔗

@@ -123,6 +123,7 @@ dependencies {
     implementation 'com.github.Priyansh-Kedia:OpenGraphParser:2.5.6'
     implementation 'me.xdrop:fuzzywuzzy:1.4.0'
     implementation 'net.fellbaum:jemoji:1.4.1'
+    implementation 'com.github.natario1:Autocomplete:v1.1.0'
 }
 
 ext {

src/main/java/eu/siacs/conversations/entities/MucOptions.java 🔗

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.entities;
 
+import android.content.Context;
 import android.net.Uri;
 import android.text.TextUtils;
 
@@ -423,6 +424,18 @@ public class MucOptions {
         }
     }
 
+    public ArrayList<User> getUsersByRole(Role role) {
+        synchronized (users) {
+            ArrayList<User> list = new ArrayList<>();
+            for (User user : users) {
+                if (user.getRole().ranks(role)) {
+                    list.add(user);
+                }
+            }
+            return list;
+        }
+    }
+
     public ArrayList<User> getUsersWithChatState(ChatState state, int max) {
         synchronized (users) {
             ArrayList<User> list = new ArrayList<>();
@@ -936,6 +949,17 @@ public class MucOptions {
             return this.hats == null ? new HashSet<>() : hats;
         }
 
+        public List<MucOptions.Hat> getPseudoHats(Context context) {
+            List<MucOptions.Hat> hats = new ArrayList<>();
+            if (getAffiliation() != MucOptions.Affiliation.NONE) {
+                hats.add(new MucOptions.Hat(null, context.getString(getAffiliation().getResId())));
+            }
+            if (getRole() != MucOptions.Role.PARTICIPANT) {
+                hats.add(new MucOptions.Hat(null, context.getString(getRole().getResId())));
+            }
+            return hats;
+        }
+
         public long getPgpKeyId() {
             if (this.pgpKeyId != 0) {
                 return this.pgpKeyId;
@@ -1039,6 +1063,14 @@ public class MucOptions {
 
         @Override
         public int compareTo(@NonNull User another) {
+            final var anotherPseudoId = another.getOccupantId() != null && another.getOccupantId().charAt(0) == '\0';
+            final var pseudoId = getOccupantId() != null && getOccupantId().charAt(0) == '\0';
+            if (anotherPseudoId && !pseudoId) {
+                return 1;
+            }
+            if (pseudoId && !anotherPseudoId) {
+                return -1;
+            }
             if (another.getAffiliation().outranks(getAffiliation())) {
                 return 1;
             } else if (getAffiliation().outranks(another.getAffiliation())) {

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -83,6 +83,7 @@ import androidx.core.view.inputmethod.InputConnectionCompat;
 import androidx.core.view.inputmethod.InputContentInfoCompat;
 import androidx.databinding.DataBindingUtil;
 import androidx.documentfile.provider.DocumentFile;
+import androidx.recyclerview.widget.RecyclerView.Adapter;
 import androidx.viewpager.widget.PagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 
@@ -94,7 +95,16 @@ import com.cheogram.android.WebxdcStore;
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Optional;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Ordering;
+
+import com.otaliastudios.autocomplete.Autocomplete;
+import com.otaliastudios.autocomplete.AutocompleteCallback;
+import com.otaliastudios.autocomplete.AutocompletePresenter;
+import com.otaliastudios.autocomplete.CharPolicy;
+import com.otaliastudios.autocomplete.RecyclerViewPresenter;
 
 import org.jetbrains.annotations.NotNull;
 
@@ -110,12 +120,14 @@ import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -146,6 +158,7 @@ import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.adapter.CommandAdapter;
 import eu.siacs.conversations.ui.adapter.MediaPreviewAdapter;
 import eu.siacs.conversations.ui.adapter.MessageAdapter;
+import eu.siacs.conversations.ui.adapter.UserAdapter;
 import eu.siacs.conversations.ui.util.ActivityResult;
 import eu.siacs.conversations.ui.util.Attachment;
 import eu.siacs.conversations.ui.util.ConversationMenuConfigurator;
@@ -969,18 +982,6 @@ public class ConversationFragment extends XmppFragment
                 body.delete(0, 6);
                 while (body.length() > 0 && Character.isWhitespace(body.charAt(0))) body.delete(0, 1);
             }
-            if (Pattern.compile("\\A@mods\\s.*").matcher(body).find()) {
-                body.delete(0, 5);
-                final var mods = new StringBuffer();
-                for (final var user : conversation.getMucOptions().getUsers()) {
-                    if (user.getRole().ranks(MucOptions.Role.MODERATOR)) {
-                        if (mods.length() > 0) mods.append(", ");
-                        mods.append(user.getNick());
-                    }
-                }
-                mods.append(":");
-                body.insert(0, mods.toString());
-            }
             if (conversation.getReplyTo() != null) {
                 if (Emoticons.isEmoji(body.toString().replaceAll("\\s", ""))) {
                     message = conversation.getReplyTo().react(body.toString().replaceAll("\\s", ""));
@@ -1495,6 +1496,104 @@ public class ConversationFragment extends XmppFragment
             return true;
         });
 
+        Autocomplete.<MucOptions.User>on(binding.textinput)
+            .with(activity.getDrawable(R.drawable.background_message_bubble))
+            .with(new CharPolicy('@'))
+            .with(new RecyclerViewPresenter<MucOptions.User>(activity) {
+                protected UserAdapter adapter;
+
+                @Override
+                protected Adapter instantiateAdapter() {
+                    adapter = new UserAdapter(false) {
+                        @Override
+                        public void onBindViewHolder(UserAdapter.ViewHolder viewHolder, int position) {
+                            super.onBindViewHolder(viewHolder, position);
+                            final var item = getItem(position);
+                            viewHolder.binding.getRoot().setOnClickListener(v -> {
+                                dispatchClick(item);
+                            });
+                        }
+                    };
+                    return adapter;
+                }
+
+                @Override
+                protected void onQuery(@Nullable CharSequence query) {
+                    getRecyclerView().getItemAnimator().endAnimations();
+                    final var allUsers = conversation.getMucOptions().getUsers();
+                    if (!conversation.getMucOptions().getUsersByRole(MucOptions.Role.MODERATOR).isEmpty()) {
+                        final var u = new MucOptions.User(conversation.getMucOptions(), null, "\0role:moderator", "Notify active moderators", new HashSet<>());
+                        u.setRole("participant");
+                        allUsers.add(u);
+                    }
+                    if (!allUsers.isEmpty()) {
+                        final var u = new MucOptions.User(conversation.getMucOptions(), null, "\0attention", "Notify active participants", new HashSet<>());
+                        u.setRole("participant");
+                        allUsers.add(u);
+                    }
+                    final String needle = query.toString().toLowerCase(Locale.getDefault());
+                    adapter.submitList(
+                        Ordering.natural().immutableSortedCopy(Collections2.filter(
+                            allUsers,
+                            user -> {
+                                if ("mods".contains(needle) && "\0role:moderator".equals(user.getOccupantId())) return true;
+                                if ("here".contains(needle) && "\0attention".equals(user.getOccupantId())) return true;
+                                final String name = user.getNick();
+                                if (name == null) return false;
+                                for (final var hat : user.getHats()) {
+                                    if (hat.toString().toLowerCase(Locale.getDefault()).contains(needle)) return true;
+                                }
+                                for (final var hat : user.getPseudoHats(activity)) {
+                                    if (hat.toString().toLowerCase(Locale.getDefault()).contains(needle)) return true;
+                                }
+                                final Contact contact = user.getContact();
+                                return name.toLowerCase(Locale.getDefault()).contains(needle)
+                                    || contact != null
+                                    && contact.getDisplayName().toLowerCase(Locale.getDefault()).contains(needle);
+                            })));
+                }
+
+                @Override
+                protected AutocompletePresenter.PopupDimensions getPopupDimensions() {
+                    final var dim = new AutocompletePresenter.PopupDimensions();
+                    dim.width = displayMetrics.widthPixels * 4/5;
+                    return dim;
+                }
+            })
+            .with(new AutocompleteCallback<MucOptions.User>() {
+                @Override
+                public boolean onPopupItemClicked(Editable editable, MucOptions.User user) {
+                    int[] range = com.otaliastudios.autocomplete.CharPolicy.getQueryRange(editable);
+                    if (range == null) return false;
+                    range[0] -= 1;
+                    if ("\0attention".equals(user.getOccupantId())) {
+	                    editable.delete(Math.max(0, range[0]), Math.min(editable.length(), range[1]));
+                        editable.insert(0, "@here ");
+                        return true;
+                    }
+                    int colon = editable.toString().indexOf(':');
+                    final var beforeColon = range[0] < colon;
+                    String prefix = "";
+                    String suffix = " ";
+                    if (beforeColon) suffix = ", ";
+                    if (colon < 0 && range[0] == 0) suffix = ": ";
+                    if (colon > 0 && colon == range[0] - 2) {
+                        prefix = ", ";
+                        suffix = ": ";
+                        range[0] -= 2;
+                    }
+                    var insert = user.getNick();
+                    if ("\0role:moderator".equals(user.getOccupantId())) {
+                        insert = conversation.getMucOptions().getUsersByRole(MucOptions.Role.MODERATOR).stream().map(MucOptions.User::getNick).collect(Collectors.joining(", "));
+                    }
+                    editable.replace(Math.max(0, range[0]), Math.min(editable.length(), range[1]), prefix + insert + suffix);
+                    return true;
+                }
+
+                @Override
+                public void onPopupVisibilityChanged(boolean shown) {}
+            }).build();
+
         final Pattern lastColonPattern = Pattern.compile("(?<!\\w):");
         emojiSearchBinding = DataBindingUtil.inflate(inflater, R.layout.emoji_search, null, false);
         emojiSearchBinding.emoji.setOnItemClickListener((parent, view, position, id) -> {

src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java 🔗

@@ -147,7 +147,7 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
         viewHolder.binding.tags.setVisibility(View.VISIBLE);
         viewHolder.binding.tags.removeViews(1, viewHolder.binding.tags.getChildCount() - 1);
         final ImmutableList.Builder<Integer> viewIdBuilder = new ImmutableList.Builder<>();
-        for (MucOptions.Hat hat : getPseudoHats(viewHolder.binding.getRoot().getContext(), user)) {
+        for (MucOptions.Hat hat : user.getPseudoHats(viewHolder.binding.getRoot().getContext())) {
             final String tag = hat.toString();
             final TextView tv = (TextView) inflater.inflate(R.layout.list_item_tag, viewHolder.binding.tags, false);
             tv.setText(tag);
@@ -175,17 +175,6 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
         }
     }
 
-    private List<MucOptions.Hat> getPseudoHats(Context context, MucOptions.User user) {
-        List<MucOptions.Hat> hats = new ArrayList<>();
-        if (user.getAffiliation() != MucOptions.Affiliation.NONE) {
-            hats.add(new MucOptions.Hat(null, context.getString(user.getAffiliation().getResId())));
-        }
-        if (user.getRole() != MucOptions.Role.PARTICIPANT) {
-            hats.add(new MucOptions.Hat(null, context.getString(user.getRole().getResId())));
-        }
-        return hats;
-    }
-
     public MucOptions.User getSelectedUser() {
         return selectedUser;
     }
@@ -195,9 +184,9 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
         MucDetailsContextMenuHelper.onCreateContextMenu(menu,v);
     }
 
-    static class ViewHolder extends RecyclerView.ViewHolder {
+    public static class ViewHolder extends RecyclerView.ViewHolder {
 
-        private final ItemContactBinding binding;
+        public final ItemContactBinding binding;
 
         private ViewHolder(ItemContactBinding binding) {
             super(binding.getRoot());