@mention autocomplete UI

(cherry picked from commit 735e5c4aec55ec070018c3a08d2aba15e24ea897)
This commit is contained in:
Stephen Paul Weber 2024-09-09 17:21:05 +02:00 committed by Arne
parent e5d1328337
commit 963de2bb2a
4 changed files with 164 additions and 40 deletions

View file

@ -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 {

View file

@ -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<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<>();
@ -923,6 +936,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;
@ -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())) {

View file

@ -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.<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) -> {
@ -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));

View file

@ -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());