Compare commits

...

5 Commits

Author SHA1 Message Date
adbenitez 862771641d improve src/main/res/layout/emoji_suggestion_item.xml 2026-02-22 12:58:01 +01:00
copilot-swe-agent[bot] 2128a1718f Fix emoji suggestions clipped at bottom: wrap_content height and extra bottom padding
Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-02-21 21:52:31 +00:00
copilot-swe-agent[bot] 7e633a2597 Replace manual emoji JSON with com.vdurmont:emoji-java:5.1.1 library
Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-02-21 14:37:09 +00:00
copilot-swe-agent[bot] 2ffe663798 Add emoji auto-completion when typing :query in compose field
Co-authored-by: adbenitez <24558636+adbenitez@users.noreply.github.com>
2026-02-21 04:11:30 +00:00
copilot-swe-agent[bot] 1453bd1314 Initial plan 2026-02-21 03:58:01 +00:00
7 changed files with 251 additions and 2 deletions
+3
View File
@@ -210,6 +210,9 @@ dependencies {
implementation "io.noties.markwon:ext-strikethrough:$markwon_version"
implementation "io.noties.markwon:inline-parser:$markwon_version"
implementation 'com.airbnb.android:lottie:4.2.2' // Lottie animations support.
implementation ('com.vdurmont:emoji-java:5.1.1') { // emoji dataset for auto-completion
exclude group: 'org.json', module: 'json'
}
def media3_version = "1.8.0" // 1.9.0 need minSdkVersion 23
@@ -94,6 +94,7 @@ import org.thoughtcrime.securesms.components.ScaleStableImageView;
import org.thoughtcrime.securesms.components.SendButton;
import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel;
import org.thoughtcrime.securesms.components.audioplay.AudioView;
import org.thoughtcrime.securesms.components.emoji.EmojiSearch;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.connect.AccountManager;
import org.thoughtcrime.securesms.connect.DcEventCenter;
@@ -976,6 +977,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
container.addOnKeyboardShownListener(backgroundView);
inputPanel.setListener(this);
inputPanel.setMediaListener(this);
inputPanel.setEmojiSuggestionListener(emoji -> insertEmojiSuggestion(emoji));
attachmentTypeSelector = null;
attachmentManager = new AttachmentManager(this, this);
@@ -1593,13 +1595,84 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (composeText.getTextTrimmed().length() == 0 || beforeLength == 0) {
composeText.postDelayed(ConversationActivity.this::updateToggleButtonState, 50);
}
updateEmojiSuggestions(s);
}
@Override
public void onTextChanged(CharSequence s, int start, int before,int count) {}
@Override
public void onFocusChange(View v, boolean hasFocus) {}
public void onFocusChange(View v, boolean hasFocus) {
if (!hasFocus) inputPanel.hideEmojiSuggestions();
}
}
private String lastEmojiQuery = null;
private void updateEmojiSuggestions(CharSequence text) {
String query = getEmojiQuery(text, composeText.getSelectionEnd());
if (query == null) {
lastEmojiQuery = null;
inputPanel.hideEmojiSuggestions();
return;
}
if (query.equals(lastEmojiQuery)) return;
lastEmojiQuery = query;
Util.runOnBackground(() -> {
List<String> results = EmojiSearch.search(query);
Util.runOnMain(() -> {
if (!query.equals(lastEmojiQuery)) return; // stale result
if (results.isEmpty()) {
inputPanel.hideEmojiSuggestions();
} else {
inputPanel.showEmojiSuggestions(results);
}
});
});
}
/**
* Returns the emoji search query if the cursor is positioned after a `:word` pattern,
* or null if there is no active emoji query.
*/
private String getEmojiQuery(CharSequence text, int cursorPos) {
if (text == null || cursorPos <= 0) return null;
int colonPos = -1;
for (int i = cursorPos - 1; i >= 0; i--) {
char c = text.charAt(i);
if (c == ':') {
colonPos = i;
break;
}
if (!Character.isLetterOrDigit(c) && c != '_') break;
}
if (colonPos < 0) return null;
String query = text.subSequence(colonPos + 1, cursorPos).toString();
if (query.length() < 2) return null;
return query;
}
private void insertEmojiSuggestion(String emoji) {
android.text.Editable editable = composeText.getText();
if (editable == null) return;
int cursorPos = composeText.getSelectionEnd();
if (cursorPos < 0) return;
// Find colon before the query at the cursor position
int colonPos = -1;
for (int i = cursorPos - 1; i >= 0; i--) {
char c = editable.charAt(i);
if (c == ':') {
colonPos = i;
break;
}
if (!Character.isLetterOrDigit(c) && c != '_') break;
}
if (colonPos < 0) return;
String query = editable.subSequence(colonPos + 1, cursorPos).toString();
if (query.length() < 2) return;
editable.replace(colonPos, cursorPos, emoji + " ");
lastEmojiQuery = null;
inputPanel.hideEmojiSuggestions();
}
@Override
@@ -21,11 +21,14 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.b44t.messenger.DcMsg;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiSuggestionAdapter;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -37,6 +40,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.guava.Optional;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@@ -62,6 +66,8 @@ public class InputPanel extends ConstraintLayout
private View buttonToggle;
private View recordingContainer;
private View recordLockCancel;
private RecyclerView emojiSuggestions;
private EmojiSuggestionAdapter emojiSuggestionAdapter;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -103,6 +109,11 @@ public class InputPanel extends ConstraintLayout
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setListener(this);
this.emojiSuggestions = findViewById(R.id.emoji_suggestions);
this.emojiSuggestionAdapter = new EmojiSuggestionAdapter();
this.emojiSuggestions.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
this.emojiSuggestions.setAdapter(emojiSuggestionAdapter);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction());
quoteDismiss.setOnClickListener(v -> clearQuote());
@@ -229,6 +240,20 @@ public class InputPanel extends ConstraintLayout
mediaKeyboard.setKeyboardListener(this);
}
public void setEmojiSuggestionListener(@NonNull EmojiSuggestionAdapter.OnEmojiClickListener listener) {
emojiSuggestionAdapter.setOnEmojiClickListener(listener);
}
public void showEmojiSuggestions(@NonNull List<String> emojis) {
emojiSuggestionAdapter.setEmojis(emojis);
emojiSuggestions.setVisibility(View.VISIBLE);
emojiSuggestions.scrollToPosition(0);
}
public void hideEmojiSuggestions() {
emojiSuggestions.setVisibility(View.GONE);
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.NonNull;
import com.vdurmont.emoji.Emoji;
import com.vdurmont.emoji.EmojiManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Provides emoji search functionality using the emoji-java library.
* Used for emoji auto-completion when user types :query in the compose field.
*/
public final class EmojiSearch {
private static final int MAX_RESULTS = 20;
private EmojiSearch() {}
/**
* Search for emojis whose aliases or tags contain the given query (case-insensitive).
* Returns up to MAX_RESULTS emoji Unicode strings.
*/
@NonNull
public static List<String> search(@NonNull String query) {
if (query.isEmpty()) return Collections.emptyList();
String lowerQuery = query.toLowerCase();
List<String> results = new ArrayList<>();
for (Emoji emoji : EmojiManager.getAll()) {
if (matchesQuery(emoji, lowerQuery)) {
results.add(emoji.getUnicode());
if (results.size() >= MAX_RESULTS) break;
}
}
return results;
}
private static boolean matchesQuery(@NonNull Emoji emoji, @NonNull String lowerQuery) {
for (String alias : emoji.getAliases()) {
if (alias.toLowerCase().contains(lowerQuery)) return true;
}
for (String tag : emoji.getTags()) {
if (tag.toLowerCase().contains(lowerQuery)) return true;
}
return false;
}
}
@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.components.emoji;
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 org.thoughtcrime.securesms.R;
import java.util.ArrayList;
import java.util.List;
/**
* RecyclerView adapter for showing emoji auto-completion suggestions.
*/
public class EmojiSuggestionAdapter extends RecyclerView.Adapter<EmojiSuggestionAdapter.EmojiViewHolder> {
public interface OnEmojiClickListener {
void onEmojiClicked(@NonNull String emoji);
}
private final List<String> emojis = new ArrayList<>();
private OnEmojiClickListener listener;
public void setOnEmojiClickListener(OnEmojiClickListener listener) {
this.listener = listener;
}
public void setEmojis(@NonNull List<String> newEmojis) {
int oldSize = emojis.size();
emojis.clear();
emojis.addAll(newEmojis);
int newSize = emojis.size();
if (oldSize > 0) notifyItemRangeRemoved(0, oldSize);
if (newSize > 0) notifyItemRangeInserted(0, newSize);
}
@NonNull
@Override
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.emoji_suggestion_item, parent, false);
return new EmojiViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull EmojiViewHolder holder, int position) {
String emoji = emojis.get(position);
holder.emojiView.setText(emoji);
holder.itemView.setOnClickListener(v -> {
if (listener != null) listener.onEmojiClicked(emoji);
});
}
@Override
public int getItemCount() {
return emojis.size();
}
static class EmojiViewHolder extends RecyclerView.ViewHolder {
final TextView emojiView;
EmojiViewHolder(@NonNull View itemView) {
super(itemView);
emojiView = itemView.findViewById(R.id.emoji_text);
}
}
}
@@ -42,11 +42,24 @@
app:layout_constraintTop_toBottomOf="@id/quote_view"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/emoji_suggestions"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false"
android:background="?attr/input_panel_bg_color"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/subject_text" />
<FrameLayout
android:id="@+id/input_field_frame_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/button_toggle"
app:layout_constraintTop_toBottomOf="@id/subject_text"
app:layout_constraintTop_toBottomOf="@id/emoji_suggestions"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/emoji_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="6dp"
android:paddingBottom="10dp"
android:minWidth="44dp"
android:textSize="26sp"
android:textColor="?attr/emoji_text_color"
android:background="@drawable/touch_highlight_background" />