Files
arcanechat-android/src/main/java/org/thoughtcrime/securesms/ConversationFragment.java
T
B. Petersen eeb558d94d sticker alerts: do not repeat text in title and message
moreover, the "Delete" button is shown as being destructive.
this removes noise and is more consistent with other confirmations.
2026-03-30 14:33:50 +02:00

1177 lines
42 KiB
Java

/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import static com.b44t.messenger.DcContact.DC_CONTACT_ID_SELF;
import static org.thoughtcrime.securesms.util.ShareUtil.setForwardingMessageIds;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import chat.delta.rpc.Rpc;
import chat.delta.rpc.RpcException;
import com.b44t.messenger.DcChat;
import com.b44t.messenger.DcContact;
import com.b44t.messenger.DcContext;
import com.b44t.messenger.DcEvent;
import com.b44t.messenger.DcMsg;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import org.thoughtcrime.securesms.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.components.audioplay.AudioPlaybackViewModel;
import org.thoughtcrime.securesms.components.reminder.DozeReminder;
import org.thoughtcrime.securesms.connect.DcEventCenter;
import org.thoughtcrime.securesms.connect.DcHelper;
import org.thoughtcrime.securesms.database.Address;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.reactions.AddReactionView;
import org.thoughtcrime.securesms.reactions.ReactionsDetailsFragment;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.relay.EditRelayActivity;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.ConversationAdaptiveActionsToolbar;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends MessageSelectorFragment {
private static final String TAG = ConversationFragment.class.getSimpleName();
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
private final ItemClickListener selectionClickListener =
new ConversationFragmentItemClickListener();
private ConversationFragmentListener listener;
private Recipient recipient;
private long chatId;
private int startingPosition;
private boolean firstLoad;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private StickyHeaderDecoration dateDecoration;
private View scrollToBottomButton;
private View floatingLocationButton;
private View bottomDivider;
private AddReactionView addReactionView;
private TextView noMessageTextView;
private Timer reloadTimer;
public boolean isPaused;
private Debouncer markseenDebouncer;
private Rpc rpc;
private boolean pendingAddBottomInsets;
private boolean pendingRemoveBottomInsets;
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
rpc = DcHelper.getRpc(getContext());
DcEventCenter eventCenter = DcHelper.getEventCenter(getContext());
eventCenter.addObserver(DcContext.DC_EVENT_INCOMING_MSG, this);
eventCenter.addObserver(DcContext.DC_EVENT_MSGS_CHANGED, this);
eventCenter.addObserver(DcContext.DC_EVENT_REACTIONS_CHANGED, this);
eventCenter.addObserver(DcContext.DC_EVENT_MSG_DELIVERED, this);
eventCenter.addObserver(DcContext.DC_EVENT_MSG_FAILED, this);
eventCenter.addObserver(DcContext.DC_EVENT_MSG_READ, this);
eventCenter.addObserver(DcContext.DC_EVENT_CHAT_MODIFIED, this);
markseenDebouncer = new Debouncer(800);
reloadTimer = new Timer("reloadTimer", false);
reloadTimer.scheduleAtFixedRate(
new TimerTask() {
@Override
public void run() {
Util.runOnMain(ConversationFragment.this::reloadList);
}
},
60 * 1000,
60 * 1000);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
list = ViewUtil.findById(view, android.R.id.list);
scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button);
floatingLocationButton = ViewUtil.findById(view, R.id.floating_location_button);
addReactionView = ViewUtil.findById(view, R.id.add_reaction_view);
noMessageTextView = ViewUtil.findById(view, R.id.no_messages_text_view);
bottomDivider = ViewUtil.findById(view, R.id.bottom_divider);
scrollToBottomButton.setOnClickListener(v -> scrollToBottom());
final SetStartingPositionLinearLayoutManager layoutManager =
new SetStartingPositionLinearLayoutManager(
getActivity(), LinearLayoutManager.VERTICAL, true);
list.setHasFixedSize(false);
list.setLayoutManager(layoutManager);
list.setItemAnimator(null);
new ConversationItemSwipeCallback(msg -> actionMode == null, this::handleReplyMessage)
.attachToRecyclerView(list);
// setLayerType() is needed to allow larger items (long texts in our case)
// with hardware layers, drawing may result in errors as "OpenGLRenderer: Path too large to be
// rendered into a texture"
list.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
if (pendingAddBottomInsets) {
bottomDivider.setVisibility(View.GONE);
ViewUtil.forceApplyWindowInsets(list, false, true, false, true);
ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, true);
pendingAddBottomInsets = false;
}
if (pendingRemoveBottomInsets) {
bottomDivider.setVisibility(View.VISIBLE);
ViewUtil.forceApplyWindowInsets(list, false, true, false, false);
ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, true, true, true, false);
pendingRemoveBottomInsets = false;
}
return view;
}
@Override
public void onViewCreated(@NonNull View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
initializeResources();
initializeListAdapter();
}
private void setNoMessageText() {
DcChat dcChat = getListAdapter().getChat();
if (dcChat.isMultiUser()) {
if (dcChat.isInBroadcast() || dcChat.isOutBroadcast()) {
noMessageTextView.setText(R.string.chat_new_channel_hint);
} else if (dcChat.isUnpromoted()) {
noMessageTextView.setText(R.string.chat_new_group_hint);
} else {
noMessageTextView.setText(R.string.chat_no_messages);
}
} else if (dcChat.isSelfTalk()) {
noMessageTextView.setText(R.string.saved_messages_explain);
} else if (dcChat.isDeviceTalk()) {
noMessageTextView.setText(R.string.device_talk_explain);
} else if (!dcChat.isEncrypted()) {
noMessageTextView.setText(R.string.chat_unencrypted_explanation);
} else {
String message = getString(R.string.chat_new_one_to_one_hint, dcChat.getName());
noMessageTextView.setText(message);
}
}
public void handleAddBottomInsets() {
if (bottomDivider != null) {
bottomDivider.setVisibility(View.GONE);
ViewUtil.forceApplyWindowInsets(list, false, true, false, true);
ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, true);
pendingAddBottomInsets = false;
} else {
pendingAddBottomInsets = true;
}
}
public void handleRemoveBottomInsets() {
if (bottomDivider != null) {
bottomDivider.setVisibility(View.VISIBLE);
ViewUtil.forceApplyWindowInsets(list, false, true, false, false);
ViewUtil.forceApplyWindowInsetsAsMargin(scrollToBottomButton, false, false, false, false);
pendingRemoveBottomInsets = false;
} else {
pendingRemoveBottomInsets = true;
}
}
@Override
public void onDestroy() {
DcHelper.getEventCenter(getContext()).removeObservers(this);
reloadTimer.cancel();
super.onDestroy();
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
this.listener = (ConversationFragmentListener) activity;
}
@Override
public void onResume() {
super.onResume();
Util.runOnBackground(
() -> {
try {
rpc.marknoticedChat(rpc.getSelectedAccountId(), (int) chatId);
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
});
if (list.getAdapter() != null) {
list.getAdapter().notifyDataSetChanged();
}
if (isPaused) {
isPaused = false;
markseenDebouncer.publish(() -> manageMessageSeenState());
}
}
@Override
public void onPause() {
super.onPause();
setLastSeen(System.currentTimeMillis());
isPaused = true;
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
dateDecoration.onConfigurationChanged(newConfig);
}
public void onNewIntent() {
if (actionMode != null) {
actionMode.finish();
}
initializeResources();
initializeListAdapter();
if (chatId == -1) {
reloadList();
updateLocationButton();
}
}
public void moveToLastSeen() {
if (list == null || getListAdapter() == null) {
Log.w(TAG, "Tried to move to last seen position, but we hadn't initialized the view yet.");
return;
}
if (getListAdapter().getLastSeenPosition() < 0) {
return;
}
final int lastSeenPosition = getListAdapter().getLastSeenPosition() + 1;
if (lastSeenPosition > 0) {
list.post(
() ->
((LinearLayoutManager) list.getLayoutManager())
.scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));
}
}
public void hideAddReactionView() {
addReactionView.hide();
}
private void initializeResources() {
this.chatId =
this.getActivity().getIntent().getIntExtra(ConversationActivity.CHAT_ID_EXTRA, -1);
this.recipient = Recipient.from(getActivity(), Address.fromChat((int) this.chatId));
this.startingPosition =
this.getActivity()
.getIntent()
.getIntExtra(ConversationActivity.STARTING_POSITION_EXTRA, -1);
this.firstLoad = true;
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
list.addOnScrollListener(scrollListener);
}
private void initializeListAdapter() {
if (this.recipient != null && this.chatId != -1) {
ConversationAdapter adapter =
new ConversationAdapter(
getActivity(),
this.recipient.getChat(),
GlideApp.with(this),
selectionClickListener,
this.recipient);
list.setAdapter(adapter);
AudioPlaybackViewModel playbackViewModel =
new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class);
adapter.setPlaybackViewModel(playbackViewModel);
adapter.setAudioPlayPauseListener(((ConversationActivity) requireActivity()));
if (dateDecoration != null) {
list.removeItemDecoration(dateDecoration);
}
dateDecoration = new StickyHeaderDecoration(adapter, false, false);
list.addItemDecoration(dateDecoration);
int freshMsgs = 0;
try {
freshMsgs = rpc.getFreshMsgCnt(rpc.getSelectedAccountId(), (int) chatId);
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
SetStartingPositionLinearLayoutManager layoutManager =
(SetStartingPositionLinearLayoutManager) list.getLayoutManager();
if (startingPosition > -1) {
layoutManager.setStartingPosition(startingPosition);
} else if (freshMsgs > 0) {
layoutManager.setStartingPosition(freshMsgs - 1);
}
reloadList();
updateLocationButton();
if (lastSeenDecoration != null) {
list.removeItemDecoration(lastSeenDecoration);
}
if (freshMsgs > 0) {
getListAdapter().setLastSeenPosition(freshMsgs - 1);
lastSeenDecoration = new ConversationAdapter.LastSeenHeader(getListAdapter());
list.addItemDecoration(lastSeenDecoration);
}
}
}
@Override
protected void setCorrectMenuVisibility(Menu menu) {
Set<DcMsg> messageRecords = getListAdapter().getSelectedItems();
if (actionMode != null && messageRecords.size() == 0) {
actionMode.finish();
return;
}
if (messageRecords.size() > 1) {
menu.findItem(R.id.menu_context_details).setVisible(false);
menu.findItem(R.id.menu_context_share).setVisible(false);
menu.findItem(R.id.menu_context_reply).setVisible(false);
menu.findItem(R.id.menu_context_edit).setVisible(false);
menu.findItem(R.id.menu_context_reply_privately).setVisible(false);
menu.findItem(R.id.menu_add_to_home_screen).setVisible(false);
menu.findItem(R.id.menu_toggle_save).setVisible(false);
} else {
DcMsg messageRecord = messageRecords.iterator().next();
DcChat chat = getListAdapter().getChat();
menu.findItem(R.id.menu_context_details).setVisible(true);
menu.findItem(R.id.menu_context_share).setVisible(messageRecord.hasFile());
boolean canReply = canReplyToMsg(messageRecord);
menu.findItem(R.id.menu_context_reply).setVisible(chat.canSend() && canReply);
menu.findItem(R.id.menu_context_edit)
.setVisible(chat.isEncrypted() && chat.canSend() && canEditMsg(messageRecord));
boolean showReplyPrivately = chat.isMultiUser() && !messageRecord.isOutgoing() && canReply;
menu.findItem(R.id.menu_context_reply_privately).setVisible(showReplyPrivately);
menu.findItem(R.id.menu_add_to_home_screen)
.setVisible(messageRecord.getType() == DcMsg.DC_MSG_WEBXDC);
boolean saved = messageRecord.getSavedMsgId() != 0;
MenuItem toggleSave = menu.findItem(R.id.menu_toggle_save);
toggleSave.setVisible(messageRecord.canSave() && !chat.isSelfTalk());
toggleSave.setIcon(
saved ? R.drawable.baseline_bookmark_remove_24 : R.drawable.baseline_bookmark_border_24);
toggleSave.setTitle(saved ? R.string.unsave : R.string.save);
}
// if one of the selected items cannot be saved, disable saving.
boolean canSave = true;
// if one of the selected items is not from self, disable resending.
boolean canResend = true;
for (DcMsg messageRecord : messageRecords) {
if (canSave && !messageRecord.hasFile()) {
canSave = false;
}
if (canResend && !messageRecord.isOutgoing()) {
canResend = false;
}
if (!canSave && !canResend) {
break;
}
}
menu.findItem(R.id.menu_context_save_attachment).setVisible(canSave);
menu.findItem(R.id.menu_resend).setVisible(canResend);
}
static boolean canReplyToMsg(DcMsg dcMsg) {
return !dcMsg.isInfo();
}
static boolean canEditMsg(DcMsg dcMsg) {
return dcMsg.isOutgoing()
&& !dcMsg.isInfo()
&& dcMsg.getType() != DcMsg.DC_MSG_CALL
&& !dcMsg.hasHtml()
&& !dcMsg.getText().isEmpty();
}
public void handleClearChat() {
AudioPlaybackViewModel playbackViewModel =
new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class);
handleDeleteMessages(
(int) chatId,
getListAdapter().getMessageIds(),
playbackViewModel::stopByIds,
playbackViewModel::stopByIds);
}
private ConversationAdapter getListAdapter() {
return (ConversationAdapter) list.getAdapter();
}
public void reload(Recipient recipient, long chatId) {
this.recipient = recipient;
if (this.chatId != chatId) {
this.chatId = chatId;
initializeListAdapter();
}
}
public void scrollToTop() {
ConversationAdapter adapter = (ConversationAdapter) list.getAdapter();
if (adapter.getItemCount() > 0) {
final int pos = adapter.getItemCount() - 1;
list.post(
() -> {
list.getLayoutManager().scrollToPosition(pos);
});
}
}
public void scrollToBottom() {
if (((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition()
< SCROLL_ANIMATION_THRESHOLD
&& !AccessibilityUtil.areAnimationsDisabled(getContext())) {
list.smoothScrollToPosition(0);
} else {
list.scrollToPosition(0);
}
}
void setLastSeen(long lastSeen) {
ConversationAdapter adapter = getListAdapter();
if (adapter != null) {
adapter.setLastSeen(lastSeen);
if (lastSeenDecoration != null) {
list.removeItemDecoration(lastSeenDecoration);
}
if (lastSeen > 0) {
lastSeenDecoration = new ConversationAdapter.LastSeenHeader(adapter);
list.addItemDecoration(lastSeenDecoration);
}
}
}
private void handleCopyMessage(final Set<DcMsg> dcMsgsSet) {
List<DcMsg> dcMsgsList = new LinkedList<>(dcMsgsSet);
Collections.sort(
dcMsgsList, (lhs, rhs) -> Long.compare(lhs.getDateReceived(), rhs.getDateReceived()));
boolean singleMsg = dcMsgsList.size() == 1;
StringBuilder result = new StringBuilder();
DcContext dcContext = DcHelper.getContext(getContext());
DcMsg prevMsg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT);
for (DcMsg msg : dcMsgsList) {
if (result.length() > 0) {
result.append("\n\n");
}
if (msg.getFromId() != prevMsg.getFromId() && !singleMsg) {
DcContact contact = dcContext.getContact(msg.getFromId());
result.append(msg.getSenderName(contact)).append(":\n");
}
if (msg.getType() == DcMsg.DC_MSG_TEXT || (singleMsg && !msg.getText().isEmpty())) {
result.append(msg.getText());
} else {
result.append(msg.getSummarytext(10000000));
}
prevMsg = msg;
}
if (result.length() > 0) {
Util.writeTextToClipboard(getActivity(), result.toString());
Toast.makeText(
getActivity(),
getActivity().getResources().getString(R.string.copied_to_clipboard),
Toast.LENGTH_LONG)
.show();
}
}
private void handleForwardMessage(final Set<DcMsg> messageRecords) {
Intent composeIntent = new Intent();
int[] msgIds = DcMsg.msgSetToIds(messageRecords);
try {
setForwardingMessageIds(composeIntent, msgIds, rpc.getSelectedAccountId());
ConversationListRelayingActivity.start(this, composeIntent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
}
private void handleSaveSticker(final DcMsg message) {
if (message.getType() != DcMsg.DC_MSG_STICKER) {
return;
}
File stickerFile = message.getFileAsFile();
if (stickerFile == null || !stickerFile.exists()) {
Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT).show();
return;
}
// Create stickers directory in internal storage
File stickersDir = new File(getContext().getFilesDir(), "stickers");
if (!stickersDir.exists()) {
stickersDir.mkdirs();
}
// Copy sticker to stickers directory
String extension = stickerFile.getName();
int dotPos = extension.lastIndexOf('.');
if (dotPos >= 0) {
extension = extension.substring(dotPos + 1);
}
String fileName = System.currentTimeMillis() + "." + extension;
File destFile = new File(stickersDir, fileName);
try (java.io.FileInputStream in = new java.io.FileInputStream(stickerFile);
java.io.FileOutputStream out = new java.io.FileOutputStream(destFile)) {
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
Toast.makeText(getContext(), R.string.saved, Toast.LENGTH_SHORT).show();
// Refresh sticker picker to show the newly added sticker
if (getActivity() instanceof ConversationActivity) {
((ConversationActivity) getActivity()).refreshStickerPicker();
}
} catch (Exception e) {
Log.e(TAG, "Error saving sticker", e);
Toast.makeText(getContext(), R.string.error, Toast.LENGTH_SHORT).show();
}
}
@SuppressLint("RestrictedApi")
private void handleReplyMessage(final DcMsg message) {
if (getActivity() != null) {
//noinspection ConstantConditions
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
}
listener.handleReplyMessage(message);
}
@SuppressLint("RestrictedApi")
private void handleEditMessage(final DcMsg message) {
if (getActivity() != null) {
//noinspection ConstantConditions
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
}
listener.handleEditMessage(message);
}
private void handleReplyMessagePrivately(final DcMsg msg) {
if (getActivity() != null) {
DcContext dcContext = DcHelper.getContext(getActivity());
int privateChatId = dcContext.createChatByContactId(msg.getFromId());
DcMsg replyMsg = new DcMsg(dcContext, DcMsg.DC_MSG_TEXT);
replyMsg.setQuote(msg);
dcContext.setDraft(privateChatId, replyMsg);
Intent intent = new Intent(getActivity(), ConversationActivity.class);
intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, privateChatId);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
getActivity().startActivity(intent);
} else {
Log.e(TAG, "Activity was null");
}
}
private void handleToggleSave(final Set<DcMsg> messageRecords) {
DcMsg msg = getSelectedMessageRecord(messageRecords);
try {
if (msg.getSavedMsgId() != 0) {
rpc.deleteMessages(
rpc.getSelectedAccountId(), Collections.singletonList(msg.getSavedMsgId()));
} else {
rpc.saveMsgs(rpc.getSelectedAccountId(), Collections.singletonList(msg.getId()));
}
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
}
private void reloadList() {
reloadList(false);
}
private void reloadList(boolean chatModified) {
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
return;
}
// if chat is a contact request and is accepted/blocked, the DcChat object must be reloaded,
// otherwise DcChat.canSend() returns wrong values
if (chatModified) {
adapter.reloadChat();
}
int oldCount = 0;
int oldIndex = 0;
int pixelOffset = 0;
if (!firstLoad) {
oldCount = adapter.getItemCount();
oldIndex =
((LinearLayoutManager) list.getLayoutManager()).findFirstCompletelyVisibleItemPosition();
View firstView = list.getLayoutManager().findViewByPosition(oldIndex);
pixelOffset =
(firstView == null)
? 0
: list.getBottom() - firstView.getBottom() - list.getPaddingBottom();
}
if (getContext() == null) {
Log.e(TAG, "reloadList: getContext() was null");
return;
}
DcContext dcContext = DcHelper.getContext(getContext());
long startMs = System.currentTimeMillis();
int[] msgs = dcContext.getChatMsgs((int) chatId, 0, 0);
Log.i(TAG, "⏰ getChatMsgs(" + chatId + "): " + (System.currentTimeMillis() - startMs) + "ms");
adapter.changeData(msgs);
if (firstLoad) {
if (startingPosition >= 0) {
getListAdapter().pulseHighlightItem(startingPosition);
}
firstLoad = false;
} else if (oldIndex > 0) {
int newIndex = oldIndex + msgs.length - oldCount;
if (newIndex < 0) {
newIndex = 0;
pixelOffset = 0;
} else if (newIndex >= msgs.length) {
newIndex = msgs.length - 1;
pixelOffset = 0;
}
((LinearLayoutManager) list.getLayoutManager())
.scrollToPositionWithOffset(newIndex, pixelOffset);
}
if (!adapter.isActive()) {
setNoMessageText();
noMessageTextView.setVisibility(View.VISIBLE);
} else {
noMessageTextView.setVisibility(View.GONE);
}
if (!isPaused) {
markseenDebouncer.publish(() -> manageMessageSeenState());
}
}
private void updateLocationButton() {
floatingLocationButton.setVisibility(
DcHelper.getContext(getContext()).isSendingLocationsToChat((int) chatId)
? View.VISIBLE
: View.GONE);
}
private void scrollAndHighlight(final int pos, boolean smooth) {
list.post(
() -> {
if (smooth && !AccessibilityUtil.areAnimationsDisabled(getContext())) {
list.smoothScrollToPosition(pos);
} else {
list.scrollToPosition(pos);
}
getListAdapter().pulseHighlightItem(pos);
});
}
public void scrollToMsgId(final int msgId) {
ConversationAdapter adapter = (ConversationAdapter) list.getAdapter();
int position = adapter.msgIdToPosition(msgId);
if (position != -1) {
scrollAndHighlight(position, false);
} else {
Log.e(TAG, "msgId {} not found for scrolling");
}
}
private void scrollMaybeSmoothToMsgId(final int msgId) {
LinearLayoutManager layout = ((LinearLayoutManager) list.getLayoutManager());
boolean smooth = false;
ConversationAdapter adapter = (ConversationAdapter) list.getAdapter();
if (adapter == null) return;
int position = adapter.msgIdToPosition(msgId);
if (layout != null) {
int distance1 = Math.abs(position - layout.findFirstVisibleItemPosition());
int distance2 = Math.abs(position - layout.findLastVisibleItemPosition());
int distance = Math.min(distance1, distance2);
smooth = distance < 15;
Log.i(TAG, "Scrolling to destMsg, smoth: " + smooth + ", distance: " + distance);
}
if (position != -1) {
scrollAndHighlight(position, smooth);
} else {
Log.e(TAG, "msgId not found for scrolling: " + msgId);
}
}
public interface ConversationFragmentListener {
void handleReplyMessage(DcMsg messageRecord);
void handleEditMessage(DcMsg messageRecord);
}
private class ConversationScrollListener extends OnScrollListener {
private final Animation scrollButtonInAnimation;
private final Animation scrollButtonOutAnimation;
private boolean wasAtBottom = true;
private boolean wasAtZoomScrollHeight = false;
// private long lastPositionId = -1;
ConversationScrollListener(@NonNull Context context) {
this.scrollButtonInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in);
this.scrollButtonOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out);
this.scrollButtonInAnimation.setDuration(100);
this.scrollButtonOutAnimation.setDuration(50);
}
@Override
public void onScrolled(final RecyclerView rv, final int dx, final int dy) {
boolean currentlyAtBottom = isAtBottom();
boolean currentlyAtZoomScrollHeight = isAtZoomScrollHeight();
// int positionId = getHeaderPositionId();
if (currentlyAtZoomScrollHeight && !wasAtZoomScrollHeight) {
ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation);
} else if (currentlyAtBottom && !wasAtBottom) {
ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE);
}
// if (positionId != lastPositionId) {
// bindScrollHeader(conversationDateHeader, positionId);
// }
wasAtBottom = currentlyAtBottom;
wasAtZoomScrollHeight = currentlyAtZoomScrollHeight;
// lastPositionId = positionId;
markseenDebouncer.publish(() -> manageMessageSeenState());
ConversationFragment.this.addReactionView.move(dy);
}
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
// if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
// conversationDateHeader.show();
// } else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
// conversationDateHeader.hide();
// }
}
private boolean isAtBottom() {
if (list.getChildCount() == 0) return true;
View bottomView = list.getChildAt(0);
int firstVisibleItem =
((LinearLayoutManager) list.getLayoutManager()).findFirstVisibleItemPosition();
boolean isAtBottom = (firstVisibleItem == 0);
return isAtBottom && bottomView.getBottom() <= list.getHeight();
}
private boolean isAtZoomScrollHeight() {
return ((LinearLayoutManager) list.getLayoutManager())
.findFirstCompletelyVisibleItemPosition()
> 4;
}
// private int getHeaderPositionId() {
// return
// ((LinearLayoutManager)list.getLayoutManager()).findLastVisibleItemPosition();
// }
// private void bindScrollHeader(HeaderViewHolder headerViewHolder, int positionId) {
// if (((ConversationAdapter)list.getAdapter()).getHeaderId(positionId) != -1) {
// ((ConversationAdapter)
// list.getAdapter()).onBindHeaderViewHolder(headerViewHolder, positionId);
// }
// }
}
private void manageMessageSeenState() {
LinearLayoutManager layoutManager = (LinearLayoutManager) list.getLayoutManager();
int firstPos = layoutManager.findFirstVisibleItemPosition();
int lastPos = layoutManager.findLastVisibleItemPosition();
if (firstPos == RecyclerView.NO_POSITION || lastPos == RecyclerView.NO_POSITION) {
return;
}
ArrayList<Integer> ids = new ArrayList<>(lastPos - firstPos + 1);
for (int pos = firstPos; pos <= lastPos; pos++) {
DcMsg message = ((ConversationAdapter) list.getAdapter()).getMsg(pos);
if (message.getFromId() != DC_CONTACT_ID_SELF) {
ids.add(message.getId());
}
}
Util.runOnAnyBackgroundThread(
() -> {
try {
rpc.markseenMsgs(rpc.getSelectedAccountId(), ids);
} catch (RpcException e) {
Log.e(TAG, "RPC error", e);
}
});
}
private class ConversationFragmentItemClickListener implements ItemClickListener {
@Override
public void onItemClick(DcMsg messageRecord) {
if (actionMode != null) {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
list.getAdapter().notifyDataSetChanged();
if (getListAdapter().getSelectedItems().size() == 0) {
actionMode.finish();
} else {
hideAddReactionView();
Menu menu = actionMode.getMenu();
setCorrectMenuVisibility(menu);
ConversationAdaptiveActionsToolbar.adjustMenuActions(
menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size()));
actionMode.setTitleOptionalHint(
false); // the title represents important information, also indicating implicitly,
// more items can be selected
}
} else if (DozeReminder.isDozeReminderMsg(getContext(), messageRecord)) {
DozeReminder.dozeReminderTapped(getContext());
} else if (StatsSending.isStatsSendingDeviceMsg(getContext(), messageRecord)) {
StatsSending.statsDeviceMsgTapped(getActivity());
} else if (messageRecord.getInfoType() == DcMsg.DC_INFO_WEBXDC_INFO_MESSAGE) {
if (messageRecord.getParent() != null) {
// if the parent webxdc message still exists
WebxdcActivity.openWebxdcActivity(
getContext(), messageRecord.getParent(), messageRecord.getWebxdcHref());
}
} else if (messageRecord.getInfoType() == DcMsg.DC_INFO_CHAT_DESCRIPTION_CHANGED) {
Intent intent = new Intent(getContext(), ProfileActivity.class);
intent.putExtra(ProfileActivity.CHAT_ID_EXTRA, (int) chatId);
startActivity(intent);
} else {
int infoContactId = messageRecord.getInfoContactId();
if (infoContactId != 0 && infoContactId != DC_CONTACT_ID_SELF) {
Intent intent = new Intent(getContext(), ProfileActivity.class);
intent.putExtra(ProfileActivity.CONTACT_ID_EXTRA, infoContactId);
startActivity(intent);
} else {
String self_mail = DcHelper.getContext(getContext()).getConfig("configured_mail_user");
if (self_mail != null
&& !self_mail.isEmpty()
&& messageRecord.getText().contains(self_mail)
&& getListAdapter().getChat().isDeviceTalk()) {
// This is a device message informing the user that the password is wrong
Intent intent = new Intent(getActivity(), EditRelayActivity.class);
intent.putExtra(EditRelayActivity.EXTRA_ADDR, self_mail);
startActivity(intent);
}
}
}
}
@Override
public void onItemLongClick(DcMsg messageRecord, View view) {
if (actionMode == null) {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
list.getAdapter().notifyDataSetChanged();
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback);
addReactionView.show(
messageRecord,
view,
() -> {
if (actionMode != null) {
actionMode.finish();
}
});
}
}
private void jumpToOriginal(DcMsg original) {
if (original == null) {
Log.i(
TAG,
"Clicked on a quote or jump-to-original whose original message was deleted/non-existing.");
Toast.makeText(
getContext(),
R.string.ConversationFragment_quoted_message_not_found,
Toast.LENGTH_SHORT)
.show();
return;
}
int foreignChatId = original.getChatId();
if (foreignChatId != 0 && foreignChatId != chatId) {
Intent intent = new Intent(getActivity(), ConversationActivity.class);
intent.putExtra(ConversationActivity.CHAT_ID_EXTRA, foreignChatId);
int start = DcMsg.getMessagePosition(original, DcHelper.getContext(getContext()));
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, start);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
((ConversationActivity) getActivity()).hideSoftKeyboard();
if (getActivity() != null) {
getActivity().startActivity(intent);
} else {
Log.e(TAG, "Activity was null");
}
} else {
scrollMaybeSmoothToMsgId(original.getId());
}
}
@Override
public void onJumpToOriginalClicked(DcMsg messageRecord) {
jumpToOriginal(DcHelper.getContext(getContext()).getMsg(messageRecord.getOriginalMsgId()));
}
@Override
public void onQuoteClicked(DcMsg messageRecord) {
jumpToOriginal(messageRecord.getQuotedMsg());
}
@Override
public void onShowFullClicked(DcMsg messageRecord) {
Intent intent = new Intent(getActivity(), FullMsgActivity.class);
intent.putExtra(FullMsgActivity.MSG_ID_EXTRA, messageRecord.getId());
intent.putExtra(
FullMsgActivity.BLOCK_LOADING_REMOTE, getListAdapter().getChat().isContactRequest());
startActivity(intent);
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.fade_scale_out);
}
@Override
public void onDownloadClicked(DcMsg messageRecord) {
DcHelper.getContext(getContext()).downloadFullMsg(messageRecord.getId());
}
@Override
public void onReactionClicked(DcMsg messageRecord) {
ReactionsDetailsFragment dialog = ReactionsDetailsFragment.newInstance(messageRecord.getId());
dialog.show(getActivity().getSupportFragmentManager(), null);
}
@Override
public void onStickerClicked(DcMsg messageRecord) {
new AlertDialog.Builder(getContext())
.setMessage(R.string.ask_add_sticker_to_collection)
.setPositiveButton(
R.string.ok,
(dialog, which) -> {
handleSaveSticker(messageRecord);
})
.setNegativeButton(R.string.cancel, null)
.show();
}
}
private class ActionModeCallback implements ActionMode.Callback {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.conversation_context, menu);
mode.setTitle("1");
Util.redMenuItem(menu, R.id.menu_context_delete_message);
setCorrectMenuVisibility(menu);
ConversationAdaptiveActionsToolbar.adjustMenuActions(
menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
((ConversationAdapter) list.getAdapter()).clearSelection();
list.getAdapter().notifyDataSetChanged();
actionMode = null;
hideAddReactionView();
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
hideAddReactionView();
int itemId = item.getItemId();
AudioPlaybackViewModel playbackViewModel =
new ViewModelProvider(requireActivity()).get(AudioPlaybackViewModel.class);
if (itemId == R.id.menu_context_copy) {
handleCopyMessage(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
} else if (itemId == R.id.menu_context_delete_message) {
handleDeleteMessages(
(int) chatId,
getListAdapter().getSelectedItems(),
playbackViewModel::stopByIds,
playbackViewModel::stopByIds);
return true;
} else if (itemId == R.id.menu_context_share) {
DcHelper.openForViewOrShare(
getContext(),
getSelectedMessageRecord(getListAdapter().getSelectedItems()).getId(),
Intent.ACTION_SEND);
return true;
} else if (itemId == R.id.menu_context_details) {
handleDisplayDetails(getSelectedMessageRecord(getListAdapter().getSelectedItems()));
actionMode.finish();
return true;
} else if (itemId == R.id.menu_context_forward) {
handleForwardMessage(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
} else if (itemId == R.id.menu_add_to_home_screen) {
WebxdcActivity.addToHomeScreen(
getActivity(), getSelectedMessageRecord(getListAdapter().getSelectedItems()).getId());
actionMode.finish();
return true;
} else if (itemId == R.id.menu_context_save_attachment) {
handleSaveAttachment(getListAdapter().getSelectedItems());
return true;
} else if (itemId == R.id.menu_context_reply) {
handleReplyMessage(getSelectedMessageRecord(getListAdapter().getSelectedItems()));
actionMode.finish();
return true;
} else if (itemId == R.id.menu_context_edit) {
handleEditMessage(getSelectedMessageRecord(getListAdapter().getSelectedItems()));
actionMode.finish();
return true;
} else if (itemId == R.id.menu_context_reply_privately) {
handleReplyMessagePrivately(getSelectedMessageRecord(getListAdapter().getSelectedItems()));
return true;
} else if (itemId == R.id.menu_resend) {
handleResendMessage(getListAdapter().getSelectedItems());
return true;
} else if (itemId == R.id.menu_toggle_save) {
handleToggleSave(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
}
return false;
}
}
@Override
public void handleEvent(@NonNull DcEvent event) {
switch (event.getId()) {
case DcContext.DC_EVENT_MSGS_CHANGED:
if (event.getData1Int() == 0 // deleted messages or batch insert
|| event.getData1Int() == chatId) {
reloadList();
}
break;
case DcContext.DC_EVENT_REACTIONS_CHANGED:
case DcContext.DC_EVENT_INCOMING_MSG:
case DcContext.DC_EVENT_MSG_DELIVERED:
case DcContext.DC_EVENT_MSG_FAILED:
case DcContext.DC_EVENT_MSG_READ:
if (event.getData1Int() == chatId) {
reloadList();
}
break;
case DcContext.DC_EVENT_CHAT_MODIFIED:
if (event.getData1Int() == chatId) {
updateLocationButton();
reloadList(true);
}
break;
}
// removing the "new message" marker on incoming messages may be a bit unexpected,
// esp. when a series of message is coming in and after the first, the screen is turned on,
// the "new message" marker will flash for a short moment and disappear.
/*if (eventId == DcContext.DC_EVENT_INCOMING_MSG && isResumed()) {
setLastSeen(-1);
}*/
}
}