Compare commits

...

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] d8ba2652ce Show contact avatar in @mention suggestion list items
Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-02-21 18:16:17 +00:00
copilot-swe-agent[bot] d0d1a9c902 Add @mentions autocomplete when drafting a message in group chats
Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-02-21 18:03:49 +00:00
copilot-swe-agent[bot] f39be17803 Initial plan 2026-02-21 17:50:07 +00:00
4 changed files with 222 additions and 0 deletions
@@ -72,6 +72,8 @@ import androidx.lifecycle.ViewModelProvider;
import androidx.media3.session.MediaController;
import androidx.media3.session.SessionCommand;
import androidx.media3.session.SessionToken;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.b44t.messenger.DcChat;
import com.b44t.messenger.DcContact;
@@ -90,6 +92,7 @@ import org.thoughtcrime.securesms.components.HidingLinearLayout;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.InputPanel;
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout.OnKeyboardShownListener;
import org.thoughtcrime.securesms.components.MentionAdapter;
import org.thoughtcrime.securesms.components.ScaleStableImageView;
import org.thoughtcrime.securesms.components.SendButton;
import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel;
@@ -208,6 +211,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private boolean isEditing = false;
private boolean switchedProfile = false;
private RecyclerView mentionSuggestions;
private MentionAdapter mentionAdapter;
@Override
protected void onCreate(Bundle state, boolean ready) {
this.context = ApplicationContext.getInstance(getApplicationContext());
@@ -956,6 +962,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
inputPanel = ViewUtil.findById(this, R.id.bottom_panel);
backgroundView = ViewUtil.findById(this, R.id.conversation_background);
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
mentionSuggestions = ViewUtil.findById(this, R.id.mention_suggestions);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
@@ -1057,6 +1064,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
recipient = new Recipient(this, dcChat);
glideRequests = GlideApp.with(this);
mentionAdapter = new MentionAdapter(glideRequests);
mentionSuggestions.setLayoutManager(new LinearLayoutManager(this));
mentionSuggestions.setAdapter(mentionAdapter);
mentionAdapter.setOnMentionClickListener(contact -> insertMention(contact));
setComposePanelVisibility(true);
initializeContactRequest();
}
@@ -1560,6 +1572,77 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
private void updateMentionSuggestions() {
if (!dcChat.isMultiUser()) {
mentionSuggestions.setVisibility(View.GONE);
return;
}
Editable text = composeText.getText();
if (text == null) {
mentionSuggestions.setVisibility(View.GONE);
return;
}
int cursorPos = composeText.getSelectionStart();
if (cursorPos <= 0) {
mentionSuggestions.setVisibility(View.GONE);
return;
}
String textBeforeCursor = text.toString().substring(0, cursorPos);
int atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex < 0) {
mentionSuggestions.setVisibility(View.GONE);
return;
}
// Check that there's no space between "@" and the cursor
String query = textBeforeCursor.substring(atIndex + 1);
if (query.contains(" ") || query.contains("\n")) {
mentionSuggestions.setVisibility(View.GONE);
return;
}
DcContext dcContext = DcHelper.getContext(context);
int[] contactIds = dcContext.getChatContacts(chatId);
List<DcContact> matched = new ArrayList<>();
String lowerQuery = query.toLowerCase();
for (int id : contactIds) {
if (id <= DcContact.DC_CONTACT_ID_LAST_SPECIAL) continue;
DcContact contact = dcContext.getContact(id);
String displayName = contact.getDisplayName().toLowerCase();
String addr = contact.getAddr().toLowerCase();
if (displayName.contains(lowerQuery) || addr.contains(lowerQuery)) {
matched.add(contact);
}
}
if (matched.isEmpty()) {
mentionSuggestions.setVisibility(View.GONE);
} else {
mentionAdapter.setContacts(matched);
mentionSuggestions.setVisibility(View.VISIBLE);
}
}
private void insertMention(DcContact contact) {
Editable text = composeText.getText();
if (text == null) return;
int cursorPos = composeText.getSelectionStart();
if (cursorPos <= 0) return;
String textBeforeCursor = text.toString().substring(0, cursorPos);
int atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex < 0) return;
String mention = "@" + contact.getDisplayName() + " ";
text.replace(atIndex, cursorPos, mention);
composeText.setSelection(atIndex + mention.length());
mentionSuggestions.setVisibility(View.GONE);
}
private class ComposeKeyPressedListener implements OnKeyListener, OnClickListener, TextWatcher, OnFocusChangeListener {
int beforeLength;
@@ -1593,6 +1676,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (composeText.getTextTrimmed().length() == 0 || beforeLength == 0) {
composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50);
}
updateMentionSuggestions();
}
@Override
@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.b44t.messenger.DcContact;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.ArrayList;
import java.util.List;
public class MentionAdapter extends RecyclerView.Adapter<MentionAdapter.ViewHolder> {
public interface OnMentionClickListener {
void onMentionClicked(DcContact contact);
}
private final List<DcContact> contacts = new ArrayList<>();
private final GlideRequests glideRequests;
private OnMentionClickListener listener;
public MentionAdapter(@NonNull GlideRequests glideRequests) {
this.glideRequests = glideRequests;
}
public void setContacts(List<DcContact> newContacts) {
contacts.clear();
contacts.addAll(newContacts);
notifyDataSetChanged();
}
public void setOnMentionClickListener(OnMentionClickListener listener) {
this.listener = listener;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.mention_list_item, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
DcContact contact = contacts.get(position);
Context context = holder.itemView.getContext();
holder.displayName.setText(contact.getDisplayName());
String addr = contact.getAddr();
String name = contact.getName();
if (!name.isEmpty() && !name.equals(addr)) {
holder.address.setText(addr);
holder.address.setVisibility(View.VISIBLE);
} else {
holder.address.setVisibility(View.GONE);
}
holder.avatar.setAvatar(glideRequests, new Recipient(context, contact), false);
holder.itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onMentionClicked(contact);
}
});
}
@Override
public int getItemCount() {
return contacts.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
final AvatarImageView avatar;
final TextView displayName;
final TextView address;
ViewHolder(@NonNull View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.mention_avatar);
displayName = itemView.findViewById(R.id.mention_display_name);
address = itemView.findViewById(R.id.mention_address);
}
}
}
@@ -53,6 +53,14 @@
android:paddingEnd="16dp"
android:focusable="true" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/mention_suggestions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:background="?attr/input_panel_bg_color"
android:elevation="4dp" />
<include layout="@layout/conversation_input_panel"/>
<FrameLayout
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="48dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?attr/conversation_list_item_background"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/mention_avatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="10dp" />
<TextView
android:id="@+id/mention_display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:singleLine="true"
android:ellipsize="end"
android:textSize="15sp" />
<TextView
android:id="@+id/mention_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="end"
android:textSize="13sp"
android:paddingStart="8dp"
android:textAppearance="?android:attr/textAppearanceSmall" />
</LinearLayout>