move bubbles from same sender closer together

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/Config.java                    |  5 
src/main/java/eu/siacs/conversations/entities/Message.java          |  3 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java | 95 
src/main/res/layout/item_message_received.xml                       | 10 
src/main/res/layout/item_message_sent.xml                           | 10 
src/main/res/values/dimens.xml                                      |  5 
6 files changed, 107 insertions(+), 21 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/Config.java 🔗

@@ -2,11 +2,9 @@ package eu.siacs.conversations;
 
 import android.graphics.Bitmap;
 import android.net.Uri;
-
 import eu.siacs.conversations.crypto.XmppDomainVerifier;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
-
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -81,7 +79,6 @@ public final class Config {
     public static final int CONNECT_DISCO_TIMEOUT = 20;
     public static final int MINI_GRACE_PERIOD = 750;
 
-
     // media file formats. Homogenous Android or Conversations only deployments can switch to opus
     // and webp
     public static final int AVATAR_SIZE = 192;
@@ -94,7 +91,7 @@ public final class Config {
 
     public static final boolean USE_OPUS_VOICE_MESSAGES = false;
 
-    public static final int MESSAGE_MERGE_WINDOW = 20;
+    public static final int MESSAGE_MERGE_WINDOW = 90_000;
 
     public static final int PAGE_SIZE = 50;
     public static final int MAX_NUM_PAGES = 3;

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -615,8 +615,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 return this.remoteMsgId == null
                         && matchingCounterpart
                         && body.equals(otherBody)
-                        && Math.abs(this.getTimeSent() - message.getTimeSent())
-                                < Config.MESSAGE_MERGE_WINDOW * 1000;
+                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < 20_000;
             }
         }
     }

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -29,6 +29,7 @@ import androidx.annotation.ColorInt;
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.core.app.ActivityCompat;
 import androidx.core.content.ContextCompat;
 import androidx.core.widget.ImageViewCompat;
@@ -152,7 +153,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         return 5;
     }
 
-    private int getItemViewType(Message message) {
+    private static int getItemViewType(final Message message) {
         if (message.getType() == Message.TYPE_STATUS) {
             if (DATE_SEPARATOR_BODY.equals(message.getBody())) {
                 return DATE_SEPARATOR;
@@ -169,8 +170,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     }
 
     @Override
-    public int getItemViewType(int position) {
-        return this.getItemViewType(getItem(position));
+    public int getItemViewType(final int position) {
+        return getItemViewType(getItem(position));
     }
 
     private void displayStatus(
@@ -729,7 +730,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 message.isValidInSession() && (!omemoEncryption || message.isTrusted());
         final Conversational conversation = message.getConversation();
         final Account account = conversation.getAccount();
-        final int type = getItemViewType(position);
+        final int type = getItemViewType(message);
         ViewHolder viewHolder;
         if (view == null) {
             viewHolder = new ViewHolder();
@@ -753,6 +754,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     view =
                             activity.getLayoutInflater()
                                     .inflate(R.layout.item_message_sent, parent, false);
+                    viewHolder.root = (ConstraintLayout) view;
                     viewHolder.message_box = view.findViewById(R.id.message_box);
                     viewHolder.contact_picture = view.findViewById(R.id.message_photo);
                     viewHolder.download_button = view.findViewById(R.id.download_button);
@@ -769,6 +771,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     view =
                             activity.getLayoutInflater()
                                     .inflate(R.layout.item_message_received, parent, false);
+                    viewHolder.root = (ConstraintLayout) view;
                     viewHolder.message_box = view.findViewById(R.id.message_box);
                     viewHolder.contact_picture = view.findViewById(R.id.message_photo);
                     viewHolder.download_button = view.findViewById(R.id.download_button);
@@ -901,7 +904,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 } else if (message.getCounterpart() != null
                         || message.getTrueCounterpart() != null
                         || (message.getCounterparts() != null
-                                && message.getCounterparts().size() > 0)) {
+                                && !message.getCounterparts().isEmpty())) {
                     showAvatar = true;
                     AvatarWorkerTask.loadAvatar(
                             message, viewHolder.contact_picture, R.dimen.avatar_on_status_message);
@@ -917,6 +920,12 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             }
             return view;
         } else {
+            // sent and received bubbles
+            final var mergeIntoTop = mergeIntoTop(position, message);
+            final var mergeIntoBottom = mergeIntoBottom(position, message);
+            final var requiresAvatar = type == SENT ? !mergeIntoBottom : !mergeIntoTop;
+            setBubblePadding(viewHolder.root, mergeIntoTop, mergeIntoBottom);
+            setRequiresAvatar(viewHolder, requiresAvatar);
             viewHolder.message_box.setClipToOutline(true);
             AvatarWorkerTask.loadAvatar(message, viewHolder.contact_picture, R.dimen.avatar);
         }
@@ -1075,6 +1084,81 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         return view;
     }
 
+    private void setBubblePadding(
+            final ConstraintLayout root,
+            final boolean mergeIntoTop,
+            final boolean mergeIntoBottom) {
+        final var resources = root.getResources();
+        final var horizontal = resources.getDimensionPixelSize(R.dimen.bubble_horizontal_padding);
+        final int top =
+                resources.getDimensionPixelSize(
+                        mergeIntoTop
+                                ? R.dimen.bubble_vertical_padding_minimum
+                                : R.dimen.bubble_vertical_padding);
+        final int bottom =
+                resources.getDimensionPixelSize(
+                        mergeIntoBottom
+                                ? R.dimen.bubble_vertical_padding_minimum
+                                : R.dimen.bubble_vertical_padding);
+        root.setPadding(horizontal, top, horizontal, bottom);
+    }
+
+    private void setRequiresAvatar(final ViewHolder viewHolder, final boolean requiresAvatar) {
+        final var layoutParams = viewHolder.contact_picture.getLayoutParams();
+        if (requiresAvatar) {
+            final var resources = viewHolder.contact_picture.getResources();
+            final var avatarSize = resources.getDimensionPixelSize(R.dimen.bubble_avatar_size);
+            layoutParams.height = avatarSize;
+            viewHolder.contact_picture.setVisibility(View.VISIBLE);
+            viewHolder.message_box.setMinimumHeight(avatarSize);
+        } else {
+            layoutParams.height = 0;
+            viewHolder.contact_picture.setVisibility(View.INVISIBLE);
+            viewHolder.message_box.setMinimumHeight(0);
+        }
+        viewHolder.contact_picture.setLayoutParams(layoutParams);
+    }
+
+    private boolean mergeIntoTop(final int position, final Message message) {
+        if (position < 0) {
+            return false;
+        }
+        final var top = getItem(position - 1);
+        return merge(top, message);
+    }
+
+    private boolean mergeIntoBottom(final int position, final Message message) {
+        final Message bottom;
+        try {
+            bottom = getItem(position + 1);
+        } catch (final IndexOutOfBoundsException e) {
+            return false;
+        }
+        return merge(message, bottom);
+    }
+
+    private static boolean merge(final Message a, final Message b) {
+        if (getItemViewType(a) != getItemViewType(b)) {
+            return false;
+        }
+        if (a.getConversation().getMode() == Conversation.MODE_MULTI
+                && a.getStatus() == Message.STATUS_RECEIVED) {
+            final var occupantIdA = a.getOccupantId();
+            final var occupantIdB = b.getOccupantId();
+            if (occupantIdA != null && occupantIdB != null) {
+                if (!occupantIdA.equals(occupantIdB)) {
+                    return false;
+                }
+            }
+            final var counterPartA = a.getCounterpart();
+            final var counterPartB = b.getCounterpart();
+            if (counterPartA == null || !counterPartA.equals(counterPartB)) {
+                return false;
+            }
+        }
+        return b.getTimeSent() - a.getTimeSent() <= Config.MESSAGE_MERGE_WINDOW;
+    }
+
     private boolean showDetailedReaction(final Message message, final String emoji) {
         final var c = message.getConversation();
         if (c instanceof Conversation conversation && c.getMode() == Conversational.MODE_MULTI) {
@@ -1300,6 +1384,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
     private static class ViewHolder {
 
+        private ConstraintLayout root;
         public MaterialButton load_more_messages;
         public ImageView edit_indicator;
         public RelativeLayout audioPlayer;

src/main/res/layout/item_message_received.xml 🔗

@@ -6,13 +6,13 @@
     <androidx.constraintlayout.widget.ConstraintLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:paddingHorizontal="8dp"
-        android:paddingVertical="4dp">
+        android:paddingHorizontal="@dimen/bubble_horizontal_padding"
+        android:paddingVertical="@dimen/bubble_vertical_padding">
 
         <com.makeramen.roundedimageview.RoundedImageView
             android:id="@+id/message_photo"
-            android:layout_width="48dp"
-            android:layout_height="48dp"
+            android:layout_width="@dimen/bubble_avatar_size"
+            android:layout_height="@dimen/bubble_avatar_size"
             android:scaleType="fitXY"
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintTop_toTopOf="@id/message_box"
@@ -27,7 +27,7 @@
             android:background="@drawable/background_message_bubble"
             android:backgroundTint="?colorTertiaryContainer"
             android:longClickable="true"
-            android:minHeight="48dp"
+            android:minHeight="@dimen/bubble_avatar_size"
             app:layout_constrainedWidth="true"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintHorizontal_bias="0.0"

src/main/res/layout/item_message_sent.xml 🔗

@@ -5,13 +5,13 @@
     <androidx.constraintlayout.widget.ConstraintLayout
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:paddingHorizontal="8dp"
-        android:paddingVertical="4dp">
+        android:paddingHorizontal="@dimen/bubble_horizontal_padding"
+        android:paddingVertical="@dimen/bubble_vertical_padding">
 
         <com.makeramen.roundedimageview.RoundedImageView
             android:id="@+id/message_photo"
-            android:layout_width="48dp"
-            android:layout_height="48dp"
+            android:layout_width="@dimen/bubble_avatar_size"
+            android:layout_height="@dimen/bubble_avatar_size"
             android:scaleType="fitXY"
             app:layout_constraintBottom_toBottomOf="@id/message_box"
             app:layout_constraintEnd_toEndOf="parent"
@@ -26,7 +26,7 @@
             android:background="@drawable/background_message_bubble"
             android:backgroundTint="?colorSecondaryContainer"
             android:longClickable="true"
-            android:minHeight="48dp"
+            android:minHeight="@dimen/bubble_avatar_size"
             app:layout_constrainedWidth="true"
             app:layout_constraintEnd_toStartOf="@id/message_photo"
             app:layout_constraintHorizontal_bias="1.0"

src/main/res/values/dimens.xml 🔗

@@ -42,4 +42,9 @@
     <dimen name="local_video_preview_height">128dp</dimen>
     <dimen name="local_video_preview_width">96dp</dimen>
     <dimen name="rtp_session_duration_top_margin">24dp</dimen>
+
+    <dimen name="bubble_horizontal_padding">8dp</dimen>
+    <dimen name="bubble_vertical_padding">4dp</dimen>
+    <dimen name="bubble_vertical_padding_minimum">1dp</dimen>
+    <dimen name="bubble_avatar_size">48dp</dimen>
 </resources>