diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index e1bd8fc2f..29c8db3f2 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -164,7 +164,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption"; private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message"; protected final ArrayList messages = new ArrayList<>(); + protected final ArrayList 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; @@ -584,9 +586,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } public void populateWithMessages(final List messages) { - synchronized (this.messages) { + if (historyPartMessages.size() > 0) { messages.clear(); - messages.addAll(this.messages); + messages.addAll(this.historyPartMessages); + } else { + synchronized (this.messages) { + messages.clear(); + messages.addAll(this.messages); + } } } @@ -1144,23 +1151,38 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl String res1 = message.getCounterpart() == null ? null : message.getCounterpart().getResource(); String res2 = nextCounterpart == null ? null : nextCounterpart.getResource(); + List properListToAdd; + + if (!historyPartMessages.isEmpty()) { + properListToAdd = historyPartMessages; + } else { + properListToAdd = this.messages; + } + if (nextCounterpart == null) { if (!message.isPrivateMessage()) { synchronized (this.messages) { - this.messages.add(Math.min(offset, this.messages.size()), message); + properListToAdd.add(Math.min(offset, properListToAdd.size()), message); } } } else { if (message.isPrivateMessage() && Objects.equals(res1, res2)) { 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 messages) { - ArrayList newM = new ArrayList<>(); + public void addAll(int index, List messages, boolean fromPagination) { + if (messages.isEmpty()) return; + + List newM = new ArrayList<>(); if (nextCounterpart == null) { for(Message m : messages) { @@ -1181,8 +1203,28 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + synchronized (this.messages) { - this.messages.addAll(index, newM); + List 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(newM); } @@ -1213,6 +1255,43 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + public void jumpToHistoryPart(List 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 messages) { + if (messages.isEmpty()) return true; + return findDuplicateMessage(messages.get(messages.size() - 1)) != null; + } + + private List filterExisted(List messages) { + if (messages.isEmpty()) return Collections.emptyList(); + + List 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(); diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 7e1b1ef70..fdcd0f17c 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -11,6 +11,8 @@ import android.os.SystemClock; import android.util.Base64; import android.util.Log; +import androidx.annotation.Nullable; + import com.google.common.base.Stopwatch; import org.json.JSONException; @@ -31,6 +33,7 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -799,45 +802,86 @@ public class DatabaseBackend extends SQLiteOpenHelper { } public ArrayList getMessages(Conversation conversations, int limit) { - return getMessages(conversations, limit, -1); + return getMessages(conversations, limit, -1, false); } - public ArrayList getMessages(Conversation conversation, int limit, long timestamp) { + + @Nullable + public ArrayList 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 prev = getMessages(conversation, limit / 2, anchorMessage.getTimeSent(), false); + List next = getMessages(conversation, limit / 2, anchorMessage.getTimeSent(), true); + + ArrayList list = new ArrayList<>(prev); + list.add(anchorMessage); + list.addAll(next); + + return list; + } + + public ArrayList getMessages(Conversation conversation, int limit, long timestamp, boolean isForward) { ArrayList list = new ArrayList<>(); SQLiteDatabase db = this.getReadableDatabase(); Cursor cursor; + String comparsionOperation = isForward ? ">?" : " markMessage(message, Message.STATUS_WAITING)); conversation.findUnreadMessagesAndCalls(mNotificationService::pushFromBacklog); } @@ -2175,20 +2175,46 @@ public class XmppConnectionService extends Service { } } - public void loadMoreMessages(final Conversation conversation, final long timestamp, final OnMoreMessagesLoaded callback) { + public void jumpToMessage(final Conversation conversation, final String uuid, JumpToMessageListener listener) { + final Runnable runnable = () -> { + List 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().queryInProgress(conversation, callback)) { return; } else if (timestamp == 0) { return; } - Log.d(Config.LOGTAG, "load more messages for " + 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 messages = databaseBackend.getMessages(conversation, 50, timestamp); + List 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; @@ -2359,7 +2385,7 @@ public class XmppConnectionService extends Service { final Conversation c = conversation; final Runnable runnable = () -> { 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); } @@ -5085,6 +5111,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); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 8747c5a77..67e6b8275 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -14,6 +14,7 @@ import android.app.Activity; import android.app.Fragment; import android.app.FragmentManager; import android.app.PendingIntent; +import android.app.ProgressDialog; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.DialogInterface; @@ -37,6 +38,7 @@ import android.text.TextUtils; import android.text.format.DateUtils; import android.util.Log; import android.util.TypedValue; +import android.util.Range; import android.view.ActionMode; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; @@ -49,6 +51,7 @@ import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; +import android.view.animation.CycleInterpolator; import android.view.ViewParent; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; @@ -59,6 +62,7 @@ import android.widget.AdapterView.AdapterContextMenuInfo; import android.widget.CheckBox; import android.widget.ListView; import android.widget.PopupMenu; +import android.widget.TextView; import android.widget.TextView.OnEditorActionListener; import android.widget.Toast; @@ -71,6 +75,7 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.databinding.DataBindingUtil; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.viewpager.widget.PagerAdapter; import com.google.common.base.Optional; @@ -84,10 +89,13 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -218,6 +226,8 @@ public class ConversationFragment extends XmppFragment @ColorInt private int primaryColor = -1; + private LinkedList replyJumps = new LinkedList<>(); + private ActionMode selectionActionMode; private final OnClickListener clickToMuc = new OnClickListener() { @@ -291,115 +301,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) { - long timestamp; - if (messageList.get(0).getType() == Message.TYPE_STATUS - && messageList.size() >= 2) { - timestamp = messageList.get(1).getTimeSent(); - } else { - timestamp = messageList.get(0).getTimeSent(); - } - activity.xmppConnectionService.loadMoreMessages( - conversation, - timestamp, - 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); - 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(); - }); - } - }); - } + 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); + 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); + } + } + }); + } + + @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 @@ -546,6 +587,29 @@ 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.getMergedTimeSent() > lastVisibleMessage.getMergedTimeSent()) { + 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); } }; @@ -673,6 +737,8 @@ public class ConversationFragment extends XmppFragment 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); if (fragment instanceof ConversationFragment) { @@ -770,7 +836,7 @@ public class ConversationFragment extends XmppFragment if (conversation == null) { return; } - if (scrolledToBottom(listView)) { + if (scrolledToBottom(listView) && !conversation.isInHistoryPart()) { lastMessageUuid = null; hideUnreadMessagesCount(); } else { @@ -797,6 +863,27 @@ public class ConversationFragment extends XmppFragment return -1; } + + private int getIndexOfExtended(String uuid, List 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 @@ -1265,6 +1352,9 @@ public class ConversationFragment extends XmppFragment public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); + if (savedInstanceState == null) { + conversation.jumpToLatest(); + } } @Override @@ -1391,6 +1481,7 @@ public class ConversationFragment extends XmppFragment quoteMessage(message); } }); + messageListAdapter.setReplyClickListener(this::scrollToReply); binding.messagesView.setAdapter(messageListAdapter); @@ -1457,7 +1548,7 @@ public class ConversationFragment extends XmppFragment SpannableStringBuilder body = message.getBodyForDisplaying(); if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️"); - messageListAdapter.handleTextQuotes(body, activity.isDarkTheme(), false); + messageListAdapter.handleTextQuotes(body, activity.isDarkTheme(), false, message); binding.contextPreviewText.setText(body); binding.contextPreviewAuthor.setText(message.getAvatarName()); binding.contextPreview.setVisibility(View.VISIBLE); @@ -1470,6 +1561,111 @@ public class ConversationFragment extends XmppFragment } } + private void scrollToReply(Message message) { + Element reply = message.getReply(); + 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.03f) + .scaleY(1.03f) + .setInterpolator(new CycleInterpolator(0.5f)) + .setDuration(300L) + .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 @@ -2756,6 +2952,8 @@ public class ConversationFragment extends XmppFragment refreshCommands(false); } + replyJumps.clear(); + return true; } @@ -2808,6 +3006,7 @@ public class ConversationFragment extends XmppFragment } this.binding.scrollToBottomButton.setEnabled(false); this.binding.scrollToBottomButton.hide(); + replyJumps.clear(); this.binding.unreadCountCustomView.setVisibility(View.GONE); } @@ -2819,7 +3018,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) { @@ -2913,6 +3112,12 @@ public class ConversationFragment extends XmppFragment if (message != null) { startDownloadable(message); } + + 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) { diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index 16bce04f4..58d035baa 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -110,6 +110,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio 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 VIEW_AND_SHARE_ACTIONS = Arrays.asList( ACTION_VIEW_CONVERSATION, diff --git a/src/main/java/eu/siacs/conversations/ui/SearchActivity.java b/src/main/java/eu/siacs/conversations/ui/SearchActivity.java index ecdd558f6..c6ddeb03d 100644 --- a/src/main/java/eu/siacs/conversations/ui/SearchActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SearchActivity.java @@ -163,7 +163,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); diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index d6c87fe4c..49647efaa 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -561,6 +561,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, messageUuid); + } + public void switchToConversationAndQuote(Conversation conversation, String text) { switchToConversation(conversation, text, true, null, false, false); } @@ -575,7 +579,7 @@ public abstract class XmppActivity extends ActionBarActivity { protected void switchToConversationDoNotAppend(Contact contact, String body, String postInit) { Conversation conversation = xmppConnectionService.findOrCreateConversation(contact.getAccount(), contact.getJid(), null, false, false, true, null); - switchToConversation(conversation, body, false, null, false, true, postInit); + switchToConversation(conversation, body, false, null, false, true, postInit, null); } public void highlightInMuc(Conversation conversation, String nick) { @@ -588,10 +592,10 @@ public abstract class XmppActivity extends ActionBarActivity { } public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend) { - switchToConversation(conversation, text, asQuote, nick, pm, doNotAppend, null); + switchToConversation(conversation, text, asQuote, nick, pm, doNotAppend, null, null); } - public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend, String postInit) { + public void switchToConversation(Conversation conversation, String text, boolean asQuote, String nick, boolean pm, boolean doNotAppend, String postInit, String messageUuid) { if (conversation == null) return; Intent intent = new Intent(this, ConversationsActivity.class); @@ -610,6 +614,11 @@ 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); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index 5f38b9154..560bf88b0 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -14,7 +14,10 @@ import android.preference.PreferenceManager; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; +import android.text.TextPaint; import android.text.format.DateUtils; +import android.text.style.CharacterStyle; +import android.text.style.ClickableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; @@ -33,6 +36,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; @@ -103,6 +107,7 @@ public class MessageAdapter extends ArrayAdapter { private MessageEmptyPartClickListener messageEmptyPartClickListener; private SelectionStatusProvider selectionStatusProvider; private MessageBoxSwipedListener messageBoxSwipedListener; + private ReplyClickListener replyClickListener; private boolean mUseGreenBackground = false; private final boolean mForceNames; @@ -193,6 +198,10 @@ public class MessageAdapter extends ArrayAdapter { this.messageBoxSwipedListener = listener; } + public void setReplyClickListener(ReplyClickListener listener) { + this.replyClickListener = listener; + } + @Override public int getViewTypeCount() { return 5; @@ -402,7 +411,7 @@ public class MessageAdapter extends ArrayAdapter { viewHolder.messageBody.setText(span); } - private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground, boolean highlightReply) { + private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground, boolean highlightReply, Message message) { if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) { body.insert(start++, "\n"); body.setSpan( @@ -427,13 +436,28 @@ public class MessageAdapter extends ArrayAdapter { DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); body.setSpan(new QuoteSpan(color, highlightReply ? ContextCompat.getColor(activity, R.color.blue_a100) : -1, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + if (highlightReply) { + body.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + if (replyClickListener != null) { + replyClickListener.onReplyClick(message); + } + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + ds.setUnderlineText(false); + } + }, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } } /** * Applies QuoteSpan to group of lines which starts with > or » characters. * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text. */ - public boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground, boolean highlightReply) { + public boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground, boolean highlightReply, Message message) { boolean startsWithQuote = false; int quoteDepth = 1; while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) { @@ -452,7 +476,7 @@ public class MessageAdapter extends ArrayAdapter { if (i == 0) startsWithQuote = true; } else if (quoteStart >= 0) { // Line start without quote, apply spans there - applyQuoteSpan(body, quoteStart, i - 1, darkBackground, quoteDepth == 1 && highlightReply); + applyQuoteSpan(body, quoteStart, i - 1, darkBackground, quoteDepth == 1 && highlightReply, message); quoteStart = -1; } } @@ -477,7 +501,7 @@ public class MessageAdapter extends ArrayAdapter { } if (quoteStart >= 0) { // Apply spans to finishing open quote - applyQuoteSpan(body, quoteStart, body.length(), darkBackground, quoteDepth == 1 && highlightReply); + applyQuoteSpan(body, quoteStart, body.length(), darkBackground, quoteDepth == 1 && highlightReply, message); } quoteDepth++; } @@ -521,10 +545,10 @@ public class MessageAdapter extends ArrayAdapter { int start = body.getSpanStart(quote); int end = body.getSpanEnd(quote); body.removeSpan(quote); - applyQuoteSpan(body, start, end, darkBackground, message.getReply() != null); + applyQuoteSpan(body, start, end, darkBackground, message.getReply() != null, message); } - boolean startsWithQuote = handleTextQuotes(body, darkBackground, message.getReply() != null); + boolean startsWithQuote = handleTextQuotes(body, darkBackground, message.getReply() != null, message); if (!message.isPrivateMessage()) { if (hasMeCommand) { body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(), @@ -1123,6 +1147,10 @@ public class MessageAdapter extends ArrayAdapter { void onMessageBoxSwiped(Message message); } + public interface ReplyClickListener { + void onReplyClick(Message message); + } + private static class ViewHolder { public View root; diff --git a/src/main/java/eu/siacs/conversations/ui/util/ListViewUtils.java b/src/main/java/eu/siacs/conversations/ui/util/ListViewUtils.java index 4fae7f13a..b24362dc9 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/ListViewUtils.java +++ b/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); + } + } + }