@mention autocomplete UI

This commit is contained in:
Arne 2024-09-27 18:39:12 +02:00
parent a2a694c1bb
commit 40b0ba7838
5 changed files with 149 additions and 31 deletions

View file

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

View file

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

View file

@ -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.<MucOptions.User>on(binding.textinput)
.with(activity.getDrawable(R.drawable.input_bubble_light))
.with(new CharPolicy('@'))
.with(new RecyclerViewPresenter<User>(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<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();
hasWriteAccessInMUC();
return binding.getRoot();
}

View file

@ -114,7 +114,7 @@ public class ChannelSearchResultAdapter extends ListAdapter<Room, ChannelSearchR
public static class ViewHolder extends RecyclerView.ViewHolder {
private final SearchResultItemBinding binding;
public final SearchResultItemBinding binding;
private ViewHolder(SearchResultItemBinding binding) {
super(binding.getRoot());

View file

@ -126,7 +126,7 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
viewHolder.binding.tags.setVisibility(View.VISIBLE);
viewHolder.binding.tags.removeAllViewsInLayout();
for (MucOptions.Hat hat : getPseudoHats(viewHolder.binding.getRoot().getContext(), user)) {
for (MucOptions.Hat hat : user.getPseudoHats(viewHolder.binding.getRoot().getContext())) {
TextView tv = (TextView) LayoutInflater.from(viewHolder.binding.getRoot().getContext()).inflate(R.layout.list_item_tag, viewHolder.binding.tags, false);
tv.setText(hat.toString());
Drawable unwrappedDrawable = AppCompatResources.getDrawable(tv.getContext(), R.drawable.rounded_tag);
@ -151,17 +151,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;
}
@ -171,9 +160,9 @@ public class UserAdapter extends ListAdapter<MucOptions.User, UserAdapter.ViewHo
MucDetailsContextMenuHelper.onCreateContextMenu(menu, v);
}
class ViewHolder extends RecyclerView.ViewHolder {
protected class ViewHolder extends RecyclerView.ViewHolder {
private final ContactBinding binding;
public final ContactBinding binding;
private ViewHolder(ContactBinding binding) {
super(binding.getRoot());