clickable replies

This commit is contained in:
kosyak 2023-10-27 05:31:52 +02:00
parent f2012bc7f5
commit 2d92736810
9 changed files with 551 additions and 142 deletions

View file

@ -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_NEXT_ENCRYPTION = "next_encryption";
private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message"; private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
protected final ArrayList<Message> messages = new ArrayList<>(); protected final ArrayList<Message> messages = new ArrayList<>();
protected final ArrayList<Message> historyPartMessages = new ArrayList<>();
public AtomicBoolean messagesLoaded = new AtomicBoolean(true); public AtomicBoolean messagesLoaded = new AtomicBoolean(true);
public AtomicBoolean historyPartLoadedForward = new AtomicBoolean(true);
protected Account account = null; protected Account account = null;
private String draftMessage; private String draftMessage;
private final String name; private final String name;
@ -584,9 +586,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
} }
public void populateWithMessages(final List<Message> messages) { public void populateWithMessages(final List<Message> messages) {
synchronized (this.messages) { if (historyPartMessages.size() > 0) {
messages.clear(); 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 res1 = message.getCounterpart() == null ? null : message.getCounterpart().getResource();
String res2 = nextCounterpart == null ? null : nextCounterpart.getResource(); String res2 = nextCounterpart == null ? null : nextCounterpart.getResource();
List<Message> properListToAdd;
if (!historyPartMessages.isEmpty()) {
properListToAdd = historyPartMessages;
} else {
properListToAdd = this.messages;
}
if (nextCounterpart == null) { if (nextCounterpart == null) {
if (!message.isPrivateMessage()) { if (!message.isPrivateMessage()) {
synchronized (this.messages) { synchronized (this.messages) {
this.messages.add(Math.min(offset, this.messages.size()), message); properListToAdd.add(Math.min(offset, properListToAdd.size()), message);
} }
} }
} else { } else {
if (message.isPrivateMessage() && Objects.equals(res1, res2)) { if (message.isPrivateMessage() && Objects.equals(res1, res2)) {
synchronized (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) {
ArrayList<Message> newM = new ArrayList<>(); if (messages.isEmpty()) return;
List<Message> newM = new ArrayList<>();
if (nextCounterpart == null) { if (nextCounterpart == null) {
for(Message m : messages) { for(Message m : messages) {
@ -1181,8 +1203,28 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
} }
} }
synchronized (this.messages) { synchronized (this.messages) {
this.messages.addAll(index, newM); 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(newM); account.getPgpDecryptionService().decrypt(newM);
} }
@ -1213,6 +1255,43 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
} }
} }
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() { private void untieMessages() {
for (Message message : this.messages) { for (Message message : this.messages) {
message.untie(); message.untie();

View file

@ -11,6 +11,8 @@ import android.os.SystemClock;
import android.util.Base64; import android.util.Base64;
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable;
import com.google.common.base.Stopwatch; import com.google.common.base.Stopwatch;
import org.json.JSONException; import org.json.JSONException;
@ -31,6 +33,7 @@ import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory; import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate; import java.security.cert.X509Certificate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@ -799,45 +802,86 @@ public class DatabaseBackend extends SQLiteOpenHelper {
} }
public ArrayList<Message> getMessages(Conversation conversations, int limit) { public ArrayList<Message> getMessages(Conversation conversations, int limit) {
return getMessages(conversations, limit, -1); return getMessages(conversations, limit, -1, false);
} }
public ArrayList<Message> getMessages(Conversation conversation, int limit, long timestamp) {
@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 ArrayList<Message> getMessages(Conversation conversation, int limit, long timestamp, boolean isForward) {
ArrayList<Message> list = new ArrayList<>(); ArrayList<Message> list = new ArrayList<>();
SQLiteDatabase db = this.getReadableDatabase(); SQLiteDatabase db = this.getReadableDatabase();
Cursor cursor; Cursor cursor;
String comparsionOperation = isForward ? ">?" : "<?";
String sorting = isForward ? " ASC" : " DESC";
if (timestamp == -1) { if (timestamp == -1) {
if (conversation.getNextCounterpart() == null) { if (conversation.getNextCounterpart() == null) {
String[] selectionArgs = {conversation.getUuid()}; String[] selectionArgs = {conversation.getUuid()};
cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION
+ "=?", selectionArgs, null, null, Message.TIME_SENT + "=?", selectionArgs, null, null, Message.TIME_SENT
+ " DESC", String.valueOf(limit)); + sorting, String.valueOf(limit));
} else { } else {
String[] selectionArgs = {conversation.getUuid(), String.valueOf(Message.TYPE_PRIVATE), String.valueOf(Message.TYPE_PRIVATE_FILE), conversation.getNextCounterpart().toString()}; String[] selectionArgs = {conversation.getUuid(), String.valueOf(Message.TYPE_PRIVATE), String.valueOf(Message.TYPE_PRIVATE_FILE), conversation.getNextCounterpart().toString()};
cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION
+ "=? and (" + Message.TYPE + "=? or " + Message.TYPE + "=?) and " + Message.COUNTERPART + "=?" , selectionArgs, null, null, Message.TIME_SENT + "=? and (" + Message.TYPE + "=? or " + Message.TYPE + "=?) and " + Message.COUNTERPART + "=?" , selectionArgs, null, null, Message.TIME_SENT
+ " DESC", String.valueOf(limit)); + sorting, String.valueOf(limit));
} }
} else { } else {
if (conversation.getNextCounterpart() == null) { if (conversation.getNextCounterpart() == null) {
String[] selectionArgs = {conversation.getUuid(), String[] selectionArgs = {conversation.getUuid(),
Long.toString(timestamp)}; Long.toString(timestamp)};
cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION
+ "=? and " + Message.TIME_SENT + "<?", selectionArgs, + "=? and " + Message.TIME_SENT + comparsionOperation, selectionArgs,
null, null, Message.TIME_SENT + " DESC", null, null, Message.TIME_SENT + sorting,
String.valueOf(limit)); String.valueOf(limit));
} else { } else {
String[] selectionArgs = {conversation.getUuid(), String.valueOf(Message.TYPE_PRIVATE), String.valueOf(Message.TYPE_PRIVATE_FILE), conversation.getNextCounterpart().toString(), Long.toString(timestamp)}; String[] selectionArgs = {conversation.getUuid(), String.valueOf(Message.TYPE_PRIVATE), String.valueOf(Message.TYPE_PRIVATE_FILE), conversation.getNextCounterpart().toString(), Long.toString(timestamp)};
cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION cursor = db.query(Message.TABLENAME, null, Message.CONVERSATION
+ "=? and (" + Message.TYPE + "=? or " + Message.TYPE + "=?) and " + Message.COUNTERPART + "=? and " + Message.TIME_SENT + "<?" , selectionArgs, null, null, Message.TIME_SENT + "=? and (" + Message.TYPE + "=? or " + Message.TYPE + "=?) and " + Message.COUNTERPART + "=? and " + Message.TIME_SENT + comparsionOperation, selectionArgs, null, null, Message.TIME_SENT
+ " DESC", String.valueOf(limit)); + sorting, String.valueOf(limit));
} }
} }
CursorUtils.upgradeCursorWindowSize(cursor); CursorUtils.upgradeCursorWindowSize(cursor);
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
try { try {
Message m = Message.fromCursor(cursor, conversation); Message m = Message.fromCursor(cursor, conversation);
list.add(0, m); if (isForward) {
list.add(m);
} else {
list.add(0, m);
}
} catch (Exception e) { } catch (Exception e) {
Log.e(Config.LOGTAG, "unable to restore message"); Log.e(Config.LOGTAG, "unable to restore message");
} }

View file

@ -2050,7 +2050,7 @@ public class XmppConnectionService extends Service {
} }
private void restoreMessages(Conversation conversation) { 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.findUnsentTextMessages(message -> markMessage(message, Message.STATUS_WAITING));
conversation.findUnreadMessagesAndCalls(mNotificationService::pushFromBacklog); 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<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().queryInProgress(conversation, callback)) { if (XmppConnectionService.this.getMessageArchiveService().queryInProgress(conversation, callback)) {
return; return;
} else if (timestamp == 0) { } else if (timestamp == 0) {
return; 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 Runnable runnable = () -> {
final Account account = conversation.getAccount(); 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) { 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); callback.onMoreMessagesLoaded(messages.size(), conversation);
} else if (conversation.hasMessagesLeftOnServer() } else if (!isForward &&
conversation.hasMessagesLeftOnServer()
&& account.isOnlineAndConnected() && account.isOnlineAndConnected()
&& conversation.getLastClearHistory().getTimestamp() == 0) { && conversation.getLastClearHistory().getTimestamp() == 0) {
final boolean mamAvailable; final boolean mamAvailable;
@ -2359,7 +2385,7 @@ public class XmppConnectionService extends Service {
final Conversation c = conversation; final Conversation c = conversation;
final Runnable runnable = () -> { final Runnable runnable = () -> {
if (loadMessagesFromDb) { if (loadMessagesFromDb) {
c.addAll(0, databaseBackend.getMessages(c, Config.PAGE_SIZE)); c.addAll(0, databaseBackend.getMessages(c, Config.PAGE_SIZE), false);
updateConversationUi(); updateConversationUi();
c.messagesLoaded.set(true); c.messagesLoaded.set(true);
} }
@ -5085,6 +5111,11 @@ public class XmppConnectionService extends Service {
void informUser(int r); void informUser(int r);
} }
public interface JumpToMessageListener {
void onSuccess();
void onNotFound();
}
public interface OnMoreMessagesLoaded { public interface OnMoreMessagesLoaded {
void onMoreMessagesLoaded(int count, Conversation conversation); void onMoreMessagesLoaded(int count, Conversation conversation);

View file

@ -14,6 +14,7 @@ import android.app.Activity;
import android.app.Fragment; import android.app.Fragment;
import android.app.FragmentManager; import android.app.FragmentManager;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.ProgressDialog;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
@ -37,6 +38,7 @@ import android.text.TextUtils;
import android.text.format.DateUtils; import android.text.format.DateUtils;
import android.util.Log; import android.util.Log;
import android.util.TypedValue; import android.util.TypedValue;
import android.util.Range;
import android.view.ActionMode; import android.view.ActionMode;
import android.view.ContextMenu; import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo; import android.view.ContextMenu.ContextMenuInfo;
@ -49,6 +51,7 @@ import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.View.OnClickListener; import android.view.View.OnClickListener;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.CycleInterpolator;
import android.view.ViewParent; import android.view.ViewParent;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodManager;
@ -59,6 +62,7 @@ import android.widget.AdapterView.AdapterContextMenuInfo;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.ListView; import android.widget.ListView;
import android.widget.PopupMenu; import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.TextView.OnEditorActionListener; import android.widget.TextView.OnEditorActionListener;
import android.widget.Toast; 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.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.databinding.DataBindingUtil; import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.PagerAdapter;
import com.google.common.base.Optional; import com.google.common.base.Optional;
@ -84,10 +89,13 @@ import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@ -218,6 +226,8 @@ public class ConversationFragment extends XmppFragment
@ColorInt @ColorInt
private int primaryColor = -1; private int primaryColor = -1;
private LinkedList<Message> replyJumps = new LinkedList<>();
private ActionMode selectionActionMode; private ActionMode selectionActionMode;
private final OnClickListener clickToMuc = private final OnClickListener clickToMuc =
new OnClickListener() { new OnClickListener() {
@ -291,115 +301,146 @@ public class ConversationFragment extends XmppFragment
int totalItemCount) { int totalItemCount) {
toggleScrollDownButton(view); toggleScrollDownButton(view);
synchronized (ConversationFragment.this.messageList) { synchronized (ConversationFragment.this.messageList) {
if (firstVisibleItem < 5 boolean paginateBackward = firstVisibleItem < 5;
&& conversation != null boolean paginationForward = conversation.isInHistoryPart() && firstVisibleItem + visibleItemCount + 5 > totalItemCount;
&& conversation.messagesLoaded.compareAndSet(true, false) loadMoreMessages(paginateBackward, paginationForward, view);
&& 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();
});
}
});
}
} }
} }
}; };
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 = private final EditMessage.OnCommitContentListener mEditorContentListener =
new EditMessage.OnCommitContentListener() { new EditMessage.OnCommitContentListener() {
@Override @Override
@ -546,6 +587,29 @@ public class ConversationFragment extends XmppFragment
@Override @Override
public void onClick(View v) { public void onClick(View v) {
stopScrolling(); 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); setSelection(binding.messagesView.getCount() - 1, true);
} }
}; };
@ -673,6 +737,8 @@ public class ConversationFragment extends XmppFragment
private boolean firstWord = false; private boolean firstWord = false;
private Message mPendingDownloadableMessage; private Message mPendingDownloadableMessage;
private ProgressDialog fetchHistoryDialog;
private static ConversationFragment findConversationFragment(Activity activity) { private static ConversationFragment findConversationFragment(Activity activity) {
Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment); Fragment fragment = activity.getFragmentManager().findFragmentById(R.id.main_fragment);
if (fragment instanceof ConversationFragment) { if (fragment instanceof ConversationFragment) {
@ -770,7 +836,7 @@ public class ConversationFragment extends XmppFragment
if (conversation == null) { if (conversation == null) {
return; return;
} }
if (scrolledToBottom(listView)) { if (scrolledToBottom(listView) && !conversation.isInHistoryPart()) {
lastMessageUuid = null; lastMessageUuid = null;
hideUnreadMessagesCount(); hideUnreadMessagesCount();
} else { } else {
@ -797,6 +863,27 @@ public class ConversationFragment extends XmppFragment
return -1; 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() { private ScrollState getScrollPosition() {
final ListView listView = this.binding == null ? null : this.binding.messagesView; final ListView listView = this.binding == null ? null : this.binding.messagesView;
if (listView == null if (listView == null
@ -1265,6 +1352,9 @@ public class ConversationFragment extends XmppFragment
public void onCreate(Bundle savedInstanceState) { public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setHasOptionsMenu(true); setHasOptionsMenu(true);
if (savedInstanceState == null) {
conversation.jumpToLatest();
}
} }
@Override @Override
@ -1391,6 +1481,7 @@ public class ConversationFragment extends XmppFragment
quoteMessage(message); quoteMessage(message);
} }
}); });
messageListAdapter.setReplyClickListener(this::scrollToReply);
binding.messagesView.setAdapter(messageListAdapter); binding.messagesView.setAdapter(messageListAdapter);
@ -1457,7 +1548,7 @@ public class ConversationFragment extends XmppFragment
SpannableStringBuilder body = message.getBodyForDisplaying(); SpannableStringBuilder body = message.getBodyForDisplaying();
if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️"); 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.contextPreviewText.setText(body);
binding.contextPreviewAuthor.setText(message.getAvatarName()); binding.contextPreviewAuthor.setText(message.getAvatarName());
binding.contextPreview.setVisibility(View.VISIBLE); 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 @Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) {
// This should cancel any remaining click events that would otherwise trigger links // This should cancel any remaining click events that would otherwise trigger links
@ -2756,6 +2952,8 @@ public class ConversationFragment extends XmppFragment
refreshCommands(false); refreshCommands(false);
} }
replyJumps.clear();
return true; return true;
} }
@ -2808,6 +3006,7 @@ public class ConversationFragment extends XmppFragment
} }
this.binding.scrollToBottomButton.setEnabled(false); this.binding.scrollToBottomButton.setEnabled(false);
this.binding.scrollToBottomButton.hide(); this.binding.scrollToBottomButton.hide();
replyJumps.clear();
this.binding.unreadCountCustomView.setVisibility(View.GONE); this.binding.unreadCountCustomView.setVisibility(View.GONE);
} }
@ -2819,7 +3018,7 @@ public class ConversationFragment extends XmppFragment
} }
private boolean scrolledToBottom() { 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) { private void processExtras(final Bundle extras) {
@ -2913,6 +3112,12 @@ public class ConversationFragment extends XmppFragment
if (message != null) { if (message != null) {
startDownloadable(message); 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) { private Element commandFor(final Jid jid, final String node) {

View file

@ -110,6 +110,7 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
public static final String EXTRA_TYPE = "type"; public static final String EXTRA_TYPE = "type";
public static final String EXTRA_NODE = "node"; public static final String EXTRA_NODE = "node";
public static final String EXTRA_JID = "jid"; 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( private static final List<String> VIEW_AND_SHARE_ACTIONS = Arrays.asList(
ACTION_VIEW_CONVERSATION, ACTION_VIEW_CONVERSATION,

View file

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

View file

@ -561,6 +561,10 @@ public abstract class XmppActivity extends ActionBarActivity {
switchToConversation(conversation, null); 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) { public void switchToConversationAndQuote(Conversation conversation, String text) {
switchToConversation(conversation, text, true, null, false, false); 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) { protected void switchToConversationDoNotAppend(Contact contact, String body, String postInit) {
Conversation conversation = xmppConnectionService.findOrCreateConversation(contact.getAccount(), contact.getJid(), null, false, false, true, null); 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) { 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) { 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; if (conversation == null) return;
Intent intent = new Intent(this, ConversationsActivity.class); Intent intent = new Intent(this, ConversationsActivity.class);
@ -610,6 +614,11 @@ public abstract class XmppActivity extends ActionBarActivity {
if (doNotAppend) { if (doNotAppend) {
intent.putExtra(ConversationsActivity.EXTRA_DO_NOT_APPEND, true); 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.putExtra(ConversationsActivity.EXTRA_POST_INIT_ACTION, postInit);
intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP); intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent); startActivity(intent);

View file

@ -14,7 +14,10 @@ import android.preference.PreferenceManager;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.format.DateUtils; import android.text.format.DateUtils;
import android.text.style.CharacterStyle;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.text.style.RelativeSizeSpan; import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan; import android.text.style.StyleSpan;
@ -33,6 +36,7 @@ import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.ColorInt; import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
@ -103,6 +107,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
private MessageEmptyPartClickListener messageEmptyPartClickListener; private MessageEmptyPartClickListener messageEmptyPartClickListener;
private SelectionStatusProvider selectionStatusProvider; private SelectionStatusProvider selectionStatusProvider;
private MessageBoxSwipedListener messageBoxSwipedListener; private MessageBoxSwipedListener messageBoxSwipedListener;
private ReplyClickListener replyClickListener;
private boolean mUseGreenBackground = false; private boolean mUseGreenBackground = false;
private final boolean mForceNames; private final boolean mForceNames;
@ -193,6 +198,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
this.messageBoxSwipedListener = listener; this.messageBoxSwipedListener = listener;
} }
public void setReplyClickListener(ReplyClickListener listener) {
this.replyClickListener = listener;
}
@Override @Override
public int getViewTypeCount() { public int getViewTypeCount() {
return 5; return 5;
@ -402,7 +411,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
viewHolder.messageBody.setText(span); 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())) { if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
body.insert(start++, "\n"); body.insert(start++, "\n");
body.setSpan( body.setSpan(
@ -427,13 +436,28 @@ public class MessageAdapter extends ArrayAdapter<Message> {
DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); 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); 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. * 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. * 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; boolean startsWithQuote = false;
int quoteDepth = 1; int quoteDepth = 1;
while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) { while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
@ -452,7 +476,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
if (i == 0) startsWithQuote = true; if (i == 0) startsWithQuote = true;
} else if (quoteStart >= 0) { } else if (quoteStart >= 0) {
// Line start without quote, apply spans there // 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; quoteStart = -1;
} }
} }
@ -477,7 +501,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
} }
if (quoteStart >= 0) { if (quoteStart >= 0) {
// Apply spans to finishing open quote // 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++; quoteDepth++;
} }
@ -521,10 +545,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
int start = body.getSpanStart(quote); int start = body.getSpanStart(quote);
int end = body.getSpanEnd(quote); int end = body.getSpanEnd(quote);
body.removeSpan(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 (!message.isPrivateMessage()) {
if (hasMeCommand) { if (hasMeCommand) {
body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(), body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(),
@ -1123,6 +1147,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
void onMessageBoxSwiped(Message message); void onMessageBoxSwiped(Message message);
} }
public interface ReplyClickListener {
void onReplyClick(Message message);
}
private static class ViewHolder { private static class ViewHolder {
public View root; public View root;

View file

@ -53,5 +53,17 @@ public class ListViewUtils {
listView.setSelection(pos); 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);
}
}
} }