diff --git a/build.gradle b/build.gradle index 9123a568f2..c84449011a 100644 --- a/build.gradle +++ b/build.gradle @@ -76,8 +76,8 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.exifinterface:exifinterface:1.3.7' implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.emoji2:emoji2:1.4.0' - gitImplementation "androidx.emoji2:emoji2-bundled:1.4.0" + implementation 'androidx.emoji2:emoji2:1.5.0' + gitImplementation "androidx.emoji2:emoji2-bundled:1.5.0" implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.cardview:cardview:1.0.0' // for compatibility @@ -126,14 +126,15 @@ dependencies { implementation "com.daimajia.swipelayout:library:1.2.0@aar" implementation 'com.nineoldandroids:library:2.4.0' implementation "androidx.core:core-ktx:1.13.1" - implementation "androidx.compose.material3:material3-android:1.2.1" - implementation "androidx.emoji2:emoji2-emojipicker:1.4.0" + implementation "androidx.compose.material3:material3-android:1.3.0" + implementation "androidx.emoji2:emoji2-emojipicker:1.5.0" implementation 'com.github.Priyansh-Kedia:OpenGraphParser:2.5.6' implementation 'com.github.bumptech.glide:glide:4.15.1' // For photo editor compatibility implementation 'me.xdrop:fuzzywuzzy:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.github.inusedname:AndroidPhotoFilters:1.0.7.2' implementation 'com.github.chrisbanes:PhotoView:2.3.0' + 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 e35e9396f8..4cd6734af1 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; @@ -408,6 +409,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<>(); @@ -896,6 +909,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; @@ -997,6 +1021,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 45d19424f5..5afc9e3a7f 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -115,13 +115,21 @@ import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.databinding.DataBindingUtil; import androidx.documentfile.provider.DocumentFile; import androidx.emoji2.emojipicker.EmojiPickerView; +import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager.widget.PagerAdapter; import com.bumptech.glide.Glide; import com.google.android.material.materialswitch.MaterialSwitch; 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.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 net.java.otr4j.session.SessionStatus; @@ -155,6 +163,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 java.util.stream.Stream; import de.monocles.chat.BobTransfer; @@ -193,6 +202,7 @@ 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.MessageLogAdapter; +import eu.siacs.conversations.ui.adapter.UserAdapter; import eu.siacs.conversations.ui.adapter.model.MessageLogModel; import eu.siacs.conversations.ui.util.ActivityResult; import eu.siacs.conversations.ui.util.Attachment; @@ -1425,18 +1435,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", "")); @@ -2036,6 +2034,104 @@ public class ConversationFragment extends XmppFragment return true; }); + Autocomplete.on(binding.textinput) + .with(activity.getDrawable(R.drawable.input_bubble_light)) + .with(new CharPolicy('@')) + .with(new RecyclerViewPresenter(activity) { + protected UserAdapter adapter; + + @Override + protected RecyclerView.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(); + hasWriteAccessInMUC(); return binding.getRoot(); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java index b8a30e9a09..f5f375771e 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ChannelSearchResultAdapter.java @@ -114,7 +114,7 @@ public class ChannelSearchResultAdapter 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; } @@ -171,9 +160,9 @@ public class UserAdapter extends ListAdapter