Initial jump to searched message

Arne created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java          | 113 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java    |  60 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  43 
src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java   |   2 
src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java      |   9 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 444 
src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java       |   1 
src/main/java/eu/siacs/conversations/ui/SearchActivity.java              |   2 
src/main/java/eu/siacs/conversations/ui/XmppActivity.java                |  12 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  19 
src/main/java/eu/siacs/conversations/ui/util/ListViewUtils.java          |  12 
11 files changed, 582 insertions(+), 135 deletions(-)

Detailed changes

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

@@ -223,7 +223,9 @@ public class Conversation extends AbstractEntity
     private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
     private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
     protected final ArrayList<Message> messages = new ArrayList<>();
+    protected final ArrayList<Message> historyPartMessages = new ArrayList<>();
     public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
+    public AtomicBoolean historyPartLoadedForward = new AtomicBoolean(true);
     protected Account account = null;
     private String draftMessage;
     private final String name;
@@ -770,11 +772,18 @@ public class Conversation extends AbstractEntity
     }
 
     public void populateWithMessages(final List<Message> messages, XmppConnectionService xmppConnectionService) {
-        synchronized (this.messages) {
+        if (historyPartMessages.size() > 0) {
             messages.clear();
-            messages.addAll(this.messages);
+            messages.addAll(this.historyPartMessages);
             threads.clear();
             reactions.clear();
+        } else {
+            synchronized (this.messages) {
+                messages.clear();
+                messages.addAll(this.messages);
+                threads.clear();
+                reactions.clear();
+            }
         }
         Set<String> extraIds = new HashSet<>();
         for (ListIterator<Message> iterator = messages.listIterator(messages.size()); iterator.hasPrevious(); ) {
@@ -1583,15 +1592,72 @@ public class Conversation extends AbstractEntity
 
     public void prepend(int offset, Message message) {
         checkSpam(message);
+
+        List<Message> properListToAdd;
+
+        if (!historyPartMessages.isEmpty()) {
+            properListToAdd = historyPartMessages;
+        } else {
+            properListToAdd = this.messages;
+        }
+
         synchronized (this.messages) {
-            this.messages.add(Math.min(offset, this.messages.size()), message);
+            properListToAdd.add(Math.min(offset, properListToAdd.size()), message);
+        }
+
+        if (!historyPartMessages.isEmpty() && hasDuplicateMessage(historyPartMessages.get(historyPartMessages.size() - 1))) {
+            messages.addAll(0, historyPartMessages);
+            jumpToLatest();
         }
     }
 
-    public void addAll(int index, List<Message> messages) {
+    public void addAll(int index, List<Message> messages, boolean fromPagination) {
+        if (messages.isEmpty()) return;
         checkSpam(messages.toArray(new Message[0]));
+
+        List<Message> newM = new ArrayList<>();
+
+        if (nextCounterpart == null) {
+            for(Message m : messages) {
+                if (!m.isPrivateMessage() && m.encryption != Message.ENCRYPTION_OTR) {
+                    newM.add(m);
+                }
+            }
+
+        } else {
+            for(Message m : messages) {
+                String res1 = m.getCounterpart() == null ? null : m.getCounterpart().getResource();
+                String res2 = nextCounterpart == null ? null : nextCounterpart.getResource();
+
+
+                if ((m.isPrivateMessage() || m.encryption == Message.ENCRYPTION_OTR) && Objects.equals(res1, res2)) {
+                    newM.add(m);
+                }
+            }
+
+        }
+
         synchronized (this.messages) {
-            this.messages.addAll(index, messages);
+            List<Message> properListToAdd;
+
+            if (fromPagination && !historyPartMessages.isEmpty() && checkIsMergeable(newM)) {
+                historyPartMessages.addAll(newM);
+                newM = filterExisted(historyPartMessages);
+                index = 0;
+                jumpToLatest();
+            }
+
+            if (fromPagination && !historyPartMessages.isEmpty()) {
+                properListToAdd = historyPartMessages;
+            } else {
+                properListToAdd = this.messages;
+            }
+
+            if (index == -1) {
+                properListToAdd.addAll(newM);
+            } else {
+                properListToAdd.addAll(index, newM);
+            }
         }
         account.getPgpDecryptionService().decrypt(messages);
     }
@@ -1625,6 +1691,43 @@ public class Conversation extends AbstractEntity
         }
     }
 
+    public void jumpToHistoryPart(List<Message> messages) {
+        historyPartMessages.clear();
+
+        if (checkIsMergeable(messages)) {
+            addAll(0, filterExisted(messages), false);
+        } else {
+            historyPartMessages.addAll(messages);
+        }
+    }
+
+    public void jumpToLatest() {
+        historyPartMessages.clear();
+    }
+
+    public boolean isInHistoryPart() {
+        return !historyPartMessages.isEmpty();
+    }
+
+    private boolean checkIsMergeable(List<Message> messages) {
+        if (messages.isEmpty()) return true;
+        return findDuplicateMessage(messages.get(messages.size() - 1)) != null;
+    }
+
+    private List<Message> filterExisted(List<Message> messages) {
+        if (messages.isEmpty()) return Collections.emptyList();
+
+        List<Message> result = new ArrayList<>();
+
+        for (Message m : messages) {
+            if (findDuplicateMessage(m) == null) {
+                result.add(m);
+            }
+        }
+
+        return result;
+    }
+
     private void untieMessages() {
         for (Message message : this.messages) {
             message.untie();

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -16,6 +16,8 @@ import android.util.Base64;
 import android.util.Log;
 import com.cheogram.android.WebxdcUpdate;
 
+import androidx.annotation.Nullable;
+
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.HashMultimap;
@@ -1728,9 +1730,45 @@ public class DatabaseBackend extends SQLiteOpenHelper {
     }
 
     public ArrayList<Message> getMessages(Conversation conversations, int limit) {
-        return getMessages(conversations, limit, -1);
+        return getMessages(conversations, limit, -1, false);
     }
 
+
+    @Nullable
+    public ArrayList<Message> getMessagesNearUuid(Conversation conversation, int limit, String uuid) {
+        SQLiteDatabase db = this.getReadableDatabase();
+
+        String[] selectionArgs = {conversation.getUuid(), uuid, uuid, uuid};
+        Cursor cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION
+                + "=? and (" + Message.SERVER_MSG_ID + "=? or " + Message.REMOTE_MSG_ID +  "=? or " + Message.UUID + "=?)", selectionArgs, null, null, Message.TIME_SENT
+                + " DESC", String.valueOf(1));
+        CursorUtils.upgradeCursorWindowSize(cursor);
+        Message anchorMessage = null;
+        while (cursor.moveToNext()) {
+            try {
+                anchorMessage = Message.fromCursor(cursor, conversation);
+            } catch (Exception e) {
+                Log.e(Config.LOGTAG, "unable to restore message");
+            }
+        }
+
+        cursor.close();
+
+        if (anchorMessage == null) {
+            return null;
+        }
+
+        List<Message> prev = getMessages(conversation, limit / 2, anchorMessage.getTimeSent(), false);
+        List<Message> next = getMessages(conversation, limit / 2, anchorMessage.getTimeSent(), true);
+
+        ArrayList<Message> list = new ArrayList<>(prev);
+        list.add(anchorMessage);
+        list.addAll(next);
+
+        return list;
+    }
+
+
     public Map<String, Message> getMessageFuzzyIds(Conversation conversation, Collection<String> ids) {
         final var result = new Hashtable<String, Message>();
         if (ids.size() < 1) return result;
@@ -1767,10 +1805,12 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return result;
     }
 
-    public ArrayList<Message> getMessages(Conversation conversation, int limit, long timestamp) {
+    public ArrayList<Message> getMessages(Conversation conversation, int limit, long timestamp, boolean isForward) {
         ArrayList<Message> list = new ArrayList<>();
         SQLiteDatabase db = this.getReadableDatabase();
         Cursor cursor;
+        String comparsionOperation = isForward ? ">? " : "<? ";
+        String sorting = isForward ? " ASC " : " DESC ";
         if (timestamp == -1) {
             String[] selectionArgs = {conversation.getUuid()};
             cursor = db.rawQuery(
@@ -1780,9 +1820,9 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 " WHERE " + Message.UUID + " IN (" +
                 "SELECT " + Message.UUID + " FROM " + Message.TABLENAME +
                 " WHERE " + Message.CONVERSATION + "=? " +
-                "ORDER BY " + Message.TIME_SENT + " DESC " +
+                "ORDER BY " + Message.TIME_SENT + sorting +
                 "LIMIT " + String.valueOf(limit) + ") " +
-                "ORDER BY " + Message.TIME_SENT + " DESC ",
+                "ORDER BY " + Message.TIME_SENT + sorting,
                 selectionArgs
             );
         } else {
@@ -1795,10 +1835,10 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                 " WHERE " + Message.UUID + " IN (" +
                 "SELECT " + Message.UUID + " FROM " + Message.TABLENAME +
                 " WHERE " + Message.CONVERSATION + "=? AND " +
-                Message.TIME_SENT + "<? " +
-                "ORDER BY " + Message.TIME_SENT + " DESC " +
+                Message.TIME_SENT + comparsionOperation +
+                "ORDER BY " + Message.TIME_SENT + sorting +
                 "LIMIT " + String.valueOf(limit) + ") " +
-                "ORDER BY " + Message.TIME_SENT + " DESC ",
+                "ORDER BY " + Message.TIME_SENT + sorting,
                 selectionArgs
             );
         }
@@ -1813,7 +1853,11 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                     replyIds.add(reply.getAttribute("id"));
                     waitingForReplies.put(reply.getAttribute("id"), m);
                 }
-                list.add(0, m);
+                if (isForward) {
+                    list.add(m);
+                } else {
+                    list.add(0, m);
+                }
             } catch (Exception e) {
                 Log.e(Config.LOGTAG, "unable to restore message", e);
             }

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

@@ -2457,7 +2457,7 @@ public class XmppConnectionService extends Service {
     }
 
     private void restoreMessages(Conversation conversation) {
-        conversation.addAll(0, databaseBackend.getMessages(conversation, Config.PAGE_SIZE));
+        conversation.addAll(0, databaseBackend.getMessages(conversation, Config.PAGE_SIZE), false);
         conversation.findUnsentTextMessages(message -> markMessage(message, Message.STATUS_WAITING));
         conversation.findMessagesAndCallsToNotify(mNotificationService::pushFromBacklog);
     }
@@ -2598,9 +2598,24 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    public void jumpToMessage(final Conversation conversation, final String uuid, JumpToMessageListener listener) {
+        final Runnable runnable = () -> {
+            List<Message> messages = databaseBackend.getMessagesNearUuid(conversation, 30, uuid);
+            if (messages != null && !messages.isEmpty()) {
+                conversation.jumpToHistoryPart(messages);
+                listener.onSuccess();
+            } else {
+                listener.onNotFound();
+            }
+        };
+
+        mDatabaseReaderExecutor.execute(runnable);
+    }
+
     public void loadMoreMessages(
             final Conversation conversation,
             final long timestamp,
+            boolean isForward,
             final OnMoreMessagesLoaded callback) {
         if (XmppConnectionService.this
                 .getMessageArchiveService()
@@ -2615,15 +2630,26 @@ public class XmppConnectionService extends Service {
                         + conversation.getName()
                         + " prior to "
                         + MessageGenerator.getTimestamp(timestamp));
+
+        if (isForward) {
+            Log.d(Config.LOGTAG, "load more messages for " + conversation.getName() + " after " + MessageGenerator.getTimestamp(timestamp));
+        } else {
+            Log.d(Config.LOGTAG, "load more messages for " + conversation.getName() + " prior to " + MessageGenerator.getTimestamp(timestamp));
+        }
+
         final Runnable runnable =
                 () -> {
                     final Account account = conversation.getAccount();
-                    List<Message> messages =
-                            databaseBackend.getMessages(conversation, 50, timestamp);
+                    List<Message> messages = databaseBackend.getMessages(conversation, Config.PAGE_SIZE, timestamp, isForward);
                     if (messages.size() > 0) {
-                        conversation.addAll(0, messages);
+                        if (isForward) {
+                            conversation.addAll(-1, messages, true);
+                        } else {
+                            conversation.addAll(0, messages, true);
+                        }
                         callback.onMoreMessagesLoaded(messages.size(), conversation);
-                    } else if (conversation.hasMessagesLeftOnServer()
+                    } else if (!isForward &&
+                            conversation.hasMessagesLeftOnServer()
                             && account.isOnlineAndConnected()
                             && conversation.getLastClearHistory().getTimestamp() == 0) {
                         final boolean mamAvailable;
@@ -2927,7 +2953,7 @@ public class XmppConnectionService extends Service {
         final var singleMode = c.getMode() == Conversational.MODE_SINGLE;
         final var account = c.getAccount();
         if (loadMessagesFromDb) {
-            c.addAll(0, databaseBackend.getMessages(c, Config.PAGE_SIZE));
+            c.addAll(0, databaseBackend.getMessages(c, Config.PAGE_SIZE), false);
             updateConversationUi();
             c.messagesLoaded.set(true);
         }
@@ -5144,6 +5170,11 @@ public class XmppConnectionService extends Service {
         void informUser(int r);
     }
 
+    public interface JumpToMessageListener {
+        void onSuccess();
+        void onNotFound();
+    }
+
     public interface OnMoreMessagesLoaded {
         void onMoreMessagesLoaded(int count, Conversation conversation);
 

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

@@ -345,7 +345,7 @@ public class ConferenceDetailsActivity extends XmppActivity
         GridManager.setupLayoutManager(this, this.binding.users, R.dimen.media_size);
         this.binding.recentThreads.setOnItemClickListener((a0, v, pos, a3) -> {
             final Conversation.Thread thread = (Conversation.Thread) binding.recentThreads.getAdapter().getItem(pos);
-            switchToConversation(mConversation, null, false, null, false, true, null, thread.getThreadId());
+            switchToConversation(mConversation, null, false, null, false, true, null, thread.getThreadId(), null);
         });
         this.binding.invite.setOnClickListener(v -> inviteToConversation(mConversation));
         this.binding.showUsers.setOnClickListener(

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

@@ -300,6 +300,15 @@ public class ContactDetailsActivity extends OmemoActivity
         mMediaAdapter = new MediaAdapter(this, R.dimen.media_size);
         this.binding.media.setAdapter(mMediaAdapter);
         GridManager.setupLayoutManager(this, this.binding.media, R.dimen.media_size);
+        this.binding.recentThreads.setOnItemClickListener((a0, v, pos, a3) -> {
+            Account thisAccount = xmppConnectionService.findAccountByJid(accountJid);
+            if (thisAccount == null) {
+                return;
+            }
+            final var conversation = xmppConnectionService.findOrCreateConversation(thisAccount, contact.getJid(), false, true);
+            final Conversation.Thread thread = (Conversation.Thread) binding.recentThreads.getAdapter().getItem(pos);
+            switchToConversation(conversation, null, false, null, false, true, null, thread.getThreadId(), null);
+        });
     }
 
     @Override

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

@@ -16,6 +16,7 @@ import android.app.DatePickerDialog;
 import android.app.Fragment;
 import android.app.FragmentManager;
 import android.app.PendingIntent;
+import android.app.ProgressDialog;
 import android.app.TimePickerDialog;
 import android.content.ActivityNotFoundException;
 import android.content.Context;
@@ -56,6 +57,9 @@ import android.view.MotionEvent;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.ViewGroup;
+import android.view.animation.AlphaAnimation;
+import android.view.animation.Animation;
+import android.view.animation.CycleInterpolator;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputMethodManager;
 import android.view.WindowManager;
@@ -125,6 +129,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -293,6 +298,30 @@ public class ConversationFragment extends XmppFragment
     private int identiconWidth = -1;
     private File savingAsSticker = null;
     private EmojiSearch emojiSearch = null;
+    File dirStickers = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + "/monocles chat" + File.separator + "Stickers");
+    private String[] StickerfilesPaths;
+    private String[] StickerfilesNames;
+    private String[] GifsfilesPaths;
+    private String[] GifsfilesNames;
+
+    private LinkedList<Message> replyJumps = new LinkedList<>();
+
+    private PinnedMessageRepository pinnedMessageRepository;
+    private String currentDisplayedPinnedMessageUuid = null;
+
+    private final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
+
+    private KeyboardHeightProvider.KeyboardHeightListener keyboardHeightListener = null;
+    private KeyboardHeightProvider keyboardHeightProvider = null;
+    private static final String PINNED_MESSAGE_KEY_PREFIX = "pinned_message_";
+
+    protected OnClickListener clickToVerify = new OnClickListener() {
+        @Override
+        public void onClick(View v) {
+            activity.verifyOtrSessionDialog(conversation, v);
+        }
+    };
+
     private final OnClickListener clickToMuc =
             new OnClickListener() {
 
@@ -353,7 +382,6 @@ public class ConversationFragment extends XmppFragment
                 @Override
                 public void onScrollStateChanged(AbsListView view, int scrollState) {
                     if (AbsListView.OnScrollListener.SCROLL_STATE_IDLE == scrollState) {
-                        updateThreadFromLastMessage();
                         fireReadEvent();
                     }
                 }
@@ -366,113 +394,146 @@ public class ConversationFragment extends XmppFragment
                         int totalItemCount) {
                     toggleScrollDownButton(view);
                     synchronized (ConversationFragment.this.messageList) {
-                        if (firstVisibleItem < 5
-                                && conversation != null
-                                && conversation.messagesLoaded.compareAndSet(true, false)
-                                && messageList.size() > 0
-                                && activity != null) {
-                            long timestamp = conversation.loadMoreTimestamp();
-                            activity.xmppConnectionService.loadMoreMessages(
-                                    conversation,
-                                    timestamp,
-                                    new XmppConnectionService.OnMoreMessagesLoaded() {
-                                        @Override
-                                        public void onMoreMessagesLoaded(
-                                                final int c, final Conversation conversation) {
-                                            if (ConversationFragment.this.conversation
-                                                    != conversation) {
+                        boolean paginateBackward = firstVisibleItem < 5;
+                        boolean paginationForward = conversation.isInHistoryPart() && firstVisibleItem + visibleItemCount + 5 > totalItemCount;
+                        loadMoreMessages(paginateBackward, paginationForward, view);
+                    }
+                }
+            };
+
+    private void loadMoreMessages(boolean paginateBackward, boolean paginationForward, AbsListView view) {
+        if (paginateBackward && !conversation.messagesLoaded.get()) {
+            paginateBackward = false;
+        }
+
+        if (
+                conversation != null &&
+                        messageList.size() > 0 &&
+                        ((paginateBackward && conversation.messagesLoaded.compareAndSet(true, false)) ||
+                                (paginationForward && conversation.historyPartLoadedForward.compareAndSet(true, false)))
+        ) {
+            long timestamp;
+
+            if (paginateBackward) {
+                if (messageList.get(0).getType() == Message.TYPE_STATUS
+                        && messageList.size() >= 2) {
+                    timestamp = messageList.get(1).getTimeSent();
+                } else {
+                    timestamp = messageList.get(0).getTimeSent();
+                }
+            } else {
+                if (messageList.get(messageList.size() - 1).getType() == Message.TYPE_STATUS
+                        && messageList.size() >= 2) {
+                    timestamp = messageList.get(messageList.size() - 2).getTimeSent();
+                } else {
+                    timestamp = messageList.get(messageList.size() - 1).getTimeSent();
+                }
+            }
+
+            boolean finalPaginateBackward = paginateBackward;
+            activity.xmppConnectionService.loadMoreMessages(
+                    conversation,
+                    timestamp,
+                    !paginateBackward,
+                    new XmppConnectionService.OnMoreMessagesLoaded() {
+                        @Override
+                        public void onMoreMessagesLoaded(
+                                final int c, final Conversation conversation) {
+                            if (ConversationFragment.this.conversation
+                                    != conversation) {
+                                conversation.messagesLoaded.set(true);
+                                return;
+                            }
+                            runOnUiThread(
+                                    () -> {
+                                        synchronized (messageList) {
+                                            final int oldPosition =
+                                                    binding.messagesView
+                                                            .getFirstVisiblePosition();
+                                            Message message = null;
+                                            int childPos;
+                                            for (childPos = 0;
+                                                 childPos + oldPosition
+                                                         < messageList.size();
+                                                 ++childPos) {
+                                                message =
+                                                        messageList.get(
+                                                                oldPosition
+                                                                        + childPos);
+                                                if (message.getType()
+                                                        != Message.TYPE_STATUS) {
+                                                    break;
+                                                }
+                                            }
+                                            final String uuid =
+                                                    message != null
+                                                            ? message.getUuid()
+                                                            : null;
+                                            View v =
+                                                    binding.messagesView.getChildAt(
+                                                            childPos);
+                                            final int pxOffset =
+                                                    (v == null) ? 0 : v.getTop();
+                                            ConversationFragment.this.conversation
+                                                    .populateWithMessages(
+                                                            ConversationFragment
+                                                                    .this
+                                                                    .messageList,
+                                                            activity == null ? null : activity.xmppConnectionService);
+                                            try {
+                                                updateStatusMessages();
+                                            } catch (IllegalStateException e) {
+                                                Log.d(
+                                                        Config.LOGTAG,
+                                                        "caught illegal state exception while updating status messages");
+                                            }
+                                            messageListAdapter
+                                                    .notifyDataSetChanged();
+                                            int pos =
+                                                    Math.max(
+                                                            getIndexOf(
+                                                                    uuid,
+                                                                    messageList),
+                                                            0);
+                                            binding.messagesView
+                                                    .setSelectionFromTop(
+                                                            pos, pxOffset);
+                                            if (messageLoaderToast != null) {
+                                                messageLoaderToast.cancel();
+                                            }
+
+                                            if (!finalPaginateBackward) {
+                                                conversation.historyPartLoadedForward.set(true);
+                                            } else {
                                                 conversation.messagesLoaded.set(true);
-                                                return;
                                             }
-                                            runOnUiThread(
-                                                    () -> {
-                                                        synchronized (messageList) {
-                                                            final int oldPosition =
-                                                                    binding.messagesView
-                                                                            .getFirstVisiblePosition();
-                                                            Message message = null;
-                                                            int childPos;
-                                                            for (childPos = 0;
-                                                                    childPos + oldPosition
-                                                                            < messageList.size();
-                                                                    ++childPos) {
-                                                                message =
-                                                                        messageList.get(
-                                                                                oldPosition
-                                                                                        + childPos);
-                                                                if (message.getType()
-                                                                        != Message.TYPE_STATUS) {
-                                                                    break;
-                                                                }
-                                                            }
-                                                            final String uuid =
-                                                                    message != null
-                                                                            ? message.getUuid()
-                                                                            : null;
-                                                            View v =
-                                                                    binding.messagesView.getChildAt(
-                                                                            childPos);
-                                                            final int pxOffset =
-                                                                    (v == null) ? 0 : v.getTop();
-                                                            ConversationFragment.this.conversation
-                                                                    .populateWithMessages(
-                                                                            ConversationFragment
-                                                                                    .this
-                                                                                    .messageList, activity == null ? null : activity.xmppConnectionService);
-                                                            try {
-                                                                updateStatusMessages();
-                                                            } catch (IllegalStateException e) {
-                                                                Log.d(
-                                                                        Config.LOGTAG,
-                                                                        "caught illegal state"
-                                                                                + " exception while"
-                                                                                + " updating status"
-                                                                                + " messages");
-                                                            }
-                                                            messageListAdapter
-                                                                    .notifyDataSetChanged();
-                                                            int pos =
-                                                                    Math.max(
-                                                                            getIndexOf(
-                                                                                    uuid,
-                                                                                    messageList),
-                                                                            0);
-                                                            binding.messagesView
-                                                                    .setSelectionFromTop(
-                                                                            pos, pxOffset);
-                                                            if (messageLoaderToast != null) {
-                                                                messageLoaderToast.cancel();
-                                                            }
-                                                            conversation.messagesLoaded.set(true);
-                                                        }
-                                                    });
                                         }
+                                    });
+                        }
 
-                                        @Override
-                                        public void informUser(final int resId) {
-
-                                            runOnUiThread(
-                                                    () -> {
-                                                        if (messageLoaderToast != null) {
-                                                            messageLoaderToast.cancel();
-                                                        }
-                                                        if (ConversationFragment.this.conversation
-                                                                != conversation) {
-                                                            return;
-                                                        }
-                                                        messageLoaderToast =
-                                                                Toast.makeText(
-                                                                        view.getContext(),
-                                                                        resId,
-                                                                        Toast.LENGTH_LONG);
-                                                        messageLoaderToast.show();
-                                                    });
+                        @Override
+                        public void informUser(final int resId) {
+
+                            runOnUiThread(
+                                    () -> {
+                                        if (messageLoaderToast != null) {
+                                            messageLoaderToast.cancel();
                                         }
+                                        if (ConversationFragment.this.conversation
+                                                != conversation) {
+                                            return;
+                                        }
+                                        messageLoaderToast =
+                                                Toast.makeText(
+                                                        view.getContext(),
+                                                        resId,
+                                                        Toast.LENGTH_LONG);
+                                        messageLoaderToast.show();
                                     });
                         }
-                    }
-                }
-            };
+                    });
+        }
+    }
     private final EditMessage.OnCommitContentListener mEditorContentListener =
             new EditMessage.OnCommitContentListener() {
                 @Override
@@ -619,6 +680,28 @@ public class ConversationFragment extends XmppFragment
                 @Override
                 public void onClick(View v) {
                     stopScrolling();
+
+                    if (!replyJumps.isEmpty()) {
+                        int lastVisiblePosition = binding.messagesView.getLastVisiblePosition();
+                        Message lastVisibleMessage = messageListAdapter.getItem(lastVisiblePosition);
+                        if (lastVisibleMessage == null) {
+                            replyJumps.clear();
+                        } else {
+                            while (!replyJumps.isEmpty()) {
+                                Message jump = replyJumps.pop();
+                                if (jump.getTimeSent() > lastVisibleMessage.getTimeSent()) {
+                                    Runnable postSelectionRunnable = () -> highlightMessage(jump.getUuid());
+                                    updateSelection(jump.getUuid(), binding.messagesView.getHeight() / 2, postSelectionRunnable, false, false);
+                                    return;
+                                }
+                            }
+                        }
+                    }
+
+                    if (conversation.isInHistoryPart()) {
+                        conversation.jumpToLatest();
+                        refresh(false);
+                    }
                     setSelection(binding.messagesView.getCount() - 1, true);
                 }
             };
@@ -682,6 +765,7 @@ public class ConversationFragment extends XmppFragment
     private int lastCompletionCursor;
     private boolean firstWord = false;
     private Message mPendingDownloadableMessage;
+    private ProgressDialog fetchHistoryDialog;
 
     private static ConversationFragment findConversationFragment(Activity activity) {
         Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
@@ -780,7 +864,7 @@ public class ConversationFragment extends XmppFragment
         if (conversation == null) {
             return;
         }
-        if (scrolledToBottom(listView)) {
+        if (scrolledToBottom(listView) && !conversation.isInHistoryPart()) {
             lastMessageUuid = null;
             hideUnreadMessagesCount();
         } else {
@@ -807,6 +891,26 @@ public class ConversationFragment extends XmppFragment
         return -1;
     }
 
+    private int getIndexOfExtended(String uuid, List<Message> messages) {
+        if (uuid == null) {
+            return messages.size() - 1;
+        }
+        for (int i = 0; i < messages.size(); ++i) {
+            if (uuid.equals(messages.get(i).getServerMsgId())) {
+                return i;
+            }
+
+            if (uuid.equals(messages.get(i).getRemoteMsgId())) {
+                return i;
+            }
+
+            if (uuid.equals(messages.get(i).getUuid())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
     private ScrollState getScrollPosition() {
         final ListView listView = this.binding == null ? null : this.binding.messagesView;
         if (listView == null
@@ -1394,6 +1498,9 @@ public class ConversationFragment extends XmppFragment
         super.onCreate(savedInstanceState);
         setHasOptionsMenu(true);
         activity.getOnBackPressedDispatcher().addCallback(this, backPressedLeaveSingleThread);
+        if (savedInstanceState == null) {
+            conversation.jumpToLatest();
+        }
     }
 
     @Override
@@ -1500,6 +1607,7 @@ public class ConversationFragment extends XmppFragment
         messageListAdapter.setOnContactPictureLongClicked(this);
         messageListAdapter.setOnInlineImageLongClicked(this);
         messageListAdapter.setConversationFragment(this);
+        // messageListAdapter.setReplyClickListener(this::scrollToReply);       //TODO add a better scrol to reply later
         binding.messagesView.setAdapter(messageListAdapter);
 
         binding.textinput.addTextChangedListener(
@@ -1805,6 +1913,114 @@ public class ConversationFragment extends XmppFragment
         updateSendButton();
     }
 
+
+    private void scrollToReply(Message message) {
+        Element reply = message.getReply();
+        if (reply == null) return;
+
+        String replyId = reply.getAttribute("id");
+
+        if (replyId != null) {
+            Runnable postSelectionRunnable = () -> highlightMessage(replyId);
+            replyJumps.push(message);
+            updateSelection(replyId, binding.messagesView.getHeight() / 2, postSelectionRunnable, true, false);
+        }
+    }
+
+    private void highlightMessage(String uuid) {
+        binding.messagesView.postDelayed(() -> {
+            int actualIndex = getIndexOfExtended(uuid, messageList);
+
+            if (actualIndex == -1) {
+                return;
+            }
+
+            View view = ListViewUtils.getViewByPosition(actualIndex, binding.messagesView);
+            View messageBox = view.findViewById(R.id.message_box);
+            if (messageBox != null) {
+                messageBox.animate()
+                        .scaleX(1.14f)
+                        .scaleY(1.14f)
+                        .setInterpolator(new CycleInterpolator(0.5f))
+                        .setDuration(400L)
+                        .start();
+            }
+        }, 300L);
+    }
+
+    private void updateSelection(String uuid, Integer offsetFormTop, Runnable selectionUpdatedRunnable, boolean populateFromMam, boolean recursiveFetch) {
+        if (recursiveFetch && (fetchHistoryDialog == null || !fetchHistoryDialog.isShowing())) return;
+
+        int pos = getIndexOfExtended(uuid, messageList);
+
+        Runnable updateSelectionRunnable = () -> {
+            FragmentConversationBinding binding = ConversationFragment.this.binding;
+
+            Runnable performRunnable = () -> {
+                if (offsetFormTop != null) {
+                    binding.messagesView.setSelectionFromTop(pos, offsetFormTop);
+                    return;
+                }
+
+                binding.messagesView.setSelection(pos);
+            };
+
+            performRunnable.run();
+            binding.messagesView.post(performRunnable);
+
+            if (selectionUpdatedRunnable != null) {
+                selectionUpdatedRunnable.run();
+            }
+        };
+
+        if (pos != -1) {
+            hideFetchHistoryDialog();
+            updateSelectionRunnable.run();
+        } else {
+            activity.xmppConnectionService.jumpToMessage(conversation, uuid, new XmppConnectionService.JumpToMessageListener() {
+                @Override
+                public void onSuccess() {
+                    activity.runOnUiThread(() -> {
+                        refresh(false);
+                        conversation.messagesLoaded.set(true);
+                        conversation.historyPartLoadedForward.set(true);
+                        toggleScrollDownButton();
+                        updateSelection(uuid, binding.messagesView.getHeight() / 2, selectionUpdatedRunnable, populateFromMam, false);
+                    });
+                }
+
+                @Override
+                public void onNotFound() {
+                    activity.runOnUiThread(() -> {
+                        if (populateFromMam && conversation.hasMessagesLeftOnServer()) {
+                            showFetchHistoryDialog();
+                            loadMoreMessages(true, false, binding.messagesView);
+                            binding.messagesView.postDelayed(() -> updateSelection(uuid, binding.messagesView.getHeight() / 2, selectionUpdatedRunnable, populateFromMam, true), 500L);
+                        } else {
+                            hideFetchHistoryDialog();
+                        }
+                    });
+                }
+            });
+        }
+    }
+
+    private void showFetchHistoryDialog() {
+        if (fetchHistoryDialog != null && fetchHistoryDialog.isShowing()) return;
+
+        fetchHistoryDialog = new ProgressDialog(getActivity());
+        fetchHistoryDialog.setIndeterminate(true);
+        fetchHistoryDialog.setMessage(getString(R.string.please_wait));
+        fetchHistoryDialog.setCancelable(true);
+        fetchHistoryDialog.show();
+    }
+
+    private void hideFetchHistoryDialog() {
+        if (fetchHistoryDialog != null && fetchHistoryDialog.isShowing()) {
+            fetchHistoryDialog.hide();
+        }
+    }
+
     @Override
     public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
         // This should cancel any remaining click events that would otherwise trigger links
@@ -3341,7 +3557,7 @@ public class ConversationFragment extends XmppFragment
         super.onStart();
         if (this.reInitRequiredOnStart && this.conversation != null) {
             final Bundle extras = pendingExtras.pop();
-            reInit(this.conversation, extras != null);
+            reInit(this.conversation, extras != null, extras != null && extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID) != null);
             if (extras != null) {
                 processExtras(extras);
             }
@@ -3407,7 +3623,7 @@ public class ConversationFragment extends XmppFragment
             this.saveMessageDraftStopAudioPlayer();
         }
         this.clearPending();
-        if (this.reInit(conversation, extras != null)) {
+        if (this.reInit(conversation, extras != null, extras != null && extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID) != null)) {
             if (extras != null) {
                 processExtras(extras);
             }
@@ -3420,10 +3636,13 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void reInit(Conversation conversation) {
-        reInit(conversation, false);
+        reInit(conversation, false, false);
+        if (activity != null) {
+            activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
+        }
     }
 
-    private boolean reInit(final Conversation conversation, final boolean hasExtras) {
+    private boolean reInit(final Conversation conversation, final boolean hasExtras, final boolean hasMessageUUID) {
         if (conversation == null) {
             return false;
         }
@@ -3504,7 +3723,7 @@ public class ConversationFragment extends XmppFragment
         this.conversation.messagesLoaded.set(true);
         Log.d(Config.LOGTAG, "scrolledToBottomAndNoPending=" + scrolledToBottomAndNoPending);
 
-        if (hasExtras || scrolledToBottomAndNoPending) {
+        if (!hasMessageUUID && (hasExtras || scrolledToBottomAndNoPending)) {
             resetUnreadMessagesCount();
             synchronized (this.messageList) {
                 Log.d(Config.LOGTAG, "jump to first unread message");
@@ -3547,9 +3766,8 @@ public class ConversationFragment extends XmppFragment
             });
             refreshCommands(false);
         }
-
         binding.commandsNote.setVisibility(activity.xmppConnectionService.isOnboarding() ? View.VISIBLE : View.GONE);
-
+        replyJumps.clear();
         return true;
     }
 
@@ -3650,6 +3868,7 @@ public class ConversationFragment extends XmppFragment
         }
         this.binding.scrollToBottomButton.setEnabled(false);
         this.binding.scrollToBottomButton.hide();
+        replyJumps.clear();
         this.binding.unreadCountCustomView.setVisibility(View.GONE);
     }
 
@@ -3661,7 +3880,7 @@ public class ConversationFragment extends XmppFragment
     }
 
     private boolean scrolledToBottom() {
-        return this.binding != null && scrolledToBottom(this.binding.messagesView);
+        return !conversation.isInHistoryPart() && this.binding != null && scrolledToBottom(this.binding.messagesView);
     }
 
     private void processExtras(final Bundle extras) {
@@ -3783,6 +4002,11 @@ public class ConversationFragment extends XmppFragment
                 conversation.startCommand(commandFor(Jid.of("cheogram.com/CHEOGRAM%jabber:iq:register"), "jabber:iq:register"), activity.xmppConnectionService);
             }
         }
+        String messageUuid = extras.getString(ConversationsActivity.EXTRA_MESSAGE_UUID);
+        if (messageUuid != null) {
+            Runnable postSelectionRunnable = () -> highlightMessage(messageUuid);
+            updateSelection(messageUuid, binding.messagesView.getHeight() / 2, postSelectionRunnable, false, false);
+        }
     }
 
     private Element commandFor(final Jid jid, final String node) {

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

@@ -148,6 +148,7 @@ public class ConversationsActivity extends XmppActivity
     public static final String EXTRA_TYPE = "type";
     public static final String EXTRA_NODE = "node";
     public static final String EXTRA_JID = "jid";
+    public static final String EXTRA_MESSAGE_UUID = "messageUuid";
 
     private static final List<String> VIEW_AND_SHARE_ACTIONS =
             Arrays.asList(

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

@@ -165,7 +165,7 @@ public class SearchActivity extends XmppActivity implements TextWatcher, OnSearc
 		if (message != null) {
 			switch (item.getItemId()) {
 				case R.id.open_conversation:
-					switchToConversation(wrap(message.getConversation()));
+					switchToConversationOnMessage(wrap(message.getConversation()), message.getUuid());
 					break;
 				case R.id.share_with:
 					ShareUtil.share(this, message);

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

@@ -793,6 +793,10 @@ public abstract class XmppActivity extends ActionBarActivity {
         switchToConversation(conversation, null);
     }
 
+    public void switchToConversationOnMessage(Conversation conversation, String messageUuid) {
+        switchToConversation(conversation, null, false, null, false, false, null, null, messageUuid);
+    }
+
     public void switchToConversationAndQuote(Conversation conversation, String text) {
         switchToConversation(conversation, text, true, null, false, false);
     }
@@ -818,7 +822,7 @@ public abstract class XmppActivity extends ActionBarActivity {
     }
 
     public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend, String postInit) {
-        switchToConversation(conversation, text, asQuote, nick, pm, doNotAppend, postInit, null);
+        switchToConversation(conversation, text, asQuote, nick, pm, doNotAppend, postInit, null, null);
     }
 
     public void switchToConversation(
@@ -829,7 +833,8 @@ public abstract class XmppActivity extends ActionBarActivity {
             boolean pm,
             boolean doNotAppend,
             String postInit,
-            String thread) {
+            String thread,
+            String messageUuid) {
         if (conversation == null) return;
         Intent intent = new Intent(this, ConversationsActivity.class);
         intent.setAction(ConversationsActivity.ACTION_VIEW_CONVERSATION);
@@ -848,6 +853,9 @@ public abstract class XmppActivity extends ActionBarActivity {
         if (doNotAppend) {
             intent.putExtra(ConversationsActivity.EXTRA_DO_NOT_APPEND, true);
         }
+        if (messageUuid != null) {
+            intent.putExtra(ConversationsActivity.EXTRA_MESSAGE_UUID, messageUuid);
+        }
         intent.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, postInit);
         intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP);
         startActivity(intent);

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

@@ -124,6 +124,7 @@ import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.MessageArchiveService;
 import eu.siacs.conversations.services.NotificationService;
+import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.Activities;
 import eu.siacs.conversations.ui.BindingAdapters;
 import eu.siacs.conversations.ui.ConversationFragment;
@@ -1515,8 +1516,22 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         } else {
             viewHolder.inReplyToBox().setVisibility(View.VISIBLE);
             viewHolder.inReplyTo().setText(UIHelper.getMessageDisplayName(message.getInReplyTo()));
-            viewHolder.inReplyTo().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
-            viewHolder.inReplyToQuote().setOnClickListener((v) -> mConversationFragment.jumpTo(message.getInReplyTo()));
+            final var replyToClickListener = (View.OnClickListener) (v) -> {
+                final Message inReplyTo = message.getInReplyTo();
+                if (inReplyTo == null || inReplyTo.getUuid() == null) return;
+                final var replyConversation = mConversationFragment.getConversation();
+                activity.xmppConnectionService.jumpToMessage(replyConversation, inReplyTo.getUuid(), new XmppConnectionService.JumpToMessageListener() {
+                    @Override
+                    public void onSuccess() {
+                        activity.runOnUiThread(() -> mConversationFragment.refresh());
+                    }
+
+                    @Override
+                    public void onNotFound() {}
+                });
+            };
+            viewHolder.inReplyTo().setOnClickListener(replyToClickListener);
+            viewHolder.inReplyToQuote().setOnClickListener(replyToClickListener);
             setTextColor(viewHolder.inReplyTo(), bubbleColor);
         }
 

src/main/java/eu/siacs/conversations/ui/util/ListViewUtils.java 🔗

@@ -53,5 +53,17 @@ public class ListViewUtils {
 		listView.setSelection(pos);
 	}
 
+	public static View getViewByPosition(int pos, ListView listView) {
+		final int firstListItemPosition = listView.getFirstVisiblePosition();
+		final int lastListItemPosition = firstListItemPosition + listView.getChildCount() - 1;
+
+		if (pos < firstListItemPosition || pos > lastListItemPosition ) {
+			return listView.getAdapter().getView(pos, null, listView);
+		} else {
+			final int childIndex = pos - firstListItemPosition;
+			return listView.getChildAt(childIndex);
+		}
+	}
+
 }