From 963de2bb2a9b9fcd64310ae20090752575c9fbee Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 9 Sep 2024 17:21:05 +0200 Subject: [PATCH] @mention autocomplete UI (cherry picked from commit 735e5c4aec55ec070018c3a08d2aba15e24ea897) --- build.gradle | 27 ++-- .../conversations/entities/MucOptions.java | 32 +++++ .../ui/ConversationFragment.java | 128 ++++++++++++++++-- .../conversations/ui/adapter/UserAdapter.java | 17 +-- 4 files changed, 164 insertions(+), 40 deletions(-) diff --git a/build.gradle b/build.gradle index f98785d32..e50eadab5 100644 --- a/build.gradle +++ b/build.gradle @@ -52,9 +52,13 @@ dependencies { implementation "androidx.core:core:1.10.1" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' + implementation project(':libs:annotation') + annotationProcessor project(':libs:annotation-processor') + + implementation 'androidx.viewpager:viewpager:1.0.0' - playstoreImplementation('com.google.firebase:firebase-messaging:23.4.1') { + playstoreImplementation('com.google.firebase:firebase-messaging:24.0.1') { exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' @@ -62,24 +66,22 @@ dependencies { monocleschatPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2") monocleschatPlaystoreImplementation 'com.github.singpolyma:play-licensing:1c637ea03c' conversationsPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2") - quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.0.2' + quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.1.0' implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1' implementation("com.github.CanHub:Android-Image-Cropper:2.0.0") - implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation 'androidx.cardview:cardview:1.0.0' implementation "androidx.preference:preference:1.2.1" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' - implementation 'com.google.android.material:material:1.11.0' + implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.work:work-runtime:2.9.0' implementation "androidx.emoji2:emoji2:1.4.0" freeImplementation "androidx.emoji2:emoji2-bundled:1.4.0" - implementation 'org.bouncycastle:bcmail-jdk15on:1.64' - //zxing stopped supporting Java 7 so we have to stick with 3.3.3 - //https://github.com/zxing/zxing/issues/1170 - implementation 'com.google.zxing:core:3.3.3' + implementation 'org.bouncycastle:bcmail-jdk18on:1.78.1' + implementation 'com.google.zxing:core:3.5.3' implementation 'org.minidns:minidns-hla:1.0.5' implementation 'me.leolin:ShortcutBadger:1.1.22@aar' implementation 'org.whispersystems:signal-protocol-java:2.6.2' @@ -99,12 +101,12 @@ dependencies { implementation 'me.drakeet.support:toastcompat:1.1.0' implementation "com.leinardi.android:speed-dial:3.3.0" - implementation "com.squareup.retrofit2:retrofit:2.9.0" - implementation "com.squareup.retrofit2:converter-gson:2.9.0" + implementation "com.squareup.retrofit2:retrofit:2.11.0" + implementation "com.squareup.retrofit2:converter-gson:2.11.0" implementation "com.squareup.okhttp3:okhttp:4.12.0" implementation 'com.google.guava:guava:32.1.3-android' - implementation 'io.michaelrocks:libphonenumber-android:8.13.28' + implementation 'io.michaelrocks:libphonenumber-android:8.13.35' implementation 'im.conversations.webrtc:webrtc-android:119.0.1' implementation 'io.github.nishkarsh:android-permissions:2.1.6' implementation 'androidx.recyclerview:recyclerview:1.1.0' @@ -128,6 +130,7 @@ dependencies { implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' implementation 'com.google.android.exoplayer:extension-mediasession:2.19.1' + implementation 'com.github.natario1:Autocomplete:v1.1.0' } ext { @@ -307,7 +310,7 @@ android { } packagingOptions { resources { - excludes += ['META-INF/BCKEY.DSA', 'META-INF/BCKEY.SF'] + excludes += ['META-INF/BCKEY.DSA', 'META-INF/BCKEY.SF', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF'] } } lint { diff --git a/src/main/java/eu/siacs/conversations/entities/MucOptions.java b/src/main/java/eu/siacs/conversations/entities/MucOptions.java index 2227e63b6..8201b94eb 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; @@ -410,6 +411,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<>(); @@ -923,6 +936,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; @@ -1026,6 +1050,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 b14c0bc52..b0a5a442f 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -92,6 +92,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; @@ -105,8 +106,17 @@ import de.monocles.chat.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.ImmutableSet; +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; @@ -137,6 +147,7 @@ import java.util.concurrent.TimeUnit; 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; @@ -167,6 +178,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; @@ -208,6 +220,8 @@ import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession; import eu.siacs.conversations.xmpp.jingle.RtpCapability; import eu.siacs.conversations.xmpp.stanzas.IqPacket; +import im.conversations.android.xmpp.model.stanza.Iq; + import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -1065,18 +1079,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", "")); @@ -1611,6 +1613,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("(? { @@ -3513,13 +3613,13 @@ public class ConversationFragment extends XmppFragment } else { if (!delayShow) conversation.showViewPager(); binding.commandsViewProgressbar.setVisibility(View.VISIBLE); - activity.xmppConnectionService.fetchCommands(conversation.getAccount(), commandJid, (a, iq) -> { + activity.xmppConnectionService.fetchCommands(conversation.getAccount(), commandJid, (iq) -> { if (activity == null) return; activity.runOnUiThread(() -> { binding.commandsViewProgressbar.setVisibility(View.GONE); commandAdapter.clear(); - if (iq.getType() == IqPacket.TYPE.RESULT) { + if (iq.getType() == Iq.Type.RESULT) { for (Element child : iq.query().getChildren()) { if (!"item".equals(child.getName()) || !Namespace.DISCO_ITEMS.equals(child.getNamespace())) continue; commandAdapter.add(new CommandAdapter.Command0050(child)); 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 c239431d9..bc8af1145 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