Merge branch 'threads'

Stephen Paul Weber created

* threads:
  When locked, just unlock
  Update default thread on scroll explicitly
  Set thread from attachments and direct reply
  Option to lock a specific thread
  Tap message bubble to set thread
  Long press to clear thread
  Prevent thread from changing after user selects manually
  Show thread marker, send thread in stanza, allow replying and starting new thread
  Show identicon for thread when present
  Store thread data on incoming messages

Change summary

build.gradle                                                             |  1 
src/cheogram/res/values/strings.xml                                      |  1 
src/cheogram/res/values/themes.xml                                       |  2 
src/main/java/eu/siacs/conversations/entities/Conversation.java          | 31 
src/main/java/eu/siacs/conversations/entities/Message.java               | 25 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java     |  3 
src/main/java/eu/siacs/conversations/parser/MessageParser.java           |  4 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  4 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 96 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      | 35 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                 | 12 
src/main/res/layout/account_row.xml                                      |  1 
src/main/res/layout/fragment_conversation.xml                            | 33 
src/main/res/layout/message_received.xml                                 |  9 
src/main/res/layout/message_sent.xml                                     | 52 
src/main/res/menu/message_context.xml                                    |  5 
src/main/res/values/attrs.xml                                            |  1 
17 files changed, 276 insertions(+), 39 deletions(-)

Detailed changes

build.gradle 🔗

@@ -98,6 +98,7 @@ dependencies {
     implementation 'com.github.ipld:java-cid:v1.3.1'
     implementation 'com.splitwise:tokenautocomplete:3.0.2'
     implementation 'me.saket:better-link-movement-method:2.2.0'
+    implementation 'com.github.singpolyma:android-identicons:master-SNAPSHOT'
     implementation 'org.snikket:webrtc-android:107.0.0'
     // INSERT
 }

src/cheogram/res/values/strings.xml 🔗

@@ -25,6 +25,7 @@
     <string name="action_execute">Go</string>
     <string name="pref_theme_oledblack">OLED Black</string>
     <string name="invite_to_app">Invite to Chat</string>
+    <string name="only_this_thread">Show only this thread</string>
     <string name="pref_dialler_integration_incoming">Use Phone Accounts for Incoming Calls</string>
     <string name="pref_dialler_integration_incoming_summary">Incoming calls from phone numbers may ring with your system dialler instead of this app\'s notification settings</string>
 </resources>

src/cheogram/res/values/themes.xml 🔗

@@ -121,6 +121,7 @@
         <item name="icon_help" type="reference">@drawable/ic_help_white_24dp</item>
         <item name="icon_goto_chat" type="reference">@drawable/ic_question_answer_white_24dp</item>
         <item name="icon_secure" type="reference">@drawable/ic_lock_open_white_24dp</item>
+        <item name="icon_small_lock" type="reference">@drawable/ic_lock_black_18dp</item>
         <item name="icon_settings" type="reference">@drawable/ic_settings_black_24dp</item>
         <item name="icon_share" type="reference">@drawable/ic_share_white_24dp</item>
         <item name="ic_cloud_download" type="reference">@drawable/ic_cloud_download_white_24dp
@@ -275,6 +276,7 @@
         <item name="icon_help" type="reference">@drawable/ic_help_white_24dp</item>
         <item name="icon_goto_chat" type="reference">@drawable/ic_question_answer_white_24dp</item>
         <item name="icon_secure" type="reference">@drawable/ic_lock_open_white_24dp</item>
+        <item name="icon_small_lock" type="reference">@drawable/ic_lock_white_18dp</item>
         <item name="icon_settings" type="reference">@drawable/ic_settings_white_24dp</item>
         <item name="icon_share" type="reference">@drawable/ic_share_white_24dp</item>
         <item name="ic_cloud_download" type="reference">@drawable/ic_cloud_download_white_24dp

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

@@ -148,6 +148,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     private String mFirstMamReference = null;
     protected int mCurrentTab = -1;
     protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter();
+    protected Element thread = null;
+    protected boolean lockThread = false;
+    protected boolean userSelectedThread = false;
 
     public Conversation(final String name, final Account account, final Jid contactJid,
                         final int mode) {
@@ -529,7 +532,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             messages.addAll(this.messages);
         }
         for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
-            if (iterator.next().wasMergedIntoPrevious()) {
+            Message m = iterator.next();
+            if (m.wasMergedIntoPrevious() || (getLockThread() && (m.getThread() == null || !m.getThread().getContent().equals(getThread().getContent())))) {
                 iterator.remove();
             }
         }
@@ -628,6 +632,31 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         this.draftMessage = draftMessage;
     }
 
+    public Element getThread() {
+        return this.thread;
+    }
+
+    public void setThread(Element thread) {
+        this.thread = thread;
+    }
+
+    public void setLockThread(boolean flag) {
+        this.lockThread = flag;
+        if (flag) setUserSelectedThread(true);
+    }
+
+    public boolean getLockThread() {
+        return this.lockThread;
+    }
+
+    public void setUserSelectedThread(boolean flag) {
+        this.userSelectedThread = flag;
+    }
+
+    public boolean getUserSelectedThread() {
+        return this.userSelectedThread;
+    }
+
     public boolean isRead() {
         synchronized (this.messages) {
             for(final Message message : Lists.reverse(this.messages)) {

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

@@ -418,6 +418,23 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         this.subject = subject;
     }
 
+    public Element getThread() {
+        if (this.payloads == null) return null;
+
+        for (Element el : this.payloads) {
+            if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) {
+                return el;
+            }
+        }
+
+        return null;
+    }
+
+    public void setThread(Element thread) {
+        payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client"));
+        addPayload(thread);
+    }
+
     public void setMucUser(MucOptions.User user) {
         this.user = new WeakReference<>(user);
     }
@@ -907,9 +924,15 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public void addPayload(Element el) {
+        if (el == null) return;
+
         this.payloads.add(el);
     }
 
+    public List<Element> getPayloads() {
+       return new ArrayList<>(this.payloads);
+    }
+
     public Element getHtml() {
         if (this.payloads == null) return null;
 
@@ -920,7 +943,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         }
 
         return null;
-    }
+   }
 
     public List<Element> getCommands() {
         if (this.payloads == null) return null;

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java 🔗

@@ -62,6 +62,9 @@ public class MessageGenerator extends AbstractGenerator {
         if (message.edited()) {
             packet.addChild("replace", "urn:xmpp:message-correct:0").setAttribute("id", message.getEditedIdWireFormat());
         }
+        for (Element el : message.getPayloads()) {
+            packet.addChild(el);
+        }
         return packet;
     }
 

src/main/java/eu/siacs/conversations/parser/MessageParser.java 🔗

@@ -610,6 +610,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
                 if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) {
                     message.addPayload(el);
                 }
+                if (el.getName().equals("thread") && (el.getNamespace() == null || el.getNamespace().equals("jabber:client"))) {
+                    el.setAttribute("xmlns", "jabber:client");
+                    message.addPayload(el);
+                }
             }
             if (conversationMultiMode) {
                 message.setMucUser(conversation.getMucOptions().findUserByFullJid(counterpart));

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -568,6 +568,7 @@ public class XmppConnectionService extends Service {
             encryption = Message.ENCRYPTION_DECRYPTED;
         }
         Message message = new Message(conversation, uri.toString(), encryption);
+        message.setThread(conversation.getThread());
         Message.configurePrivateMessage(message);
         if (encryption == Message.ENCRYPTION_DECRYPTED) {
             getPgpEngine().encrypt(message, callback);
@@ -584,6 +585,7 @@ public class XmppConnectionService extends Service {
         } else {
             message = new Message(conversation, "", conversation.getNextEncryption());
         }
+        message.setThread(conversation.getThread());
         if (!Message.configurePrivateFileMessage(message)) {
             message.setCounterpart(conversation.getNextCounterpart());
             message.setType(Message.TYPE_FILE);
@@ -616,6 +618,7 @@ public class XmppConnectionService extends Service {
         } else {
             message = new Message(conversation, "", conversation.getNextEncryption());
         }
+        message.setThread(conversation.getThread());
         if (!Message.configurePrivateFileMessage(message)) {
             message.setCounterpart(conversation.getNextCounterpart());
             message.setType(Message.TYPE_IMAGE);
@@ -980,6 +983,7 @@ public class XmppConnectionService extends Service {
     private void directReply(final Conversation conversation, final String body, final String lastMessageUuid, final boolean dismissAfterReply) {
         final Message inReplyTo = lastMessageUuid == null ? null : conversation.findMessageWithUuid(lastMessageUuid);
         final Message message = new Message(conversation, body, conversation.getNextEncryption());
+        if (inReplyTo != null) message.setThread(inReplyTo.getThread());
         if (inReplyTo != null && inReplyTo.isPrivateMessage()) {
             Message.configurePrivateMessage(message, inReplyTo.getCounterpart());
         }

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -264,6 +264,7 @@ public class ConversationFragment extends XmppFragment
                 @Override
                 public void onScrollStateChanged(AbsListView view, int scrollState) {
                     if (AbsListView.OnScrollListener.SCROLL_STATE_IDLE == scrollState) {
+                        updateThreadFromLastMessage();
                         fireReadEvent();
                     }
                 }
@@ -854,6 +855,7 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void sendMessage() {
+        conversation.setUserSelectedThread(false);
         if (mediaPreviewAdapter.hasAttachments()) {
             commitAttachments();
             return;
@@ -870,6 +872,7 @@ public class ConversationFragment extends XmppFragment
         final Message message;
         if (conversation.getCorrectingMessage() == null) {
             message = new Message(conversation, body, conversation.getNextEncryption());
+            message.setThread(conversation.getThread());
             Message.configurePrivateMessage(message);
         } else {
             message = conversation.getCorrectingMessage();
@@ -953,6 +956,8 @@ public class ConversationFragment extends XmppFragment
             this.binding.textinput.setHint(UIHelper.getMessageHint(getActivity(), conversation));
             getActivity().invalidateOptionsMenu();
         }
+
+        binding.messagesView.post(this::updateThreadFromLastMessage);
     }
 
     public void setupIme() {
@@ -1248,6 +1253,33 @@ public class ConversationFragment extends XmppFragment
                     new EditMessageActionModeCallback(this.binding.textinput));
         }
 
+        messageListAdapter.setOnMessageBoxClicked(message -> {
+            setThread(message.getThread());
+            conversation.setUserSelectedThread(true);
+        });
+
+        binding.threadIdenticonLayout.setOnClickListener(v -> {
+            boolean wasLocked = conversation.getLockThread();
+            conversation.setLockThread(false);
+            if (wasLocked) {
+                conversation.setUserSelectedThread(false);
+                refresh();
+                updateThreadFromLastMessage();
+            } else {
+                newThread();
+                conversation.setUserSelectedThread(true);
+            }
+        });
+
+        binding.threadIdenticonLayout.setOnLongClickListener(v -> {
+            boolean wasLocked = conversation.getLockThread();
+            conversation.setLockThread(false);
+            setThread(null);
+            conversation.setUserSelectedThread(true);
+            if (wasLocked) refresh();
+            return true;
+        });
+
         return binding.getRoot();
     }
 
@@ -1275,9 +1307,25 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void quoteMessage(Message message) {
+        setThread(message.getThread());
+        conversation.setUserSelectedThread(true);
         quoteText(MessageUtils.prepareQuote(message));
     }
 
+    private void setThread(Element thread) {
+        this.conversation.setThread(thread);
+        binding.threadIdenticon.setAlpha(0f);
+        binding.threadIdenticonLock.setVisibility(this.conversation.getLockThread() ? View.VISIBLE : View.GONE);
+        if (thread != null) {
+            final String threadId = thread.getContent();
+            if (threadId != null) {
+                binding.threadIdenticon.setAlpha(1f);
+                binding.threadIdenticon.setColor(UIHelper.getColorForName(threadId));
+                binding.threadIdenticon.setHash(UIHelper.identiconHash(threadId));
+            }
+        }
+    }
+
     @Override
     public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
         // This should cancel any remaining click events that would otherwise trigger links
@@ -1327,6 +1375,7 @@ public class ConversationFragment extends XmppFragment
             MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
             MenuItem correctMessage = menu.findItem(R.id.correct_message);
             MenuItem retractMessage = menu.findItem(R.id.retract_message);
+            MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread);
             MenuItem shareWith = menu.findItem(R.id.share_with);
             MenuItem sendAgain = menu.findItem(R.id.send_again);
             MenuItem copyUrl = menu.findItem(R.id.copy_url);
@@ -1334,6 +1383,7 @@ public class ConversationFragment extends XmppFragment
             MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
             MenuItem deleteFile = menu.findItem(R.id.delete_file);
             MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
+            onlyThisThread.setVisible(!conversation.getLockThread() && m.getThread() != null);
             final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
             final boolean showError =
                     m.getStatus() == Message.STATUS_SEND_FAILED
@@ -1470,6 +1520,11 @@ public class ConversationFragment extends XmppFragment
             case R.id.open_with:
                 openWith(selectedMessage);
                 return true;
+            case R.id.only_this_thread:
+                conversation.setLockThread(true);
+                setThread(selectedMessage.getThread());
+                refresh();
+                return true;
             default:
                 return super.onContextItemSelected(item);
         }
@@ -2116,7 +2171,29 @@ public class ConversationFragment extends XmppFragment
         }
     }
 
+    private void newThread() {
+        Element thread = new Element("thread", "jabber:client");
+        thread.setContent(UUID.randomUUID().toString());
+        setThread(thread);
+    }
+
+    private void updateThreadFromLastMessage() {
+        if (this.conversation != null && !this.conversation.getUserSelectedThread() && TextUtils.isEmpty(binding.textinput.getText())) {
+            Message message = getLastVisibleMessage();
+            if (message == null) {
+                newThread();
+            } else {
+                setThread(message.getThread());
+            }
+        }
+    }
+
     private String getLastVisibleMessageUuid() {
+        Message message =  getLastVisibleMessage();
+        return message == null ? null : message.getUuid();
+    }
+
+    private Message getLastVisibleMessage() {
         if (binding == null) {
             return null;
         }
@@ -2140,7 +2217,7 @@ public class ConversationFragment extends XmppFragment
                     while (message.next() != null && message.next().wasMergedIntoPrevious()) {
                         message = message.next();
                     }
-                    return message.getUuid();
+                    return message;
                 }
             }
         }
@@ -3684,13 +3761,9 @@ public class ConversationFragment extends XmppFragment
 
     @Override
     public void onContactPictureClicked(Message message) {
-        String fingerprint;
-        if (message.getEncryption() == Message.ENCRYPTION_PGP
-                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
-            fingerprint = "pgp";
-        } else {
-            fingerprint = message.getFingerprint();
-        }
+        setThread(message.getThread());
+        conversation.setUserSelectedThread(true);
+
         final boolean received = message.getStatus() <= Message.STATUS_RECEIVED;
         if (received) {
             if (message.getConversation() instanceof Conversation
@@ -3724,15 +3797,8 @@ public class ConversationFragment extends XmppFragment
                                 .show();
                     }
                 }
-                return;
-            } else {
-                if (!message.getContact().isSelf()) {
-                    activity.switchToContactDetails(message.getContact(), fingerprint);
-                    return;
-                }
             }
         }
-        activity.switchToAccount(message.getConversation().getAccount(), fingerprint);
     }
 
     private Activity requireActivity() {

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

@@ -42,6 +42,8 @@ import com.cheogram.android.BobTransfer;
 
 import com.google.common.base.Strings;
 
+import com.lelloman.identicon.view.GithubIdenticonView;
+
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -107,6 +109,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     private List<String> highlightedTerm = null;
     private final DisplayMetrics metrics;
     private OnContactPictureClicked mOnContactPictureClickedListener;
+    private OnContactPictureClicked mOnMessageBoxClickedListener;
     private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
     private boolean mUseGreenBackground = false;
     private final boolean mForceNames;
@@ -146,6 +149,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         this.mOnContactPictureClickedListener = listener;
     }
 
+    public void setOnMessageBoxClicked(OnContactPictureClicked listener) {
+        this.mOnMessageBoxClickedListener = listener;
+    }
+
     public Activity getActivity() {
         return activity;
     }
@@ -717,6 +724,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     viewHolder.subject = view.findViewById(R.id.message_subject);
                     viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received);
                     viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
+                    viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
                     break;
                 case RECEIVED:
                     view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false);
@@ -733,6 +741,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     viewHolder.encryption = view.findViewById(R.id.message_encryption);
                     viewHolder.audioPlayer = view.findViewById(R.id.audio_player);
                     viewHolder.commands_list = view.findViewById(R.id.commands_list);
+                    viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon);
                     break;
                 case STATUS:
                     view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false);
@@ -751,6 +760,19 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             }
         }
 
+        if (viewHolder.thread_identicon != null) {
+            viewHolder.thread_identicon.setVisibility(View.GONE);
+            final Element thread = message.getThread();
+            if (thread != null) {
+                final String threadId = thread.getContent();
+                if (threadId != null) {
+                    viewHolder.thread_identicon.setVisibility(View.VISIBLE);
+                    viewHolder.thread_identicon.setColor(UIHelper.getColorForName(threadId));
+                    viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId));
+                }
+            }
+        }
+
         boolean darkBackground = type == RECEIVED && (!isInValidSession || mUseGreenBackground) || activity.isDarkTheme();
 
         if (type == DATE_SEPARATOR) {
@@ -822,6 +844,18 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         resetClickListener(viewHolder.message_box, viewHolder.messageBody);
 
+        viewHolder.message_box.setOnClickListener(v -> {
+            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
+                MessageAdapter.this.mOnMessageBoxClickedListener
+                        .onContactPictureClicked(message);
+            }
+        });
+        viewHolder.messageBody.setOnClickListener(v -> {
+            if (MessageAdapter.this.mOnMessageBoxClickedListener != null) {
+                MessageAdapter.this.mOnMessageBoxClickedListener
+                        .onContactPictureClicked(message);
+            }
+        });
         viewHolder.contact_picture.setOnClickListener(v -> {
             if (MessageAdapter.this.mOnContactPictureClickedListener != null) {
                 MessageAdapter.this.mOnContactPictureClickedListener
@@ -1028,6 +1062,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         protected TextView status_message;
         protected TextView encryption;
         protected ListView commands_list;
+        protected GithubIdenticonView thread_identicon;
     }
 
     class ThumbnailTask extends AsyncTask<DownloadableFile, Void, Drawable[]> {

src/main/java/eu/siacs/conversations/utils/UIHelper.java 🔗

@@ -11,8 +11,10 @@ import androidx.annotation.ColorInt;
 import androidx.core.content.res.ResourcesCompat;
 
 import com.google.common.base.Strings;
+import com.google.common.primitives.Ints;
 
 import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.util.Arrays;
 import java.util.Calendar;
@@ -229,6 +231,16 @@ public class UIHelper {
         }
     }
 
+    public static int identiconHash(String name) {
+        try {
+            MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+            byte[] digest = sha1.digest(name.getBytes(StandardCharsets.UTF_8));
+            return Ints.fromByteArray(digest);
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+
     public static int getColorForName(String name) {
         return getColorForName(name, false);
     }

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

@@ -22,6 +22,7 @@
             android:layout_width="fill_parent"
             android:layout_height="wrap_content"
             android:layout_centerVertical="true"
+            android:layout_toEndOf="@+id/account_image"
             android:layout_toRightOf="@+id/account_image"
             android:orientation="vertical"
             android:paddingLeft="@dimen/avatar_item_distance"

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

@@ -60,10 +60,10 @@
                     <LinearLayout
                         android:layout_width="wrap_content"
                         android:layout_height="wrap_content"
-                        android:layout_alignParentStart="true"
-                        android:layout_alignParentLeft="true"
                         android:layout_toStartOf="@+id/textSendButton"
                         android:layout_toLeftOf="@+id/textSendButton"
+                        android:layout_toEndOf="@+id/thread_identicon_layout"
+                        android:layout_toRightOf="@+id/thread_identicon_layout"
                         android:orientation="vertical">
     
                         <TextView
@@ -109,6 +109,35 @@
 
                     </LinearLayout>
 
+                    <RelativeLayout
+                        android:id="@+id/thread_identicon_layout"
+                        android:layout_width="30dp"
+                        android:layout_height="30dp"
+                        android:layout_alignParentStart="true"
+                        android:layout_alignParentLeft="true"
+                        android:layout_centerVertical="true"
+                        android:layout_marginLeft="8dp">
+
+                        <com.lelloman.identicon.view.GithubIdenticonView
+                            android:id="@+id/thread_identicon"
+                            android:alpha="0"
+                            android:layout_width="24dp"
+                            android:layout_height="24dp"
+                            android:layout_centerVertical="true"
+                            android:contentDescription="Thread Marker" />
+                        <ImageView
+                            android:id="@+id/thread_identicon_lock"
+                            android:layout_width="12dp"
+                            android:layout_height="12dp"
+                            android:layout_alignParentTop="true"
+                            android:layout_alignParentEnd="true"
+                            android:layout_alignParentRight="true"
+                            android:visibility="gone"
+                            android:src="?attr/icon_small_lock"
+                            android:contentDescription="Thread Locked" />
+
+                    </RelativeLayout>
+
                     <ImageButton
                         android:id="@+id/textSendButton"
                         android:layout_width="48dp"

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

@@ -93,6 +93,15 @@
                     android:gravity="center_vertical"
                     android:src="@drawable/ic_mode_edit_white_18dp" />
 
+                <com.lelloman.identicon.view.GithubIdenticonView
+                    android:id="@+id/thread_identicon"
+                    android:visibility="gone"
+                    android:layout_width="9dp"
+                    android:layout_height="9dp"
+                    android:layout_gravity="center_vertical"
+                    android:layout_marginRight="4sp"
+                    android:layout_marginBottom="-1dp" />
+
                 <TextView
                     android:id="@+id/message_time"
                     android:layout_width="wrap_content"

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

@@ -10,29 +10,33 @@
     android:paddingRight="8dp"
     android:paddingBottom="3dp">
 
-
-    <LinearLayout
+    <RelativeLayout
         android:id="@+id/message_photo_box"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
+        android:layout_width="48dp"
+        android:layout_height="48dp"
         android:layout_alignParentEnd="true"
         android:layout_alignParentRight="true"
-        android:layout_alignParentBottom="true"
-        android:orientation="vertical">
-
-        <com.makeramen.roundedimageview.RoundedImageView
-            android:id="@+id/message_photo"
-            android:layout_width="48dp"
-            android:layout_height="48dp"
-            android:scaleType="fitXY"
-            app:riv_corner_radius="2dp" />
-
-        <View
-            android:id="@+id/placeholder"
-            android:layout_width="48dp"
-            android:layout_height="3dp" />
-    </LinearLayout>
+        android:layout_alignParentBottom="true">
 
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:orientation="vertical">
+
+            <com.makeramen.roundedimageview.RoundedImageView
+                android:id="@+id/message_photo"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:scaleType="fitXY"
+                app:riv_corner_radius="2dp" />
+
+            <View
+                android:id="@+id/placeholder"
+                android:layout_width="match_parent"
+                android:layout_height="3dp" />
+        </LinearLayout>
+
+    </RelativeLayout>
 
     <LinearLayout
         android:id="@+id/message_box"
@@ -44,7 +48,6 @@
         android:paddingTop="5dp"
         android:paddingBottom="5dp"
         android:layout_toLeftOf="@+id/message_photo_box"
-        android:translationY="-2dp"
         android:elevation="3dp"
         android:background="@drawable/message_bubble_sent"
         android:longClickable="true"
@@ -93,6 +96,15 @@
                     android:text="@string/sending"
                     android:textAppearance="@style/TextAppearance.Conversations.Caption" />
 
+                <com.lelloman.identicon.view.GithubIdenticonView
+                    android:id="@+id/thread_identicon"
+                    android:visibility="gone"
+                    android:layout_width="9dp"
+                    android:layout_height="9dp"
+                    android:layout_gravity="center_vertical"
+                    android:layout_marginRight="4sp"
+                    android:layout_marginBottom="-1dp" />
+
                 <ImageView
                     android:id="@+id/security_indicator"
                     android:layout_width="?attr/TextSizeCaption"

src/main/res/menu/message_context.xml 🔗

@@ -21,6 +21,11 @@
         android:title="@string/quote"
         android:visible="false" />
 
+    <item
+        android:id="@+id/only_this_thread"
+        android:title="@string/only_this_thread"
+        android:visible="false" />
+
     <item
         android:id="@+id/retry_decryption"
         android:title="@string/retry_decryption"

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

@@ -106,6 +106,7 @@
     <attr name="icon_help" format="reference" />
     <attr name="icon_goto_chat" format="reference" />
     <attr name="icon_secure" format="reference" />
+    <attr name="icon_small_lock" format="reference" />
     <attr name="icon_settings" format="reference" />
     <attr name="icon_share" format="reference" />
     <attr name="icon_import_export" format="reference" />