🔀 Commit

Sticker issues fixed and preview improved
Commitab5d4b9ddace31bc4438a32f68c1a2d9a52ef97f
AuthorJabJab <noreply@xmpp.tel>
Date2026-07-25
Parent88810869
commit ab5d4b9ddace31bc4438a32f68c1a2d9a52ef97f
Author: JabJab <noreply@xmpp.tel>
Date:   Sat Jul 25 20:12:44 2026 +0300

    Sticker issues fixed and preview improved
---
 build.gradle                                       |   2 +-
 src/main/java/tel/xmpp/jabjab/AppSettings.java     |   6 +
 .../tel/xmpp/jabjab/ui/ConversationFragment.java   | 240 +++++++++++++++++++--
 .../tel/xmpp/jabjab/ui/WebStickerPackActivity.java |   7 +-
 .../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java | 224 ++++++++++++++-----
 .../java/tel/xmpp/jabjab/ui/util/StickerStore.java |  26 +++
 .../jabjab/ui/util/TelegramStickerImporter.java    |  11 +-
 .../res/drawable/bg_reaction_chip_selected.xml     |   5 +
 src/main/res/drawable/ic_content_copy_24dp.xml     |  10 +
 src/main/res/layout/item_message_content.xml       |  65 ++++++
 src/main/res/layout/popup_message_action_item.xml  |  28 +++
 src/main/res/layout/popup_message_actions.xml      |  55 +++++
 src/main/res/layout/popup_reaction_item.xml        |  10 +
 src/main/res/menu/message_context.xml              |  32 ++-
 src/main/res/values/defaults.xml                   |   1 +
 src/main/res/values/strings.xml                    |   5 +
 src/main/res/xml/preferences_interface.xml         |   6 +
 17 files changed, 662 insertions(+), 71 deletions(-)

diff --git a/build.gradle b/build.gradle
index 99af5c2..56af7f1 100644
--- a/build.gradle
+++ b/build.gradle
@@ -113,7 +113,7 @@ android {
 
     defaultConfig {
         minSdkVersion 23
-        versionCode 42299
+        versionCode 42301
         versionName "1.0.2"
         applicationId "tel.xmpp.jabjab"
         resValue "string", "applicationId", applicationId
diff --git a/src/main/java/tel/xmpp/jabjab/AppSettings.java b/src/main/java/tel/xmpp/jabjab/AppSettings.java
index dcdbf7d..0c96370 100644
--- a/src/main/java/tel/xmpp/jabjab/AppSettings.java
+++ b/src/main/java/tel/xmpp/jabjab/AppSettings.java
@@ -51,6 +51,7 @@ public class AppSettings {
     public static final String DISPLAY_ENTER_KEY = "display_enter_key";
     public static final String ENTER_IS_SEND = "enter_is_send";
     public static final String SCROLL_TO_BOTTOM = "scroll_to_bottom";
+    public static final String MESSAGE_MENU_HAPTIC_FEEDBACK = "message_menu_haptic_feedback";
 
     public static final String READ_RECEIPTS = "confirm_messages";
     public static final String ALLOW_MESSAGE_CORRECTION = "allow_message_correction";
@@ -397,6 +398,11 @@ public class AppSettings {
         return getBooleanPreference(SCROLL_TO_BOTTOM, R.bool.scroll_to_bottom);
     }
 
+    public boolean isMessageMenuHapticFeedback() {
+        return getBooleanPreference(
+                MESSAGE_MENU_HAPTIC_FEEDBACK, R.bool.message_menu_haptic_feedback);
+    }
+
     public void setSendCrashReports(boolean value) {
         final SharedPreferences sharedPreferences =
                 PreferenceManager.getDefaultSharedPreferences(context);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 3945732..d89b141 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -20,6 +20,8 @@ import android.content.Intent;
 import android.content.IntentSender.SendIntentException;
 import android.content.pm.PackageManager;
 import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
@@ -30,8 +32,6 @@ import android.provider.MediaStore;
 import android.text.Editable;
 import android.text.TextUtils;
 import android.util.Log;
-import android.view.ContextMenu;
-import android.view.ContextMenu.ContextMenuInfo;
 import android.view.Gravity;
 import android.view.LayoutInflater;
 import android.view.ActionMode;
@@ -46,12 +46,13 @@ import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.AbsListView;
 import android.widget.AbsListView.OnScrollListener;
-import android.widget.AdapterView;
-import android.widget.AdapterView.AdapterContextMenuInfo;
 import android.widget.CheckBox;
 import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
 import android.widget.ListView;
 import android.widget.PopupMenu;
+import android.widget.PopupWindow;
 import android.widget.TextView;
 import android.widget.TextView.OnEditorActionListener;
 import android.widget.Toast;
@@ -1556,7 +1557,20 @@ public class ConversationFragment extends XmppFragment
                 binding.messagesView.setSelectionFromTop(pos, 0));
         binding.messagesView.setAdapter(messageListAdapter);
 
-        registerForContextMenu(binding.messagesView);
+        // Native ContextMenu (registerForContextMenu) never renders MenuItem icons — a
+        // long-standing Android limitation, not a bug in our menu XML. Using a PopupMenu with
+        // its icons force-shown instead gives the same icon-next-to-text rows Telegram uses
+        // for its own message long-press menu (see message_context.xml / showMessagePopupMenu).
+        binding.messagesView.setOnItemLongClickListener(
+                (parent, view, position, id) -> {
+                    view.dispatchTouchEvent(
+                            MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
+                    synchronized (this.messageList) {
+                        this.selectedMessage = this.messageList.get(position);
+                        showMessagePopupMenu(view);
+                    }
+                    return true;
+                });
 
         this.binding.textInput.setCustomInsertionActionModeCallback(
                 new EditMessageActionModeCallback(this.binding.textInput));
@@ -1788,23 +1802,168 @@ public class ConversationFragment extends XmppFragment
                 });
     }
 
-    @Override
-    public void onCreateContextMenu(@NonNull ContextMenu menu, View v, ContextMenuInfo menuInfo) {
-        // This should cancel any remaining click events that would otherwise trigger links
-        v.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0));
-        synchronized (this.messageList) {
-            super.onCreateContextMenu(menu, v, menuInfo);
-            AdapterView.AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo;
-            this.selectedMessage = this.messageList.get(acmi.position);
-            populateContextMenu(menu);
+    /**
+     * Custom-drawn popup instead of a stock PopupMenu: PopupMenu only shows icons via an
+     * internal reflection hack (setForceShowIcon) with no control over spacing/sizing, which
+     * reads as cramped and off-brand rather than a real menu. Building the rows ourselves
+     * (popup_message_action_item.xml: icon + text, Material touch-target height, ripple) in a
+     * rounded elevated card (popup_message_actions.xml) matches the rest of the app's Material
+     * styling instead. A throwaway, never-shown PopupMenu is still used purely to get a real,
+     * inflatable Menu — that's just the cheapest way to reuse populateContextMenu()'s large,
+     * already-correct visibility logic without duplicating it.
+     */
+    private void showMessagePopupMenu(final View anchor) {
+        final boolean hapticEnabled =
+                new tel.xmpp.jabjab.AppSettings(requireContext()).isMessageMenuHapticFeedback();
+        if (hapticEnabled) {
+            anchor.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS);
+        }
+
+        final PopupMenu menuHolder = new PopupMenu(requireActivity(), anchor);
+        final Menu menu = menuHolder.getMenu();
+        populateContextMenu(menu);
+
+        final LayoutInflater inflater = LayoutInflater.from(requireActivity());
+        final View popupContent =
+                inflater.inflate(R.layout.popup_message_actions, (ViewGroup) binding.getRoot(), false);
+        final LinearLayout actionList = popupContent.findViewById(R.id.action_list);
+
+        final PopupWindow popupWindow =
+                new PopupWindow(
+                        popupContent,
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                        true);
+        popupWindow.setOutsideTouchable(true);
+        // The card (popup_message_actions.xml) already draws its own rounded background +
+        // elevation — a transparent PopupWindow background avoids a second, square shadow box
+        // showing through around it.
+        popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
+
+        // Quick-react strip: same "recent emoji" list as the full picker, one tap instead of
+        // opening a dialog. Keyed off the SAME action_add_reaction visibility populateContextMenu()
+        // already computed — reactions being disallowed on this message (e.g. restricted MUC,
+        // already sent, wrong message type) hides both the strip and the row further down.
+        final MenuItem addReactionItem = menu.findItem(R.id.action_add_reaction);
+        if (addReactionItem != null && addReactionItem.isVisible()) {
+            populateReactionStrip(popupContent, popupWindow, inflater, hapticEnabled);
+        }
+
+        for (int i = 0; i < menu.size(); i++) {
+            final MenuItem item = menu.getItem(i);
+            if (!item.isVisible()) {
+                continue;
+            }
+            final View row = inflater.inflate(R.layout.popup_message_action_item, actionList, false);
+            ((TextView) row.findViewById(R.id.action_text)).setText(item.getTitle());
+            ((ImageView) row.findViewById(R.id.action_icon)).setImageDrawable(item.getIcon());
+            row.setOnClickListener(
+                    v -> {
+                        popupWindow.dismiss();
+                        onContextItemSelected(item);
+                    });
+            actionList.addView(row);
+        }
+        showPopupNearAnchor(popupWindow, popupContent, anchor);
+    }
+
+    private void populateReactionStrip(
+            final View popupContent,
+            final PopupWindow popupWindow,
+            final LayoutInflater inflater,
+            final boolean hapticEnabled) {
+        final LinearLayout reactionStrip = popupContent.findViewById(R.id.reaction_strip);
+        final View divider = popupContent.findViewById(R.id.reaction_strip_divider);
+        final Message message = this.selectedMessage;
+        final var recent = tel.xmpp.jabjab.entities.Reaction.getRecentSuggestions(requireContext());
+        // Start from whatever this user already reacted with — a quick-tap must ADD to that
+        // set, not replace it (matches AddReactionDialog's own behaviour). Mutable local copy:
+        // each tap toggles membership and re-sends the whole set, same semantics as XEP-0444
+        // (a user's reactions on a message are always their full current set, not a diff).
+        final java.util.Set<String> selected =
+                new java.util.HashSet<>(message.getAggregatedReactions().ourReactions);
+        // The strip is one row — cap it well below the full picker's 18 so it never wraps.
+        final int count = Math.min(6, recent.size());
+        for (int i = 0; i < count; i++) {
+            final String emoji = recent.get(i);
+            final TextView chip =
+                    (TextView) inflater.inflate(R.layout.popup_reaction_item, reactionStrip, false);
+            chip.setText(emoji);
+            chip.setBackgroundResource(
+                    selected.contains(emoji) ? R.drawable.bg_reaction_chip_selected : 0);
+            chip.setOnClickListener(
+                    v -> {
+                        if (hapticEnabled) {
+                            v.performHapticFeedback(
+                                    android.view.HapticFeedbackConstants.CONFIRM);
+                        }
+                        // Same tap-then-close convention as every other menu action (and as
+                        // the full "Add reaction" dialog itself) — the fix is that this now
+                        // sends the existing reactions PLUS the tapped one, not just the
+                        // tapped one alone, so a second long-press to add another reaction
+                        // actually adds instead of replacing.
+                        if (!selected.add(emoji)) {
+                            selected.remove(emoji);
+                        }
+                        popupWindow.dismiss();
+                        requireXmppActivity().sendReactions(message, selected);
+                    });
+            reactionStrip.addView(chip);
         }
+        reactionStrip.setVisibility(View.VISIBLE);
+        divider.setVisibility(View.VISIBLE);
+    }
+
+    /**
+     * showAsDropDown(anchor) always places the popup directly below the anchor with no regard
+     * for available space — for the last message in the list (or any message near the bottom
+     * of the screen), that pushes it off-screen or behind other content instead of clipping
+     * visibly. This measures the popup first and picks above-vs-below (and clamps horizontally)
+     * the same way Android's own PopupMenu/ListPopupWindow do internally, so it's always fully
+     * on-screen regardless of where in the list the long-pressed message sits.
+     */
+    private void showPopupNearAnchor(
+            final PopupWindow popupWindow, final View popupContent, final View anchor) {
+        popupContent.measure(
+                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
+                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
+        final int popupWidth = popupContent.getMeasuredWidth();
+        final int popupHeight = popupContent.getMeasuredHeight();
+
+        final int[] anchorLoc = new int[2];
+        anchor.getLocationOnScreen(anchorLoc);
+        final int anchorTop = anchorLoc[1];
+        final int anchorBottom = anchorTop + anchor.getHeight();
+
+        final android.graphics.Point screenSize = new android.graphics.Point();
+        requireActivity().getWindowManager().getDefaultDisplay().getSize(screenSize);
+        final int margin = (int) (8 * getResources().getDisplayMetrics().density);
+
+        final int spaceBelow = screenSize.y - anchorBottom;
+        final int spaceAbove = anchorTop;
+        final int y;
+        if (popupHeight <= spaceBelow - margin) {
+            y = anchorBottom;
+        } else if (popupHeight <= spaceAbove - margin) {
+            y = anchorTop - popupHeight;
+        } else {
+            // Doesn't fully fit either way (e.g. a very long menu on a small screen) — pick
+            // whichever side has more room; the ScrollView inside popup_message_actions.xml
+            // still caps its own height and scrolls, so nothing renders off-screen.
+            y = (spaceBelow >= spaceAbove) ? anchorBottom : Math.max(margin, anchorTop - popupHeight);
+        }
+        // Always horizontally centered on screen, regardless of where the long-pressed
+        // message/bubble sits — not anchored to its left edge.
+        final int x = Math.max(margin, (screenSize.x - popupWidth) / 2);
+
+        popupWindow.showAtLocation(binding.getRoot(), Gravity.NO_GRAVITY, x, y);
     }
 
     private static boolean isAckedModerationDisclaimer() {
         return ackModeration.isAfter(Instant.now());
     }
 
-    private void populateContextMenu(final ContextMenu menu) {
+    private void populateContextMenu(final Menu menu) {
         final Message m = this.selectedMessage;
         final Transferable t = m.getTransferable();
         if (m.getType() != Message.TYPE_STATUS && m.getType() != Message.TYPE_RTP_SESSION) {
@@ -1830,7 +1989,9 @@ public class ConversationFragment extends XmppFragment
                             && (t instanceof JingleFileTransferConnection
                                     || t instanceof HttpDownloadConnection);
             requireActivity().getMenuInflater().inflate(R.menu.message_context, menu);
-            menu.setHeaderTitle(R.string.message_options);
+            // PopupMenu has no header title slot (unlike the native ContextMenu this replaced) —
+            // Telegram's own popup doesn't have one either, so this is a deliberate, acceptable
+            // drop rather than an oversight.
             final MenuItem addReaction = menu.findItem(R.id.action_add_reaction);
             final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block);
             final MenuItem openWith = menu.findItem(R.id.open_with);
@@ -1855,6 +2016,7 @@ public class ConversationFragment extends XmppFragment
             final MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
             final MenuItem saveFile = menu.findItem(R.id.save_file);
             final MenuItem saveToStickers = menu.findItem(R.id.save_to_stickers);
+            final MenuItem saveGifToFavorites = menu.findItem(R.id.save_gif_to_favorites);
             final MenuItem installStickerPack = menu.findItem(R.id.action_install_sticker_pack);
             final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
             final boolean showError =
@@ -2036,6 +2198,14 @@ public class ConversationFragment extends XmppFragment
                         saveToStickers.setVisible(true);
                     }
                 }
+                // GIFs get their own favorites store (see GifStore) rather than the sticker
+                // pack system — previously this was its own separate PopupMenu attached
+                // directly to the GIF ImageView's long-press, which fully swallowed the
+                // long-press and meant GIFs never showed copy link/share/forward like every
+                // other message. Folding it in here means GIFs now get the full shared menu.
+                if ("image/gif".equals(mime) && m.getRelativeFilePath() != null) {
+                    saveGifToFavorites.setVisible(true);
+                }
             }
             // "Install sender's sticker pack" for received messages with a XEP-0449 pack node.
             if (m.getStatus() == Message.STATUS_RECEIVED && m.getStickerPackNode() != null) {
@@ -2121,6 +2291,10 @@ public class ConversationFragment extends XmppFragment
                 saveMessageToStickers(selectedMessage);
                 yield true;
             }
+            case R.id.save_gif_to_favorites -> {
+                saveGifToFavorites(selectedMessage);
+                yield true;
+            }
             case R.id.action_install_sticker_pack -> {
                 installStickerPackFromMessage(selectedMessage);
                 yield true;
@@ -3115,6 +3289,36 @@ public class ConversationFragment extends XmppFragment
         }).start();
     }
 
+    /** Ported from a one-off PopupMenu that used to sit directly on the GIF ImageView's
+     * long-press (MessageAdapter) — that fully swallowed the long-press, so GIFs never got
+     * the shared context menu's copy link/share/forward items like every other message. */
+    private void saveGifToFavorites(final Message message) {
+        final java.io.File src =
+                requireXmppActivity().xmppConnectionService.getFileBackend().getFile(message);
+        if (src == null || !src.exists()) return;
+        final var activity = requireActivity();
+        new Thread(() -> {
+            try {
+                final java.io.File saved =
+                        tel.xmpp.jabjab.ui.util.GifStore.save(activity, src);
+                final boolean alreadyHad =
+                        saved.lastModified() < System.currentTimeMillis() - 2000;
+                activity.runOnUiThread(() ->
+                        Toast.makeText(
+                                activity,
+                                alreadyHad
+                                        ? R.string.gif_already_saved
+                                        : R.string.gif_saved_to_favorites,
+                                Toast.LENGTH_SHORT).show());
+            } catch (final java.io.IOException e) {
+                activity.runOnUiThread(() ->
+                        Toast.makeText(
+                                activity, R.string.sticker_export_failed,
+                                Toast.LENGTH_SHORT).show());
+            }
+        }).start();
+    }
+
     private void installStickerPackFromMessage(final Message message) {
         final String packNode = message.getStickerPackNode();
         if (packNode == null) return;
@@ -3188,7 +3392,9 @@ public class ConversationFragment extends XmppFragment
         final boolean isEmoji = (cp >= 0x1F300 && cp <= 0x1FAFF)
                 || (cp >= 0x2600 && cp <= 0x27BF)
                 || (cp >= 0xFE00 && cp <= 0xFE0F)
-                || cp == 0x200D;
+                || (cp >= 0x1F1E6 && cp <= 0x1F1FF) // regional indicators — paired flag emoji
+                || cp == 0x200D
+                || last.indexOf('⃣') >= 0; // combining enclosing keycap (0-9/#/* sequences)
         return isEmoji ? last : null;
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
index 5f3f5ef..caecf25 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/WebStickerPackActivity.java
@@ -69,6 +69,7 @@ public class WebStickerPackActivity extends XmppActivity {
 
     private List<StickerItem> stickers = new ArrayList<>();
     private String resolvedPackName;
+    private String resolvedSlug;
 
     @Override
     protected void onCreate(final Bundle savedInstanceState) {
@@ -110,6 +111,7 @@ public class WebStickerPackActivity extends XmppActivity {
             showError(getString(R.string.sticker_pack_fetch_failed));
             return;
         }
+        resolvedSlug = slug;
         fetchPack(slug);
     }
 
@@ -220,7 +222,7 @@ public class WebStickerPackActivity extends XmppActivity {
                 final List<StickerItem> items = new ArrayList<>();
                 for (int i = 0; i < arr.length(); i++) {
                     final JSONObject s = arr.getJSONObject(i);
-                    items.add(new StickerItem(s.getString("url"), s.optString("emoji", "😀")));
+                    items.add(new StickerItem(s.getString("url"), s.optString("emoji", "")));
                 }
                 runOnUiThread(() -> showPack(resolvedPackName, desc, items));
             } catch (final Exception e) {
@@ -275,6 +277,9 @@ public class WebStickerPackActivity extends XmppActivity {
                 final String packName = resolvedPackName != null ? resolvedPackName : "Web Pack";
                 Log.d(Config.LOGTAG, "WebStickerPack: installing pack=" + packName + " stickers=" + stickers.size());
                 final StickerStore.Pack pack = StickerStore.createPack(this, packName);
+                if (resolvedSlug != null) {
+                    StickerStore.setPackSourceSlug(pack, resolvedSlug);
+                }
                 for (int i = 0; i < stickers.size(); i++) {
                     final StickerItem item = stickers.get(i);
                     Log.d(Config.LOGTAG, "WebStickerPack: downloading " + (i+1) + "/" + stickers.size() + " url=" + item.url());
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 966e399..820f5ca 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -675,6 +675,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 final String setName = tgStickerSet;
                 final String tgUrl = "tg://addstickers?set=" + setName;
                 card.setTag(tgUrl);
+                // A recycled view holder previously bound to a stickers.xmpp.tel link card would
+                // otherwise keep that card's wider forced width — see
+                // displayStickerWebLinkMessage().
+                setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
                 viewHolder.linkPreviewTitle().setText(
                         activity.getString(R.string.sticker_pack_title, setName));
                 viewHolder.linkPreviewDescription().setText(
@@ -759,6 +763,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
                 // Arm click listener regardless of which path we take below.
                 card.setTag(previewUrl);
+                // A recycled view holder previously bound to a stickers.xmpp.tel link card would
+                // otherwise keep that card's wider forced width — see
+                // displayStickerWebLinkMessage().
+                setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
                 card.setClickable(true);
                 card.setOnClickListener(v -> {
                     if (!previewUrl.equals(card.getTag())) return;
@@ -1089,6 +1097,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         final android.view.View card = viewHolder.linkPreviewCard();
         card.setTag(link);
+        // A recycled view holder previously bound to a stickers.xmpp.tel link card would
+        // otherwise keep that card's wider forced width — see
+        // displayStickerWebLinkMessage().
+        setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
 
         viewHolder.linkPreviewTitle().setText(R.string.jabjab_download_card_title);
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
@@ -1134,6 +1146,24 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             new java.util.HashSet<>(java.util.Arrays.asList(
                     "new", "explore", "api", "edit", "static", "favicon.svg", "robots.txt"));
 
+    /**
+     * Sets the shared link-preview card's width to an explicit pixel value, or restores
+     * MATCH_PARENT. The card sits inside a wrap_content bubble chain (message_box, and its
+     * inner wrapper in item_message_start/end.xml, are both wrap_content — see the TODO there
+     * about the never-ported max-width constraint). Under that ancestry, a match_parent card
+     * gets measured with an EXACT spec that ignores setMinimumWidth() entirely — confirmed via
+     * device testing, setMinimumWidth() had zero visible effect. Setting LayoutParams.width to
+     * a real pixel value (not the MATCH_PARENT/WRAP_CONTENT sentinels) is what actually works,
+     * since it no longer depends on the ambiguous wrap_content-vs-match_parent propagation.
+     */
+    private static void setCardWidth(final android.view.View card, final int widthPx) {
+        final var params = card.getLayoutParams();
+        if (params != null && params.width != widthPx) {
+            params.width = widthPx;
+            card.setLayoutParams(params);
+        }
+    }
+
     private static boolean isStickerWebLinkMessage(final Message message) {
         if (message.getType() != Message.TYPE_TEXT) return false;
         final String body = message.getBody();
@@ -1165,16 +1195,30 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         final String link = message.getBody().trim();
         final var card = viewHolder.linkPreviewCard();
         card.setTag(link);
+        // Reset the shared card sub-views explicitly. On a fresh bind these otherwise sit
+        // at their XML defaults (text placeholder skeleton VISIBLE, title GONE, image frame
+        // GONE) since only the generic OG-preview path used to manage them — that's why this
+        // card used to render as a small stuck grey skeleton with no visible title or image.
+        // A recycled view holder previously bound to a generic OG-preview message can also
+        // leave these in whatever state that path's own loading sequence left them in.
+        viewHolder.linkPreviewTextPlaceholder().setVisibility(View.GONE);
+        viewHolder.linkPreviewTitle().setVisibility(View.VISIBLE);
         viewHolder.linkPreviewTitle().setText(R.string.install_sticker_pack);
-        viewHolder.linkPreviewDescription().setVisibility(View.GONE);
-        viewHolder.linkPreviewImage().setVisibility(View.GONE);  // hide until real image loads
-        viewHolder.linkPreviewImage().setScaleType(android.widget.ImageView.ScaleType.FIT_CENTER);
-        viewHolder.linkPreviewImage().setAdjustViewBounds(true);
-        final int maxPx = (int) (200 * viewHolder.linkPreviewImage().getContext()
-                .getResources().getDisplayMetrics().density);
-        viewHolder.linkPreviewImage().setMaxHeight(maxPx);
-        viewHolder.linkPreviewImage().getLayoutParams().height =
-                android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+        viewHolder.linkPreviewDescription().setText(R.string.tap_to_install_sticker_pack);
+        viewHolder.linkPreviewDescription().setVisibility(View.VISIBLE);
+        // Force room for two 104dp columns — see setCardWidth() for why a plain width/minWidth
+        // change doesn't work here (this card sits inside a wrap_content bubble chain).
+        final int cardMinPx = (int) (256 * card.getContext().getResources().getDisplayMetrics().density);
+        setCardWidth(card, cardMinPx);
+        // Stickers use the dedicated multi-thumbnail row below, not the single-image frame
+        // (that one is sized/cropped for full-width blog/video thumbnails).
+        viewHolder.linkPreviewImageFrame().setVisibility(View.GONE);
+        viewHolder.linkPreviewImage().setVisibility(View.GONE);
+        viewHolder.linkPreviewStickerThumbsRow().setVisibility(View.GONE); // shown once stickers load
+        for (final ImageView thumb : viewHolder.linkPreviewStickerThumbs()) {
+            thumb.setVisibility(View.GONE);
+            thumb.setImageDrawable(null);
+        }
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
         card.setClickable(true);
         card.setOnClickListener(v -> {
@@ -1187,6 +1231,44 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         card.setVisibility(View.VISIBLE);
         // Fetch pack JSON directly for reliable preview (avoids OG HTML parsing issues)
         fetchStickerPackPreview(link, card, viewHolder);
+        updateStickerCardInstallState(link, card, viewHolder, message);
+    }
+
+    /**
+     * If the pack behind this stickers.xmpp.tel link is already installed locally (tracked via
+     * StickerStore's source_slug, written at install time by WebStickerPackActivity), switch the
+     * card from "Install sticker pack" to "Open sticker pack" and open the sticker picker instead
+     * of re-installing. Checked on a background thread since it reads meta.json for every locally
+     * installed pack.
+     */
+    private void updateStickerCardInstallState(
+            final String link,
+            final android.view.View card,
+            final BubbleMessageItemViewHolder viewHolder,
+            final Message message) {
+        final String path = android.net.Uri.parse(link).getPath();
+        final String slug = (path != null && path.length() > 1) ? path.substring(1).split("/")[0] : "";
+        if (slug.isEmpty()) return;
+        new Thread(() -> {
+            final var installedPack =
+                    tel.xmpp.jabjab.ui.util.StickerStore.packForSlug(activity, slug);
+            if (installedPack == null) return;
+            activity.runOnUiThread(() -> {
+                if (!link.equals(card.getTag())) return; // view recycled onto a different message
+                // applyStickerPreview() (the JSON-fetch path) only ever touches the title/thumbs,
+                // never the description, so these two async updates can't race each other.
+                viewHolder.linkPreviewDescription().setText(R.string.tap_to_open_sticker_pack);
+                viewHolder.linkPreviewDescription().setVisibility(View.VISIBLE);
+                card.setOnClickListener(v -> {
+                    if (!link.equals(card.getTag())) return;
+                    final android.content.Intent intent =
+                            new android.content.Intent(activity, tel.xmpp.jabjab.ui.StickerPickerActivity.class);
+                    intent.putExtra(tel.xmpp.jabjab.ui.StickerPickerActivity.EXTRA_ACCOUNT_JID,
+                            message.getConversation().getAccount().getJid().toString());
+                    activity.startActivity(intent);
+                });
+            });
+        }).start();
     }
 
     private static final java.util.concurrent.ConcurrentHashMap<String, Object[]> stickerPreviewCache =
@@ -1203,7 +1285,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         final String apiUrl = "https://stickers.xmpp.tel/api/packs/" + slug;
         final Object[] hit = stickerPreviewCache.get(apiUrl);
         if (hit != null) {
-            applyStickerPreview(card, viewHolder, link, (String) hit[0], (String) hit[1]);
+            @SuppressWarnings("unchecked")
+            final java.util.List<String> cachedUrls = (java.util.List<String>) hit[1];
+            applyStickerPreview(card, viewHolder, link, (String) hit[0], cachedUrls);
             return;
         }
         tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.fetchJson(activity, apiUrl, json -> {
@@ -1211,12 +1295,16 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             if (json == null) return;
             final String name = json.optString("name", "Sticker Pack");
             final org.json.JSONArray stickers = json.optJSONArray("stickers");
-            final org.json.JSONObject firstSticker = (stickers != null && stickers.length() > 0)
-                    ? stickers.optJSONObject(0) : null;
-            final String imageUrl = firstSticker != null
-                    ? firstSticker.optString("url", null) : null;
-            stickerPreviewCache.put(apiUrl, new Object[]{name, imageUrl});
-            applyStickerPreview(card, viewHolder, link, name, imageUrl);
+            final java.util.List<String> imageUrls = new java.util.ArrayList<>();
+            if (stickers != null) {
+                for (int i = 0; i < stickers.length() && imageUrls.size() < 4; i++) {
+                    final org.json.JSONObject s = stickers.optJSONObject(i);
+                    final String url = s != null ? s.optString("url", null) : null;
+                    if (url != null && !url.isEmpty()) imageUrls.add(url);
+                }
+            }
+            stickerPreviewCache.put(apiUrl, new Object[]{name, imageUrls});
+            applyStickerPreview(card, viewHolder, link, name, imageUrls);
         });
     }
 
@@ -1225,14 +1313,27 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             final BubbleMessageItemViewHolder viewHolder,
             final String link,
             final String name,
-            final String imageUrl) {
+            final java.util.List<String> imageUrls) {
         if (!link.equals(card.getTag())) return;
         if (name != null && !name.isEmpty()) {
             viewHolder.linkPreviewTitle().setText(name);
         }
-        if (imageUrl != null && !imageUrl.isEmpty()) {
-            viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
-            tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(imageUrl, viewHolder.linkPreviewImage());
+        if (imageUrls == null || imageUrls.isEmpty()) return;
+        final ImageView[] thumbs = viewHolder.linkPreviewStickerThumbs();
+        // The row is GONE by default/on reset — without making it visible here too, the
+        // thumbnails inside it never actually render regardless of their own visibility,
+        // since a GONE parent skips measuring/drawing its children entirely.
+        viewHolder.linkPreviewStickerThumbsRow().setVisibility(View.VISIBLE);
+        for (int i = 0; i < thumbs.length; i++) {
+            if (i < imageUrls.size()) {
+                thumbs[i].setVisibility(View.VISIBLE);
+                tel.xmpp.jabjab.ui.util.LinkPreviewFetcher.loadImage(imageUrls.get(i), thumbs[i]);
+            } else {
+                // INVISIBLE, not GONE — reserves the cell's space so a pack with fewer than 4
+                // stickers still renders a full, evenly-shaped 2x2 grid instead of a lopsided
+                // row (GONE would collapse the whole second row when there are only 2 stickers).
+                thumbs[i].setVisibility(View.INVISIBLE);
+            }
         }
     }
 
@@ -1259,13 +1360,25 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         final String link = message.getBody().trim();
         final var card = viewHolder.linkPreviewCard();
         card.setTag(link);
+        // Reset shared card sub-views explicitly — see the identical comment in
+        // displayStickerWebLinkMessage() for why this is needed (XML defaults / stale state
+        // left over from a recycled view holder previously showing the generic OG-preview
+        // path's own loading skeleton otherwise leave this card stuck looking empty).
+        viewHolder.linkPreviewTextPlaceholder().setVisibility(View.GONE);
+        viewHolder.linkPreviewTitle().setVisibility(View.VISIBLE);
         // Show card immediately with placeholder
         viewHolder.linkPreviewTitle().setText("Blog post");
         viewHolder.linkPreviewDescription().setVisibility(View.GONE); // hide description until OG data
+        viewHolder.linkPreviewImageFrame().setVisibility(View.VISIBLE);
+        viewHolder.linkPreviewImagePlaceholder().setVisibility(View.GONE);
         viewHolder.linkPreviewImage().setVisibility(View.VISIBLE);
         viewHolder.linkPreviewImage().setScaleType(android.widget.ImageView.ScaleType.CENTER_CROP);
         viewHolder.linkPreviewImage().setImageResource(android.R.drawable.ic_menu_gallery);
         viewHolder.linkPreviewPlay().setVisibility(View.GONE);
+        // Reset back to the XML default — a recycled view holder previously bound to a sticker
+        // link card would otherwise keep that card's wider forced width (see
+        // displayStickerWebLinkMessage()).
+        setCardWidth(card, android.view.ViewGroup.LayoutParams.MATCH_PARENT);
         card.setMinimumHeight(160);
         card.setClickable(true);
         // Open in-app via BlogPostActivity deep link
@@ -1397,36 +1510,13 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             activity.loadBitmap(message, viewHolder.image());
         }
         viewHolder.image().setOnClickListener(v -> openDownloadable(message));
-        if ("image/gif".equals(message.getMimeType())) {
-            viewHolder.image().setOnLongClickListener(v -> {
-                final android.widget.PopupMenu popup = new android.widget.PopupMenu(activity, v);
-                popup.getMenu().add(R.string.save_gif_to_favorites);
-                popup.setOnMenuItemClickListener(item -> {
-                    final java.io.File src = getFileBackend().getFile(message);
-                    if (src == null || !src.exists()) return true;
-                    new Thread(() -> {
-                        try {
-                            final java.io.File saved =
-                                    tel.xmpp.jabjab.ui.util.GifStore.save(activity, src);
-                            final boolean alreadyHad = saved.lastModified() < System.currentTimeMillis() - 2000;
-                            activity.runOnUiThread(() -> android.widget.Toast.makeText(
-                                    activity,
-                                    alreadyHad
-                                            ? R.string.gif_already_saved
-                                            : R.string.gif_saved_to_favorites,
-                                    android.widget.Toast.LENGTH_SHORT).show());
-                        } catch (java.io.IOException e) {
-                            activity.runOnUiThread(() -> android.widget.Toast.makeText(
-                                    activity, R.string.sticker_export_failed,
-                                    android.widget.Toast.LENGTH_SHORT).show());
-                        }
-                    }).start();
-                    return true;
-                });
-                popup.show();
-                return true;
-            });
-        }
+        // Long-press is intentionally NOT intercepted here anymore — it used to show a
+        // one-off PopupMenu with only "save to favorites", which fully swallowed the
+        // long-press and meant GIF messages never got the same context menu every other
+        // message has (copy link, share, forward, etc.). Now it bubbles up to the normal
+        // ListView item-long-click handling in ConversationFragment, which shows the full
+        // shared menu — "Save GIF to favorites" is one more conditional item there now
+        // (see populateContextMenu()/onContextItemSelected() for save_gif_to_favorites).
     }
 
     private void toggleWhisperInfo(
@@ -2741,6 +2831,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         protected abstract ImageView linkPreviewImage();
 
+        protected abstract android.view.View linkPreviewStickerThumbsRow();
+
+        protected abstract ImageView[] linkPreviewStickerThumbs();
+
         protected abstract TextView linkPreviewTitle();
 
         protected abstract TextView linkPreviewDescription();
@@ -2865,6 +2959,21 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             return this.binding.messageContent.linkPreviewImage;
         }
 
+        @Override
+        protected android.view.View linkPreviewStickerThumbsRow() {
+            return this.binding.messageContent.linkPreviewStickerThumbs;
+        }
+
+        @Override
+        protected ImageView[] linkPreviewStickerThumbs() {
+            return new ImageView[] {
+                this.binding.messageContent.linkPreviewStickerThumb1,
+                this.binding.messageContent.linkPreviewStickerThumb2,
+                this.binding.messageContent.linkPreviewStickerThumb3,
+                this.binding.messageContent.linkPreviewStickerThumb4,
+            };
+        }
+
         @Override
         protected TextView linkPreviewTitle() {
             return this.binding.messageContent.linkPreviewTitle;
@@ -2995,6 +3104,21 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             return this.binding.messageContent.linkPreviewImage;
         }
 
+        @Override
+        protected android.view.View linkPreviewStickerThumbsRow() {
+            return this.binding.messageContent.linkPreviewStickerThumbs;
+        }
+
+        @Override
+        protected ImageView[] linkPreviewStickerThumbs() {
+            return new ImageView[] {
+                this.binding.messageContent.linkPreviewStickerThumb1,
+                this.binding.messageContent.linkPreviewStickerThumb2,
+                this.binding.messageContent.linkPreviewStickerThumb3,
+                this.binding.messageContent.linkPreviewStickerThumb4,
+            };
+        }
+
         @Override
         protected TextView linkPreviewTitle() {
             return this.binding.messageContent.linkPreviewTitle;
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
index 2752c14..6254b56 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
@@ -491,6 +491,32 @@ public class StickerStore {
         return null;
     }
 
+    /** Records the stickers.xmpp.tel slug a pack was installed from, so a later share of the
+     * same pack's link can be recognized as "already installed" instead of offering to
+     * reinstall it. Best-effort — a failed write here shouldn't fail the whole install. */
+    public static void setPackSourceSlug(final Pack pack, final String slug) {
+        try {
+            JSONObject meta = readMeta(pack.dir());
+            if (meta == null) meta = new JSONObject();
+            meta.put("source_slug", slug);
+            meta.put("updated", System.currentTimeMillis());
+            writeMeta(pack.dir(), meta);
+        } catch (Exception e) {
+            Log.w(Config.LOGTAG, "STICKER setPackSourceSlug failed for pack=" + pack.displayName(), e);
+        }
+    }
+
+    public static Pack packForSlug(final Context context, final String slug) {
+        if (slug == null || slug.isEmpty()) return null;
+        for (final Pack pack : loadPacks(context)) {
+            final JSONObject meta = readMeta(pack.dir());
+            if (meta != null && slug.equals(meta.optString("source_slug", null))) {
+                return pack;
+            }
+        }
+        return null;
+    }
+
     // Upload URLs are keyed by sender bare JID so that a sticker sent from account A
     // on server X never reuses an upload URL that belongs to account B on server Y.
     public static String getStickerUploadUrl(final Pack pack, final File sticker,
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java b/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
index b10b560..4639590 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/TelegramStickerImporter.java
@@ -96,8 +96,17 @@ public final class TelegramStickerImporter {
                     conn.setReadTimeout(30_000);
                     if (conn.getResponseCode() != 200) continue;
                     try (InputStream is = conn.getInputStream()) {
-                        StickerStore.importSticker(pack, filename.isEmpty()
+                        final java.io.File dest = StickerStore.importSticker(pack, filename.isEmpty()
                                 ? "sticker_" + i + ".webp" : filename, is);
+                        final String emoji = sticker.optString("emoji", "");
+                        if (!emoji.isEmpty()) {
+                            try {
+                                StickerStore.setStickerEmoji(pack, dest, emoji);
+                            } catch (java.io.IOException ignored) {
+                                // Emoji metadata is best-effort — a failed write here
+                                // shouldn't abort the whole pack import.
+                            }
+                        }
                         count++;
                     }
                 }
diff --git a/src/main/res/drawable/bg_reaction_chip_selected.xml b/src/main/res/drawable/bg_reaction_chip_selected.xml
new file mode 100644
index 0000000..0c5c645
--- /dev/null
+++ b/src/main/res/drawable/bg_reaction_chip_selected.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <solid android:color="?attr/colorSecondaryContainer" />
+</shape>
diff --git a/src/main/res/drawable/ic_content_copy_24dp.xml b/src/main/res/drawable/ic_content_copy_24dp.xml
new file mode 100644
index 0000000..7a75806
--- /dev/null
+++ b/src/main/res/drawable/ic_content_copy_24dp.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?colorControlNormal"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
+</vector>
diff --git a/src/main/res/layout/item_message_content.xml b/src/main/res/layout/item_message_content.xml
index 91cf918..35c8bb7 100644
--- a/src/main/res/layout/item_message_content.xml
+++ b/src/main/res/layout/item_message_content.xml
@@ -111,6 +111,71 @@
 
                 </FrameLayout>
 
+                <!-- Sticker pack preview: up to 4 thumbnails as a 2x2 grid, sized to a fixed
+                     footprint (wrap_content + centered) rather than stretching to the message
+                     bubble's full width. Used only by the stickers.xmpp.tel link card (not the
+                     single-image frame above, which is sized/cropped for full-width blog/video
+                     thumbnails) — GONE by default and for every other message type. -->
+                <LinearLayout
+                    android:id="@+id/link_preview_sticker_thumbs"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:orientation="vertical"
+                    android:gravity="center"
+                    android:paddingTop="10dp"
+                    android:paddingBottom="4dp"
+                    android:visibility="gone">
+
+                    <LinearLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="horizontal"
+                        android:gravity="center">
+
+                        <ImageView
+                            android:id="@+id/link_preview_sticker_thumb_1"
+                            android:layout_width="104dp"
+                            android:layout_height="104dp"
+                            android:layout_margin="4dp"
+                            android:scaleType="fitCenter"
+                            android:visibility="gone" />
+
+                        <ImageView
+                            android:id="@+id/link_preview_sticker_thumb_2"
+                            android:layout_width="104dp"
+                            android:layout_height="104dp"
+                            android:layout_margin="4dp"
+                            android:scaleType="fitCenter"
+                            android:visibility="gone" />
+
+                    </LinearLayout>
+
+                    <LinearLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="horizontal"
+                        android:gravity="center">
+
+                        <ImageView
+                            android:id="@+id/link_preview_sticker_thumb_3"
+                            android:layout_width="104dp"
+                            android:layout_height="104dp"
+                            android:layout_margin="4dp"
+                            android:scaleType="fitCenter"
+                            android:visibility="gone" />
+
+                        <ImageView
+                            android:id="@+id/link_preview_sticker_thumb_4"
+                            android:layout_width="104dp"
+                            android:layout_height="104dp"
+                            android:layout_margin="4dp"
+                            android:scaleType="fitCenter"
+                            android:visibility="gone" />
+
+                    </LinearLayout>
+
+                </LinearLayout>
+
                 <LinearLayout
                     android:layout_width="match_parent"
                     android:layout_height="wrap_content"
diff --git a/src/main/res/layout/popup_message_action_item.xml b/src/main/res/layout/popup_message_action_item.xml
new file mode 100644
index 0000000..beea141
--- /dev/null
+++ b/src/main/res/layout/popup_message_action_item.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="48dp"
+    android:background="?attr/selectableItemBackground"
+    android:gravity="center_vertical"
+    android:minWidth="200dp"
+    android:orientation="horizontal"
+    android:paddingStart="16dp"
+    android:paddingEnd="24dp">
+
+    <ImageView
+        android:id="@+id/action_icon"
+        android:layout_width="22dp"
+        android:layout_height="22dp"
+        android:layout_marginEnd="20dp"
+        app:tint="?attr/colorOnSurfaceVariant" />
+
+    <TextView
+        android:id="@+id/action_text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:ellipsize="end"
+        android:maxLines="1"
+        android:textAppearance="?attr/textAppearanceBodyLarge"
+        android:textColor="?attr/colorOnSurface" />
+</LinearLayout>
diff --git a/src/main/res/layout/popup_message_actions.xml b/src/main/res/layout/popup_message_actions.xml
new file mode 100644
index 0000000..25af992
--- /dev/null
+++ b/src/main/res/layout/popup_message_actions.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:layout_margin="8dp"
+    app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
+    app:cardCornerRadius="12dp"
+    app:cardElevation="8dp"
+    app:strokeWidth="0dp">
+
+    <LinearLayout
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <!-- Quick-react strip: same recent-emoji list as the full "Add reaction" dialog,
+             just a one-tap shortcut for the most common case. "Add reaction" stays further
+             down as a normal menu row too — this doesn't replace it. Hidden entirely
+             (including the divider) when reactions aren't allowed on this message. -->
+        <LinearLayout
+            android:id="@+id/reaction_strip"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:gravity="center_vertical"
+            android:orientation="horizontal"
+            android:paddingStart="8dp"
+            android:paddingTop="4dp"
+            android:paddingEnd="8dp"
+            android:paddingBottom="4dp"
+            android:visibility="gone" />
+
+        <View
+            android:id="@+id/reaction_strip_divider"
+            android:layout_width="match_parent"
+            android:layout_height="1dp"
+            android:background="?attr/colorOutlineVariant"
+            android:visibility="gone" />
+
+        <ScrollView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:maxHeight="420dp"
+            android:scrollbars="none">
+
+            <LinearLayout
+                android:id="@+id/action_list"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:orientation="vertical"
+                android:paddingTop="6dp"
+                android:paddingBottom="6dp" />
+        </ScrollView>
+    </LinearLayout>
+</com.google.android.material.card.MaterialCardView>
diff --git a/src/main/res/layout/popup_reaction_item.xml b/src/main/res/layout/popup_reaction_item.xml
new file mode 100644
index 0000000..fd861e0
--- /dev/null
+++ b/src/main/res/layout/popup_reaction_item.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="40dp"
+    android:layout_height="40dp"
+    android:layout_margin="2dp"
+    android:clickable="true"
+    android:focusable="true"
+    android:foreground="?attr/selectableItemBackgroundBorderless"
+    android:gravity="center"
+    android:textSize="22sp" />
diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml
index 0a07171..267fd36 100644
--- a/src/main/res/menu/message_context.xml
+++ b/src/main/res/menu/message_context.xml
@@ -3,111 +3,141 @@
 
     <item
         android:id="@+id/action_select_message"
+        android:icon="@drawable/ic_check_circle_24dp"
         android:title="@string/select_messages"
         android:visible="false" />
 
     <item
         android:id="@+id/action_add_reaction"
+        android:icon="@drawable/ic_add_reaction_24dp"
         android:title="@string/add_reaction"
         android:visible="false" />
 
     <item
         android:id="@+id/moderation"
+        android:icon="@drawable/ic_delete_24dp"
         android:title="@string/moderate_delete"
         android:visible="false" />
 
     <item
         android:id="@+id/action_report_and_block"
+        android:icon="@drawable/ic_report_24dp"
         android:title="@string/report_spam"
         android:visible="false" />
 
     <item
         android:id="@+id/open_with"
+        android:icon="@drawable/ic_open_with_24dp"
         android:title="@string/open_with"
         android:visible="false" />
 
     <item
         android:id="@+id/share_with"
+        android:icon="@drawable/ic_share_24dp"
         android:title="@string/share_with"
         android:visible="false" />
     <item
         android:id="@+id/forward_to_contacts"
+        android:icon="@drawable/ic_group_24dp"
         android:title="@string/forward_to_contacts"
         android:visible="false" />
 
     <item
         android:id="@+id/copy_message"
+        android:icon="@drawable/ic_content_copy_24dp"
         android:title="@string/copy_to_clipboard"
         android:visible="false" />
 
     <item
         android:id="@+id/translate_message"
+        android:icon="@drawable/ic_translate_24dp"
         android:title="@string/translate_message"
         android:visible="false" />
 
     <item
         android:id="@+id/copy_link"
+        android:icon="@drawable/ic_link_24dp"
         android:title="@string/copy_link"
         android:visible="false" />
     <item
         android:id="@+id/quote_message"
+        android:icon="@drawable/ic_reply_24dp"
         android:title="@string/quote"
         android:visible="false" />
 
     <item
         android:id="@+id/retry_decryption"
+        android:icon="@drawable/ic_lock_open_outline_24dp"
         android:title="@string/retry_decryption"
         android:visible="false" />
     <item
         android:id="@+id/correct_message"
+        android:icon="@drawable/ic_edit_24dp"
         android:title="@string/correct_message"
         android:visible="false" />
     <item
         android:id="@+id/retract_message"
+        android:icon="@drawable/ic_cancel_24dp"
         android:title="@string/retract_message"
         android:visible="false" />
     <item
         android:id="@+id/copy_url"
+        android:icon="@drawable/ic_link_24dp"
         android:title="@string/copy_original_url"
         android:visible="false" />
     <item
         android:id="@+id/share_upload_link"
+        android:icon="@drawable/ic_share_24dp"
         android:title="@string/share_upload_link"
         android:visible="false" />
     <item
         android:id="@+id/show_error_message"
+        android:icon="@drawable/ic_error_24dp"
         android:title="@string/show_error_message"
         android:visible="false" />
     <item
         android:id="@+id/send_again"
+        android:icon="@drawable/ic_refresh_24dp"
         android:title="@string/send_again"
         android:visible="false" />
     <item
         android:id="@+id/send_again_as_p2p"
+        android:icon="@drawable/ic_p2p_24dp"
         android:title="@string/retry_with_p2p"
         android:visible="false" />
     <item
         android:id="@+id/download_file"
+        android:icon="@drawable/ic_download_24dp"
         android:title="@string/download_x_file"
         android:visible="false" />
     <item
         android:id="@+id/cancel_transmission"
+        android:icon="@drawable/ic_cancel_24dp"
         android:title="@string/cancel_transmission"
         android:visible="false" />
     <item
         android:id="@+id/save_file"
+        android:icon="@drawable/ic_save_24dp"
         android:title="@string/save"
         android:visible="false" />
     <item
         android:id="@+id/save_to_stickers"
+        android:icon="@drawable/ic_sticky_note_24dp"
         android:title="@string/save_to_stickers"
         android:visible="false" />
+    <item
+        android:id="@+id/save_gif_to_favorites"
+        android:icon="@drawable/ic_gif_24dp"
+        android:title="@string/save_gif_to_favorites"
+        android:visible="false" />
     <item
         android:id="@+id/action_install_sticker_pack"
+        android:icon="@drawable/ic_add_24dp"
         android:title="@string/install_sticker_pack_from_peer"
         android:visible="false" />
     <item
         android:id="@+id/delete_file"
+        android:icon="@drawable/ic_delete_24dp"
         android:title="@string/delete_x_file"
         android:visible="false" />
-</menu>
\ No newline at end of file
+</menu>
diff --git a/src/main/res/values/defaults.xml b/src/main/res/values/defaults.xml
index b6f201d..12d14f5 100644
--- a/src/main/res/values/defaults.xml
+++ b/src/main/res/values/defaults.xml
@@ -41,6 +41,7 @@
     <bool name="send_crash_reports">true</bool>
     <bool name="validate_hostname">false</bool>
     <bool name="scroll_to_bottom">true</bool>
+    <bool name="message_menu_haptic_feedback">true</bool>
     <string name="omemo_setting_default">default_on</string>
     <bool name="start_searching">false</bool>
     <string name="video_compression">480</string>
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 0374b52..bfc8afb 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -114,6 +114,9 @@
     <string name="export_name_hint">Sticker set name</string>
     <string name="download_sticker_pack">Download sticker pack</string>
     <string name="install_sticker_pack">Install sticker pack</string>
+    <string name="open_sticker_pack">Open sticker pack</string>
+    <string name="tap_to_install_sticker_pack">Tap to install sticker pack</string>
+    <string name="tap_to_open_sticker_pack">Tap to open sticker pack</string>
     <string name="install_sticker_pack_from_peer">Install sender\'s sticker pack</string>
     <string name="sticker_pack_installed">Sticker pack installed</string>
     <string name="sticker_pack_fetch_failed">Failed to install sticker pack</string>
@@ -842,6 +845,8 @@
     <string name="p1_s3_filetransfer">HTTP File Sharing for S3</string>
     <string name="pref_start_search">Direct Search</string>
     <string name="pref_start_search_summary">At ‘New chat’ screen open keyboard and place cursor in search field</string>
+    <string name="pref_message_menu_haptic_feedback">Haptic feedback</string>
+    <string name="pref_message_menu_haptic_feedback_summary">Vibrate when opening the message menu or picking a quick reaction</string>
     <string name="group_chat_avatar">Group chat avatar</string>
     <string name="host_does_not_support_group_chat_avatars">Host does not support group chat avatars</string>
     <string name="only_the_owner_can_change_group_chat_avatar">Only the owner can change group chat avatar</string>
diff --git a/src/main/res/xml/preferences_interface.xml b/src/main/res/xml/preferences_interface.xml
index b808743..6244558 100644
--- a/src/main/res/xml/preferences_interface.xml
+++ b/src/main/res/xml/preferences_interface.xml
@@ -66,6 +66,12 @@
             android:key="start_searching"
             android:summary="@string/pref_start_search_summary"
             android:title="@string/pref_start_search" />
+        <SwitchPreferenceCompat
+            android:defaultValue="@bool/message_menu_haptic_feedback"
+            android:icon="@drawable/ic_vibration_24dp"
+            android:key="message_menu_haptic_feedback"
+            android:summary="@string/pref_message_menu_haptic_feedback_summary"
+            android:title="@string/pref_message_menu_haptic_feedback" />
     </PreferenceCategory>
     <PreferenceCategory android:title="@string/pref_keyboard_options">
         <SwitchPreferenceCompat

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.