diff --git a/build.gradle b/build.gradle index 0276083ecc9034797ae1736bd2eb1851061b3e80..2214caa08648f37b8caf994e102f63ba823e0143 100644 --- a/build.gradle +++ b/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 { diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index d874b600e5b86094690544e4605fa45b29844229..63c22955b1b58769e43257df64bb8d288c7607c9 100644 --- a/src/main/java/eu/siacs/conversations/entities/MucOptions.java +++ b/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 getUsersByRole(Role role) { + synchronized (users) { + ArrayList list = new ArrayList<>(); + for (User user : users) { + if (user.getRole().ranks(role)) { + list.add(user); + } + } + return list; + } + } + public ArrayList getUsersWithChatState(ChatState state, int max) { synchronized (users) { ArrayList list = new ArrayList<>(); @@ -936,6 +949,17 @@ public class MucOptions { return this.hats == null ? new HashSet<>() : hats; } + public List getPseudoHats(Context context) { + List 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())) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index e997680a82a01b1b44bb6956f7d34c35351466d2..892e37a70625546d5d1a5e8e24dbdedd45f87129 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/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.on(binding.textinput) + .with(activity.getDrawable(R.drawable.background_message_bubble)) + .with(new CharPolicy('@')) + .with(new RecyclerViewPresenter(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() { + @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("(? { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java index c239431d9c865eda41a39a352761045f04459ddc..bc8af1145ddf3dc9c502be0b47297292c62bb11e 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java @@ -147,7 +147,7 @@ public class UserAdapter extends ListAdapter 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 getPseudoHats(Context context, MucOptions.User user) { - List 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