Tag navigation UI

Stephen Paul Weber created

Show horizontal list of tags available in current search results below the
search box, acts sort of like an autocomplete or you can just tap on it, making
tag naviation easier so you don't have to find something that matches that tag
or type it out to use it.

Change summary

src/main/java/eu/siacs/conversations/entities/Bookmark.java            | 18 
src/main/java/eu/siacs/conversations/entities/Contact.java             |  8 
src/main/java/eu/siacs/conversations/entities/ListItem.java            | 15 
src/main/java/eu/siacs/conversations/ui/StartConversationActivity.java | 87 
src/main/res/layout/actionview_search.xml                              |  8 
5 files changed, 127 insertions(+), 9 deletions(-)

Detailed changes

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

@@ -196,10 +196,20 @@ public class Bookmark extends Element implements ListItem {
 			return true;
 		}
 		needle = needle.toLowerCase(Locale.US);
-		final Jid jid = getJid();
-		return (jid != null && jid.toString().contains(needle)) ||
-			getDisplayName().toLowerCase(Locale.US).contains(needle) ||
-			matchInTag(context, needle);
+		String[] parts = needle.split("[,\\s]+");
+		if (parts.length > 1) {
+			for (String part : parts) {
+				if (!match(context, part)) {
+					return false;
+				}
+			}
+			return true;
+		} else {
+			final Jid jid = getJid();
+			return (jid != null && jid.toString().contains(parts[0])) ||
+				getDisplayName().toLowerCase(Locale.US).contains(parts[0]) ||
+				matchInTag(context, parts[0]);
+		}
 	}
 
 	private boolean matchInTag(Context context, String needle) {

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

@@ -216,7 +216,7 @@ public class Contact implements ListItem, Blockable {
             return true;
         }
         needle = needle.toLowerCase(Locale.US).trim();
-        String[] parts = needle.split("\\s+");
+        String[] parts = needle.split("[,\\s]+");
         if (parts.length > 1) {
             for (String part : parts) {
                 if (!match(context, part)) {
@@ -225,9 +225,9 @@ public class Contact implements ListItem, Blockable {
             }
             return true;
         } else {
-            return jid.toString().contains(needle) ||
-                    getDisplayName().toLowerCase(Locale.US).contains(needle) ||
-                    matchInTag(context, needle);
+            return jid.toString().contains(parts[0]) ||
+                    getDisplayName().toLowerCase(Locale.US).contains(parts[0]) ||
+                    matchInTag(context, parts[0]);
         }
     }
 

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

@@ -3,6 +3,7 @@ package eu.siacs.conversations.entities;
 import android.content.Context;
 
 import java.util.List;
+import java.util.Locale;
 
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.xmpp.Jid;
@@ -31,6 +32,20 @@ public interface ListItem extends Comparable<ListItem>, AvatarService.Avatarable
 		public String getName() {
 			return this.name;
 		}
+
+		public String toString() {
+			return getName();
+		}
+
+		public boolean equals(Object o) {
+			if (!(o instanceof Tag)) return false;
+			Tag ot = (Tag) o;
+			return name.toLowerCase(Locale.US).equals(ot.getName().toLowerCase(Locale.US)) && color == ot.getColor();
+		}
+
+		public int hashCode() {
+			return name.toLowerCase(Locale.US).hashCode();
+		}
 	}
 
 	boolean match(Context context, final String needle);

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

@@ -21,6 +21,7 @@ import android.util.Pair;
 import android.view.ContextMenu;
 import android.view.ContextMenu.ContextMenuInfo;
 import android.view.KeyEvent;
+import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
@@ -49,6 +50,8 @@ import androidx.databinding.DataBindingUtil;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import androidx.viewpager.widget.PagerAdapter;
 import androidx.viewpager.widget.ViewPager;
@@ -57,10 +60,16 @@ import com.google.android.material.textfield.TextInputLayout;
 import com.leinardi.android.speeddial.SpeedDialActionItem;
 import com.leinardi.android.speeddial.SpeedDialView;
 
+import java.util.Arrays;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -83,6 +92,7 @@ import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.ui.widget.SwipeRefreshListFragment;
 import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
@@ -101,6 +111,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
     private ListPagerAdapter mListPagerAdapter;
     private final List<ListItem> contacts = new ArrayList<>();
     private ListItemAdapter mContactsAdapter;
+    private TagsAdapter mTagsAdapter = new TagsAdapter();
     private final List<ListItem> conferences = new ArrayList<>();
     private ListItemAdapter mConferenceAdapter;
     private final List<String> mActivatedAccounts = new ArrayList<>();
@@ -668,6 +679,9 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
         mSearchEditText = mSearchView.findViewById(R.id.search_field);
         mSearchEditText.addTextChangedListener(mSearchTextWatcher);
         mSearchEditText.setOnEditorActionListener(mSearchDone);
+        RecyclerView tags = mSearchView.findViewById(R.id.tags);
+        tags.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
+        tags.setAdapter(mTagsAdapter);
         String initialSearchValue = mInitialSearchValue.pop();
         if (initialSearchValue != null) {
             mMenuSearchView.expandActionView();
@@ -959,6 +973,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
 
     protected void filterContacts(String needle) {
         this.contacts.clear();
+        ArrayList<ListItem.Tag> tags = new ArrayList<>();
         final List<Account> accounts = xmppConnectionService.getAccounts();
         boolean foundSopranica = false;
         for (Account account : accounts) {
@@ -970,6 +985,7 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
                             || (needle != null && !needle.trim().isEmpty())
                             || s.compareTo(Presence.Status.OFFLINE) < 0)) {
                         this.contacts.add(contact);
+                        tags.addAll(contact.getTags(this));
                     }
                 }
 
@@ -979,11 +995,22 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
                             foundSopranica = true;
                         }
                         this.contacts.add(bookmark);
+                        tags.addAll(bookmark.getTags(this));
                     }
                 }
             }
         }
 
+        Comparator<Map.Entry<ListItem.Tag,Integer>> sortTagsBy = Map.Entry.comparingByValue(Comparator.reverseOrder());
+        sortTagsBy = sortTagsBy.thenComparing(entry -> entry.getKey().getName());
+
+        mTagsAdapter.setTags(
+            tags.stream()
+            .collect(Collectors.toMap((x) -> x, (t) -> 1, (c1, c2) -> c1 + c2))
+            .entrySet().stream()
+            .sorted(sortTagsBy)
+            .map(e -> e.getKey()).collect(Collectors.toList())
+        );
         Collections.sort(this.contacts);
 
         final boolean sopranicaDeleted = getPreferences().getBoolean("cheogram_sopranica_bookmark_deleted", false);
@@ -1385,4 +1412,64 @@ public class StartConversationActivity extends XmppActivity implements XmppConne
             return false;
         }
     }
+
+    class TagsAdapter extends RecyclerView.Adapter<TagsAdapter.ViewHolder> {
+        class ViewHolder extends RecyclerView.ViewHolder {
+            protected TextView tv;
+
+            public ViewHolder(View v) {
+                super(v);
+                tv = (TextView) v;
+                tv.setOnClickListener(view -> {
+                    String needle = mSearchEditText.getText().toString();
+                    String tag = tv.getText().toString();
+                    String[] parts = needle.split("[,\\s]+");
+                    if(needle.isEmpty()) {
+                        needle = tag;
+                    } else if (tag.toLowerCase(Locale.US).contains(parts[parts.length-1])) {
+                        needle = needle.replace(parts[parts.length-1], tag);
+                    } else {
+                        needle += ", " + tag;
+                    }
+                    mSearchEditText.setText("");
+                    mSearchEditText.append(needle);
+                    filter(needle);
+                });
+            }
+
+            public void setTag(ListItem.Tag tag) {
+                tv.setText(tag.getName());
+                tv.setBackgroundColor(tag.getColor());
+            }
+        }
+
+        protected List<ListItem.Tag> tags = new ArrayList<>();
+
+        @Override
+        public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
+            View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.list_item_tag, null);
+            return new ViewHolder(view);
+        }
+
+        @Override
+        public void onBindViewHolder(ViewHolder viewHolder, int i) {
+            viewHolder.setTag(tags.get(i));
+        }
+
+        @Override
+        public int getItemCount() {
+            return tags.size();
+        }
+
+        public void setTags(final List<ListItem.Tag> tags) {
+            ListItem.Tag channelTag = new ListItem.Tag("Channel", UIHelper.getColorForName("Channel", true));
+            String needle = mSearchEditText == null ? "" : mSearchEditText.getText().toString().toLowerCase(Locale.US).trim();
+            HashSet<String> parts = new HashSet<>(Arrays.asList(needle.split("[,\\s]+")));
+            this.tags = tags.stream().filter(
+                tag -> !tag.equals(channelTag) && !parts.contains(tag.getName().toLowerCase(Locale.US))
+            ).collect(Collectors.toList());
+            if (!parts.contains("channel") && tags.contains(channelTag)) this.tags.add(0, channelTag);
+            notifyDataSetChanged();
+        }
+    }
 }

src/main/res/layout/actionview_search.xml 🔗

@@ -15,4 +15,10 @@
         android:imeOptions="flagNoExtractUi|actionSearch"
         android:inputType="textEmailAddress|textNoSuggestions"/>
 
-</RelativeLayout>
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/tags"
+        android:layout_below="@+id/search_field"
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content" />
+
+</RelativeLayout>