Add message editing feature.

This commit is contained in:
Clark
2023-04-14 16:29:26 -04:00
committed by Cody Henthorne
parent 4f06a0d27c
commit 07f6baf7c1
73 changed files with 2051 additions and 304 deletions

View File

@@ -110,19 +110,19 @@ public class ConversationAdapter
private final Set<MultiselectPart> selected;
private final Calendar calendar;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private ConversationItemDisplayMode condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@NonNull LifecycleOwner lifecycleOwner,
@@ -258,7 +258,7 @@ public class ConversationAdapter
}
}
public void setCondensedMode(boolean condensedMode) {
public void setCondensedMode(ConversationItemDisplayMode condensedMode) {
this.condensedMode = condensedMode;
notifyDataSetChanged();
}
@@ -283,7 +283,7 @@ public class ConversationAdapter
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationItemDisplayMode displayMode = condensedMode ? ConversationItemDisplayMode.CONDENSED : ConversationItemDisplayMode.STANDARD;
ConversationItemDisplayMode displayMode = condensedMode != null ? condensedMode : ConversationItemDisplayMode.STANDARD;
conversationViewHolder.getBindable().bind(lifecycleOwner,
conversationMessage,
@@ -295,7 +295,7 @@ public class ConversationAdapter
recipient,
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper && !condensedMode,
hasWallpaper && displayMode.displayWallpaper(),
isMessageRequestAccepted,
conversationMessage == inlineContent,
colorizer,

View File

@@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet;
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.conversation.v2.AddToContactsContract;
import org.thoughtcrime.securesms.database.DatabaseObserver;
@@ -180,8 +181,8 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
@@ -836,6 +837,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}));
}
if (menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> {
handleEditMessage(getSelectedConversationMessage());
if (actionMode != null) {
actionMode.finish();
}
}));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleForwardMessageParts(selectedParts)));
}
@@ -1078,7 +1088,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
int deleteForEveryoneResId = isNoteToSelfDelete ? R.string.ConversationFragment_delete_everywhere : R.string.ConversationFragment_delete_for_everyone;
if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
if (MessageConstraintsUtil.isValidRemoteDeleteSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
builder.setNeutralButton(deleteForEveryoneResId, (dialog, which) -> handleDeleteForEveryone(messageRecords));
}
@@ -1148,6 +1158,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
listener.handleReplyMessage(message);
}
private void handleEditMessage(@NonNull ConversationMessage selectedConversationMessage) {
listener.handleEditMessage(selectedConversationMessage);
}
private void handleSaveAttachment(final MediaMmsMessageRecord message) {
if (message.isViewOnce()) {
throw new AssertionError("Cannot save a view-once message.");
@@ -1455,6 +1469,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
void openAttachmentKeyboard();
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void handleEditMessage(@NonNull ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onMessageActionToolbarClosed();
void onBottomActionBarVisibilityChanged(int visibility);
@@ -2110,6 +2125,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
}
@Override
public void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord.getId());
} else {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord.getId());
}
}
@Override
public void onActivatePaymentsClicked() {
Intent intent = new Intent(requireContext(), PaymentsActivity.class);
@@ -2268,6 +2292,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
case REPLY:
handleReplyMessage(conversationMessage);
break;
case EDIT:
handleEditMessage(conversationMessage);
break;
case FORWARD:
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
break;

View File

@@ -419,7 +419,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void updateTimestamps() {
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale);
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale, displayMode);
}
@Override
@@ -526,10 +526,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
!messageRecord.isRemoteDelete() &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
View dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine() && !messageRecord.isFailed()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
@@ -1666,7 +1666,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isFooterVisible(current, next, isGroupThread)) {
ConversationItemFooter activeFooter = getActiveFooter(current);
activeFooter.setVisibility(VISIBLE);
activeFooter.setMessageRecord(current, locale);
activeFooter.setMessageRecord(current, locale, displayMode);
if (MessageRecordUtil.isEditMessage(current)) {
activeFooter.getDateView().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onEditedIndicatorClicked(current);
}
});
} else {
activeFooter.getDateView().setOnClickListener(null);
activeFooter.getDateView().setClickable(false);
}
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
@@ -1714,7 +1725,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty()) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED) {
quotedIndicator.setVisibility(VISIBLE);
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
} else if (quotedIndicator != null) {
@@ -1737,7 +1748,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord);
}
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
@@ -1841,7 +1852,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int background;
if (isSingularMessage(current, previous, next, isGroupThread)) {
if (isSingularMessage(current, previous, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
@@ -1922,6 +1933,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
return false;
}
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
@@ -1937,11 +1952,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
int spacingBottom = spacingTop;
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (isStartOfMessageCluster(current, previous, isGroupThread) && (displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED || next.isEmpty())) {
spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (isEndOfMessageCluster(current, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
spacingBottom = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}

View File

@@ -7,6 +7,13 @@ enum class ConversationItemDisplayMode {
/** Smaller bubbles, often trimming text and shrinking images. Used for quote threads. */
CONDENSED,
/** Smaller bubbles, no footers */
EXTRA_CONDENSED,
/** Less length restrictions. Used to show more info in message details. */
DETAILED
DETAILED;
fun displayWallpaper(): Boolean {
return this == STANDARD || this == DETAILED
}
}

View File

@@ -282,12 +282,14 @@ import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -399,6 +401,7 @@ public class ConversationParentFragment extends Fragment
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ImageButton sendEditButton;
protected ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
@@ -794,7 +797,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
result.getScheduledTime(),
null).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -1591,6 +1595,7 @@ public class ConversationParentFragment extends Fragment
inputPanel.setEnabled(canSendMessages);
sendButton.setEnabled(canSendMessages);
attachButton.setEnabled(canSendMessages);
sendEditButton.setEnabled(canSendMessages);
});
}
@@ -1670,6 +1675,24 @@ public class ConversationParentFragment extends Fragment
quoteResult.addListener(listener);
break;
case Draft.MESSAGE_EDIT:
SettableFuture<Boolean> messageEditResult = new SettableFuture<>();
disposables.add(draftViewModel.loadDraftEditMessage(draft.getValue()).subscribe(
conversationMessage -> {
inputPanel.enterEditMessageMode(glideRequests, conversationMessage, true);
messageEditResult.set(true);
},
err -> {
Log.e(TAG, "Failed to restore message edit from a draft.", err);
messageEditResult.set(false);
},
() -> {
Log.e(TAG, "Failed to load message edit. No matching message record.");
messageEditResult.set(false);
}
));
messageEditResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
case Draft.BODY_RANGES:
listener.onSuccess(true);
@@ -1846,6 +1869,7 @@ public class ConversationParentFragment extends Fragment
buttonToggle = view.findViewById(R.id.button_toggle);
sendButton = view.findViewById(R.id.send_button);
attachButton = view.findViewById(R.id.attach_button);
sendEditButton = view.findViewById(R.id.send_edit_button);
composeText = view.findViewById(R.id.embedded_text_editor);
charactersLeft = view.findViewById(R.id.space_left);
emojiDrawerStub = ViewUtil.findStubById(view, R.id.emoji_drawer_stub);
@@ -1902,6 +1926,7 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
sendEditButton.setOnClickListener(v -> handleSendEditMessage());
sendButton.setScheduledSendListener(new SendButton.ScheduledSendListener() {
@Override
public void onSendScheduled() {
@@ -2759,6 +2784,8 @@ public class ConversationParentFragment extends Fragment
callback.onSendComplete(threadId);
draftViewModel.onSendComplete(threadId);
inputPanel.exitEditMessageMode();
}
private void sendMessage(@Nullable String metricId) {
@@ -2794,6 +2821,7 @@ public class ConversationParentFragment extends Fragment
MessageSendType sendType = sendButton.getSelectedSendType();
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean initiating = threadId == -1;
boolean isEditMessage = inputPanel.inEditMessageMode();
boolean needsSplit = !sendType.usesSmsTransport() && message.length() > sendType.calculateCharacters(message).maxPrimaryMessageSize;
boolean isMediaMessage = attachmentManager.isAttachmentPresent() ||
recipient.isGroup() ||
@@ -2806,14 +2834,19 @@ public class ConversationParentFragment extends Fragment
Log.i(TAG, "[sendMessage] recipient: " + recipient.getId() + ", threadId: " + threadId + ", sendType: " + (sendType.usesSignalTransport() ? "signal" : "sms") + ", isManual: " + sendButton.isManualSelection());
if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
if (!sendType.usesSignalTransport() && isEditMessage) {
Toast.makeText(requireContext(),
R.string.ConversationActivity_edit_sms_message_error,
Toast.LENGTH_LONG)
.show();
} else if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
handleManualMmsRequired();
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@@ -2862,7 +2895,8 @@ public class ConversationParentFragment extends Fragment
null,
true,
result.getBodyRanges(),
-1);
-1,
0);
final Context context = requireContext().getApplicationContext();
@@ -2884,7 +2918,7 @@ public class ConversationParentFragment extends Fragment
}, this::sendComplete);
}
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate, @Nullable MessageId editMessageId)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -2903,7 +2937,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
metricId,
scheduledDate);
scheduledDate,
editMessageId);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2921,7 +2956,7 @@ public class ConversationParentFragment extends Fragment
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1, null);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2938,7 +2973,8 @@ public class ConversationParentFragment extends Fragment
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
final long scheduledDate,
@Nullable MessageId editMessageId)
{
if (ExpiredBuildReminder.isEligible()) {
showExpiredDialog();
@@ -2952,7 +2988,7 @@ public class ConversationParentFragment extends Fragment
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
final String finalBody = body;
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate, editMessageId));
return new SettableFuture<>(null);
}
@@ -2988,7 +3024,8 @@ public class ConversationParentFragment extends Fragment
null,
false,
styling,
scheduledDate);
scheduledDate,
editMessageId != null ? editMessageId.getId() : 0);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@@ -3028,7 +3065,12 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
private void sendTextMessage(@NonNull MessageSendType sendType,
final long expiresIn,
final boolean initiating,
final @Nullable String metricId,
long scheduledDate,
@Nullable MessageId messageToEdit)
throws InvalidMessageException
{
if (ExpiredBuildReminder.isEligible()) {
@@ -3049,8 +3091,11 @@ public class ConversationParentFragment extends Fragment
final OutgoingMessage message;
if (sendPush) {
if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
if (messageToEdit != null) {
message = OutgoingMessage.editText(recipient.get(), messageBody, System.currentTimeMillis(), null, messageToEdit.getId());
} else if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null)
.sendAt(scheduledDate);
} else {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
}
@@ -3111,6 +3156,13 @@ public class ConversationParentFragment extends Fragment
return;
}
if (inputPanel.inEditMessageMode()) {
buttonToggle.display(sendEditButton);
quickAttachmentToggle.hide();
inlineAttachmentToggle.hide();
return;
}
if (draftViewModel.getVoiceNoteDraft() != null) {
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
@@ -3336,7 +3388,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
scheduledDate);
scheduledDate,
null);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@@ -3653,7 +3706,11 @@ public class ConversationParentFragment extends Fragment
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendButton.performClick();
if (inputPanel.isInEditMode()) {
sendEditButton.performClick();
} else {
sendButton.performClick();
}
return true;
}
return false;
@@ -3737,7 +3794,13 @@ public class ConversationParentFragment extends Fragment
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text)));
textDraftSaveDebouncer.publish(() -> {
if (inputPanel.inEditMessageMode()) {
draftViewModel.setMessageEditDraft(inputPanel.getEditMessageId(), StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
} else {
draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
}
});
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
@@ -4050,6 +4113,62 @@ public class ConversationParentFragment extends Fragment
inputPanel.clickOnComposeInput();
}
@Override
public void handleEditMessage(@NonNull ConversationMessage conversationMessage) {
if (!FeatureFlags.editMessageSending()) {
return;
}
if (isSearchRequested) {
searchViewItem.collapseActionView();
}
disposables.add(viewModel.resolveMessageToEdit(conversationMessage).subscribe(updatedMessage -> {
inputPanel.enterEditMessageMode(glideRequests, updatedMessage, false);
}));
}
private void handleSendEditMessage() {
if (!FeatureFlags.editMessageSending()) {
Log.w(TAG, "Edit message sending disabled, forcing exit of edit mode");
inputPanel.exitEditMessageMode();
return;
}
if (!inputPanel.inEditMessageMode()) {
Log.w(TAG, "Not in edit message mode, unknown state, forcing re-exit");
inputPanel.exitEditMessageMode();
return;
}
MessageRecord editMessage = inputPanel.getEditMessage();
if (editMessage == null) {
Log.w(TAG, "No edit message found, forcing exit");
inputPanel.exitEditMessageMode();
return;
}
if (!MessageConstraintsUtil.isValidEditMessageSend(editMessage, System.currentTimeMillis())) {
Log.i(TAG, "Edit message no longer valid");
final int editDurationHours = MessageConstraintsUtil.getEditMessageThresholdHours();
Dialogs.showAlertDialog(requireContext(), null, getResources().getQuantityString(R.plurals.ConversationActivity_edit_message_too_old, editDurationHours, editDurationHours));
return;
}
String metricId = recipient.get().isGroup() ? SignalLocalMetrics.GroupMessageSend.start()
: SignalLocalMetrics.IndividualMessageSend.start();
sendMessage(metricId);
}
@Override
public void onEnterEditMode() {
updateToggleButtonState();
}
@Override
public void onExitEditMode() {
updateToggleButtonState();
}
@Override
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();

View File

@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -738,6 +739,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
if (FeatureFlags.editMessageSending() && menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
}
@@ -968,6 +973,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
public enum Action {
REPLY,
EDIT,
FORWARD,
RESEND,
DOWNLOAD,

View File

@@ -6,6 +6,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
@@ -15,22 +16,28 @@ import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -184,6 +191,28 @@ public class ConversationRepository {
}).subscribeOn(Schedulers.io());
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body);
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
Observable<Integer> getUnreadCount(long threadId, long afterTime) {
if (threadId <= -1L || afterTime <= 0L) {
return Observable.just(0);

View File

@@ -58,6 +58,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.processors.PublishProcessor;
@@ -441,6 +442,11 @@ public class ConversationViewModel extends ViewModel {
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return conversationRepository.resolveMessageToEdit(message);
}
void setArgs(@NonNull ConversationIntents.Args args) {
this.args = args;
}

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
@@ -25,6 +26,7 @@ final class MenuState {
private final boolean delete;
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -36,6 +38,7 @@ final class MenuState {
delete = builder.delete;
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
}
boolean shouldShowForwardAction() {
@@ -74,6 +77,10 @@ final class MenuState {
return paymentDetails;
}
boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@@ -152,7 +159,8 @@ final class MenuState {
.shouldShowReplyAction(false)
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false);
.shouldShowResendAction(false)
.shouldShowEdit(false);
} else {
MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord();
@@ -169,6 +177,10 @@ final class MenuState {
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
builder.shouldShowEdit(!actionMessage &&
hasText &&
MessageConstraintsUtil.isValidEditMessageSend(messageRecord, System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
@@ -204,23 +216,7 @@ final class MenuState {
}
static boolean isActionMessage(@NonNull MessageRecord messageRecord) {
return messageRecord.isGroupAction() ||
messageRecord.isCallLog() ||
messageRecord.isJoined() ||
messageRecord.isExpirationTimerUpdate() ||
messageRecord.isEndSession() ||
messageRecord.isIdentityUpdate() ||
messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault() ||
messageRecord.isProfileChange() ||
messageRecord.isGroupV1MigrationEvent() ||
messageRecord.isChatSessionRefresh() ||
messageRecord.isInMemoryMessageRecord() ||
messageRecord.isChangeNumber() ||
messageRecord.isBoostRequest() ||
messageRecord.isPaymentsRequestToActivate() ||
messageRecord.isPaymentsActivated() ||
messageRecord.isSmsExportType();
return messageRecord.isInMemoryMessageRecord() || messageRecord.isUpdate();
}
private final static class Builder {
@@ -234,6 +230,7 @@ final class MenuState {
private boolean delete;
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -280,6 +277,11 @@ final class MenuState {
return this;
}
@NonNull Builder shouldShowEdit(boolean edit) {
this.edit = edit;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

@@ -93,7 +93,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
setScheduledMessagesMode(true)
}
@@ -276,6 +276,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {

View File

@@ -7,7 +7,9 @@ import android.text.SpannableString
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
@@ -21,14 +23,19 @@ import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.adjustBodyRanges
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.concurrent.Executor
class DraftRepository(
@@ -38,6 +45,10 @@ class DraftRepository(
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
) {
companion object {
val TAG = Log.tag(DraftRepository::class.java)
}
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
@@ -56,7 +67,11 @@ class DraftRepository(
}
draftTable.replaceDrafts(actualThreadId, drafts)
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
if (drafts.shouldUpdateSnippet()) {
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
} else {
threadTable.update(actualThreadId, unarchive = false, allowDeletion = false)
}
} else if (threadId > 0) {
draftTable.clearDrafts(threadId)
threadTable.update(threadId, unarchive = false, allowDeletion = false)
@@ -101,5 +116,26 @@ class DraftRepository(
}
}
fun loadDraftMessageEdit(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
}
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
}

View File

@@ -17,7 +17,8 @@ data class DraftState(
val bodyRangesDraft: DraftTable.Draft? = null,
val quoteDraft: DraftTable.Draft? = null,
val locationDraft: DraftTable.Draft? = null,
val voiceNoteDraft: DraftTable.Draft? = null
val voiceNoteDraft: DraftTable.Draft? = null,
val messageEditDraft: DraftTable.Draft? = null
) {
fun copyAndClearDrafts(threadId: Long = this.threadId): DraftState {
@@ -26,6 +27,7 @@ data class DraftState(
fun toDrafts(): Drafts {
return Drafts().apply {
addIfNotNull(messageEditDraft)
addIfNotNull(textDraft)
addIfNotNull(bodyRangesDraft)
addIfNotNull(quoteDraft)
@@ -41,7 +43,8 @@ data class DraftState(
bodyRangesDraft = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES),
quoteDraft = drafts.getDraftOfType(DraftTable.Draft.QUOTE),
locationDraft = drafts.getDraftOfType(DraftTable.Draft.LOCATION),
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE)
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE),
messageEditDraft = drafts.getDraftOfType(DraftTable.Draft.MESSAGE_EDIT)
)
}
}

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DraftTable.Draft
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.recipients.Recipient
@@ -67,6 +68,22 @@ class DraftViewModel @JvmOverloads constructor(
store.update { it.copy(recipientId = recipient.id) }
}
fun setMessageEditDraft(messageId: MessageId, text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
val bodyRanges: BodyRangeList? = if (styleBodyRanges == null) {
mentionRanges
} else if (mentionRanges == null) {
styleBodyRanges
} else {
styleBodyRanges.toBuilder().addAllRanges(mentionRanges.rangesList).build()
}
saveDrafts(it.copy(textDraft = text.toTextDraft(), bodyRangesDraft = bodyRanges?.toDraft(), messageEditDraft = Draft(Draft.MESSAGE_EDIT, messageId.serialize())))
}
}
fun setTextDraft(text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
@@ -131,6 +148,12 @@ class DraftViewModel @JvmOverloads constructor(
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun loadDraftEditMessage(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftMessageEdit(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialog
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@@ -72,7 +73,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.quotes_list).apply {
@@ -250,6 +251,11 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
dismiss()
getAdapterListener().onSendPaymentClicked(recipientId)
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onEditedIndicatorClicked(messageRecord)
}
}
companion object {

View File

@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ViewModelFactory
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Locale
/**
* Show history of edits for a specific message.
*/
class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.4f
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = MessageEditHistoryBottomSheetBinding.inflate(inflater, container, false).root
view.minimumHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
disposables.bindTo(viewLifecycleOwner)
val colorizer = Colorizer()
val messageAdapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
GlideApp.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
conversationRecipient,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.EXTRA_CONDENSED)
}
binding.editHistoryList.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
adapter = messageAdapter
itemAnimator = null
}
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
disposables += viewModel
.getEditHistory()
.subscribeBy { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages)
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
disposables += viewModel.getNameColorsMap().subscribe { map ->
colorizer.onNameColorsChanged(map)
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS)
}
initializeGiphyMp4()
}
private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
binding.videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {
private const val ARGUMENT_MESSAGE_ID = "message_id"
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
EditMessageHistoryDialog()
.apply {
arguments = bundleOf(
ARGUMENT_MESSAGE_ID to messageId,
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
)
}
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
object EditMessageHistoryRepository {
fun getEditHistory(messageId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val threadId: Long = SignalDatabase.messages.getThreadIdForMessage(messageId)
if (threadId < 0) {
emitter.onNext(emptyList())
return@create
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getEditHistorySync(messageId)) }
databaseObserver.registerConversationObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getEditHistorySync(messageId))
}.subscribeOn(Schedulers.io())
}
private fun getEditHistorySync(messageId: Long): List<ConversationMessage> {
val context = ApplicationDependencies.getApplication()
val records = SignalDatabase
.messages
.getMessageEditHistory(messageId)
.toList()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
return attachmentHelper
.buildUpdatedModels(context, records)
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model to show history of edits for a specific message.
*/
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
fun getEditHistory(): Observable<List<ConversationMessage>> {
return EditMessageHistoryRepository
.getEditHistory(messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
return conversationRecipient
.live()
.observable()
.map { recipient ->
if (recipient.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(recipient.groupId.get())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}

View File

@@ -603,6 +603,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
requireActivity().startActivity(create(requireActivity(), args), options.toBundle())
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onItemClick(item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented")
}