Add save-as-you-compose drafts.

fork-5.53.8
Cody Henthorne 2022-08-05 17:00:11 -04:00
rodzic 192509f762
commit 0a76eb81e6
10 zmienionych plików z 356 dodań i 335 usunięć

Wyświetl plik

@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@ -210,6 +211,10 @@ public class InputPanel extends LinearLayout
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
}
if (listener != null) {
listener.onQuoteChanged(id, author.getId());
}
}
public void clearQuote() {
@ -230,6 +235,10 @@ public class InputPanel extends LinearLayout
});
quoteAnimator.start();
if (listener != null) {
listener.onQuoteCleared();
}
}
private static ValueAnimator createHeightAnimator(@NonNull View view,
@ -572,6 +581,8 @@ public class InputPanel extends LinearLayout
void onEmojiToggle();
void onLinkPreviewCanceled();
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
void onQuoteChanged(long id, @NonNull RecipientId author);
void onQuoteCleared();
}
private static class SlideToCancel {

Wyświetl plik

@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.contactshare;
import android.text.Editable;
import android.text.TextWatcher;
import androidx.annotation.NonNull;
public abstract class SimpleTextWatcher implements TextWatcher {
@Override
@ -10,11 +12,15 @@ public abstract class SimpleTextWatcher implements TextWatcher {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
onTextChanged(s.toString());
onTextChanged(s);
}
@Override
public void afterTextChanged(Editable s) { }
public abstract void onTextChanged(String text);
public void onTextChanged(@NonNull CharSequence text) {
onTextChanged(text.toString());
}
public void onTextChanged(@NonNull String text) { }
}

Wyświetl plik

@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
import org.thoughtcrime.securesms.util.views.Stub
open class ConversationActivity : PassphraseRequiredActivity(), ConversationParentFragment.Callback, DonationPaymentComponent {
@ -89,10 +88,6 @@ open class ConversationActivity : PassphraseRequiredActivity(), ConversationPare
toolbar.setNavigationOnClickListener { finish() }
}
fun saveDraft(): ListenableFuture<Long> {
return fragment.saveDraft()
}
fun getRecipient(): Recipient {
return fragment.recipient
}

Wyświetl plik

@ -43,8 +43,6 @@ import android.provider.Browser;
import android.provider.ContactsContract;
import android.provider.Settings;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextWatcher;
import android.text.method.LinkMovementMethod;
@ -103,7 +101,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
@ -138,8 +136,6 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
import org.thoughtcrime.securesms.components.location.SignalPlace;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
@ -163,25 +159,19 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.conversation.drafts.DraftRepository;
import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel;
import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsPopup;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.DraftDatabase.Draft;
import org.thoughtcrime.securesms.database.DraftDatabase.Drafts;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions;
import org.thoughtcrime.securesms.database.MmsSmsColumns.Types;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.SignalDatabase;
@ -242,7 +232,6 @@ import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.LocationSlide;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
@ -285,7 +274,6 @@ import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
import org.thoughtcrime.securesms.stories.StoryViewerArgs;
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity;
import org.thoughtcrime.securesms.util.AsynchronousCallback;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
@ -417,7 +405,7 @@ public class ConversationParentFragment extends Fragment
protected Stub<ReminderView> reminderView;
private Stub<UnverifiedBannerView> unverifiedBannerView;
private Stub<ReviewBannerView> reviewBanner;
private TypingStatusTextWatcher typingTextWatcher;
private ComposeTextWatcher typingTextWatcher;
private ConversationSearchBottomBar searchNav;
private MenuItem searchViewItem;
private MessageRequestsBottomView messageRequestBottomView;
@ -466,11 +454,12 @@ public class ConversationParentFragment extends Fragment
private LiveRecipient recipient;
private long threadId;
private int distributionType;
private int reactWithAnyEmojiStartPage = -1;
private boolean isSearchRequested = false;
private int reactWithAnyEmojiStartPage = -1;
private boolean isSearchRequested = false;
private final LifecycleDisposable disposables = new LifecycleDisposable();
private final Debouncer optionsMenuDebouncer = new Debouncer(50);
private final LifecycleDisposable disposables = new LifecycleDisposable();
private final Debouncer optionsMenuDebouncer = new Debouncer(50);
private final Debouncer textDraftSaveDebouncer = new Debouncer(500);
private IdentityRecordList identityRecords = new IdentityRecordList(Collections.emptyList());
private Callback callback;
@ -640,7 +629,6 @@ public class ConversationParentFragment extends Fragment
@Override
public void onStop() {
super.onStop();
saveDraft();
EventBus.getDefault().unregister(this);
}
@ -730,6 +718,7 @@ public class ConversationParentFragment extends Fragment
case PICK_LOCATION:
SignalPlace place = new SignalPlace(PlacePickerActivity.addressFromData(data));
attachmentManager.setLocation(place, getCurrentMediaConstraints());
draftViewModel.setLocationDraft(place);
break;
case SMS_DEFAULT:
viewModel.updateSecurityInfo();
@ -1423,6 +1412,7 @@ public class ConversationParentFragment extends Fragment
private void handleDistributionBroadcastEnabled(MenuItem item) {
distributionType = ThreadDatabase.DistributionTypes.BROADCAST;
draftViewModel.setDistributionType(distributionType);
item.setChecked(true);
if (threadId != -1) {
@ -1438,6 +1428,7 @@ public class ConversationParentFragment extends Fragment
private void handleDistributionConversationEnabled(MenuItem item) {
distributionType = ThreadDatabase.DistributionTypes.CONVERSATION;
draftViewModel.setDistributionType(distributionType);
item.setChecked(true);
if (threadId != -1) {
@ -1772,90 +1763,68 @@ public class ConversationParentFragment extends Fragment
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
SettableFuture<Boolean> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
Disposable disposable = draftViewModel
.loadDrafts(threadId)
.subscribe(databaseDrafts -> {
Drafts drafts = databaseDrafts.getDrafts();
CharSequence updatedText = databaseDrafts.getUpdatedText();
new AsyncTask<Void, Void, Pair<Drafts, CharSequence>>() {
@Override
protected Pair<Drafts, CharSequence> doInBackground(Void... params) {
DraftDatabase draftDatabase = SignalDatabase.drafts();
Drafts results = draftDatabase.getDrafts(threadId);
Draft mentionsDraft = results.getDraftOfType(Draft.MENTION);
Spannable updatedText = null;
if (drafts.isEmpty()) {
future.set(false);
updateToggleButtonState();
return;
}
if (mentionsDraft != null) {
String text = results.getDraftOfType(Draft.TEXT).getValue();
List<Mention> mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.getValue()));
UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions);
AtomicInteger draftsRemaining = new AtomicInteger(drafts.size());
AtomicBoolean success = new AtomicBoolean(false);
ListenableFuture.Listener<Boolean> listener = new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
success.compareAndSet(false, result);
updatedText = new SpannableString(updated.getBody());
MentionAnnotation.setMentionAnnotations(updatedText, updated.getMentions());
}
if (draftsRemaining.decrementAndGet() <= 0) {
future.set(success.get());
}
}
};
draftDatabase.clearDrafts(threadId);
for (Draft draft : drafts) {
try {
switch (draft.getType()) {
case Draft.TEXT:
composeText.setText(updatedText == null ? draft.getValue() : updatedText);
listener.onSuccess(true);
break;
case Draft.LOCATION:
attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener);
break;
case Draft.IMAGE:
setMedia(Uri.parse(draft.getValue()), MediaType.IMAGE).addListener(listener);
break;
case Draft.AUDIO:
setMedia(Uri.parse(draft.getValue()), MediaType.AUDIO).addListener(listener);
break;
case Draft.VIDEO:
setMedia(Uri.parse(draft.getValue()), MediaType.VIDEO).addListener(listener);
break;
case Draft.QUOTE:
SettableFuture<Boolean> quoteResult = new SettableFuture<>();
new QuoteRestorationTask(draft.getValue(), quoteResult).execute();
quoteResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
listener.onSuccess(true);
break;
}
} catch (IOException e) {
Log.w(TAG, e);
}
}
return new Pair<>(results, updatedText);
}
@Override
protected void onPostExecute(Pair<Drafts, CharSequence> draftsWithUpdatedMentions) {
Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first());
CharSequence updatedText = draftsWithUpdatedMentions.second();
if (drafts.isEmpty()) {
future.set(false);
updateToggleButtonState();
return;
}
});
AtomicInteger draftsRemaining = new AtomicInteger(drafts.size());
AtomicBoolean success = new AtomicBoolean(false);
ListenableFuture.Listener<Boolean> listener = new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
success.compareAndSet(false, result);
if (draftsRemaining.decrementAndGet() <= 0) {
future.set(success.get());
}
}
};
for (Draft draft : drafts) {
try {
switch (draft.getType()) {
case Draft.TEXT:
composeText.setText(updatedText == null ? draft.getValue() : updatedText);
listener.onSuccess(true);
break;
case Draft.LOCATION:
attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener);
break;
case Draft.IMAGE:
setMedia(Uri.parse(draft.getValue()), MediaType.IMAGE).addListener(listener);
break;
case Draft.AUDIO:
setMedia(Uri.parse(draft.getValue()), MediaType.AUDIO).addListener(listener);
break;
case Draft.VIDEO:
setMedia(Uri.parse(draft.getValue()), MediaType.VIDEO).addListener(listener);
break;
case Draft.QUOTE:
SettableFuture<Boolean> quoteResult = new SettableFuture<>();
new QuoteRestorationTask(draft.getValue(), quoteResult).execute();
quoteResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
draftViewModel.setVoiceNoteDraft(recipient.getId(), draft);
break;
}
} catch (IOException e) {
Log.w(TAG, e);
}
}
updateToggleButtonState();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
disposables.add(disposable);
return future;
}
@ -2062,7 +2031,7 @@ public class ConversationParentFragment extends Fragment
attachmentManager = new AttachmentManager(requireActivity(), this);
audioRecorder = new AudioRecorder(requireContext());
typingTextWatcher = new TypingStatusTextWatcher();
typingTextWatcher = new ComposeTextWatcher();
SendButtonListener sendButtonListener = new SendButtonListener();
ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener();
@ -2437,17 +2406,24 @@ public class ConversationParentFragment extends Fragment
}
public void initializeDraftViewModel() {
draftViewModel = new ViewModelProvider(this, new DraftViewModel.Factory(new DraftRepository(requireContext().getApplicationContext()))).get(DraftViewModel.class);
draftViewModel = new ViewModelProvider(this).get(DraftViewModel.class);
recipient.observe(getViewLifecycleOwner(), r -> {
draftViewModel.onRecipientChanged(r);
});
draftViewModel.getState().observe(getViewLifecycleOwner(),
state -> {
inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft());
updateToggleButtonState();
});
draftViewModel.setThreadId(threadId);
draftViewModel.setDistributionType(distributionType);
disposables.add(
draftViewModel
.getState()
.distinctUntilChanged(state -> state.getVoiceNoteDraft())
.subscribe(state -> {
inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft());
updateToggleButtonState();
})
);
}
private void showGroupCallingTooltip() {
@ -2605,6 +2581,7 @@ public class ConversationParentFragment extends Fragment
this.threadId = threadId;
fragment.reload(recipient, this.threadId);
setVisibleThread(this.threadId);
draftViewModel.setThreadId(this.threadId);
}
});
}
@ -2715,102 +2692,6 @@ public class ConversationParentFragment extends Fragment
builder.show();
}
private Drafts getDraftsForCurrentState() {
Drafts drafts = new Drafts();
if (recipient.get().isGroup() && !recipient.get().isActiveGroup()) {
return drafts;
}
if (!Util.isEmpty(composeText)) {
drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString()));
List<Mention> draftMentions = composeText.getMentions();
if (!draftMentions.isEmpty()) {
drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray())));
}
}
for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) {
if (slide.hasAudio() && slide.getUri() != null) drafts.add(new Draft(Draft.AUDIO, slide.getUri().toString()));
else if (slide.hasVideo() && slide.getUri() != null) drafts.add(new Draft(Draft.VIDEO, slide.getUri().toString()));
else if (slide.hasLocation()) drafts.add(new Draft(Draft.LOCATION, ((LocationSlide)slide).getPlace().serialize()));
else if (slide.hasImage() && slide.getUri() != null) drafts.add(new Draft(Draft.IMAGE, slide.getUri().toString()));
}
Optional<QuoteModel> quote = inputPanel.getQuote();
if (quote.isPresent()) {
drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize()));
}
Draft voiceNoteDraft = draftViewModel.getVoiceNoteDraft();
if (voiceNoteDraft != null) {
drafts.add(voiceNoteDraft);
}
return drafts;
}
protected ListenableFuture<Long> saveDraft() {
final SettableFuture<Long> future = new SettableFuture<>();
if (this.recipient == null) {
future.set(threadId);
return future;
}
final Context context = requireContext().getApplicationContext();
final Drafts drafts = getDraftsForCurrentState();
final long thisThreadId = this.threadId;
final RecipientId recipientId = this.recipient.getId();
final int thisDistributionType = this.distributionType;
final ListenableFuture<VoiceNoteDraft> voiceNoteDraftFuture = draftViewModel.consumeVoiceNoteDraftFuture();
new AsyncTask<Long, Void, Long>() {
@Override
protected Long doInBackground(Long... params) {
if (voiceNoteDraftFuture != null) {
try {
Draft voiceNoteDraft = voiceNoteDraftFuture.get().asDraft();
draftViewModel.setVoiceNoteDraft(recipientId, voiceNoteDraft);
drafts.add(voiceNoteDraft);
} catch (ExecutionException | InterruptedException e) {
Log.w(TAG, "Could not extract voice note draft data.", e);
}
}
ThreadDatabase threadDatabase = SignalDatabase.threads();
DraftDatabase draftDatabase = SignalDatabase.drafts();
long threadId = params[0];
if (drafts.size() > 0) {
if (threadId == -1) threadId = threadDatabase.getOrCreateThreadIdFor(getRecipient(), thisDistributionType);
draftDatabase.replaceDrafts(threadId, drafts);
threadDatabase.updateSnippet(threadId, drafts.getSnippet(context),
drafts.getUriSnippet(),
System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true);
} else if (threadId > 0) {
threadDatabase.update(threadId, false);
}
if (drafts.isEmpty()) {
draftDatabase.clearDrafts(threadId);
}
return threadId;
}
@Override
protected void onPostExecute(Long result) {
future.set(result);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, thisThreadId);
return future;
}
private void setBlockedUserState(Recipient recipient, @NonNull ConversationSecurityInfo conversationSecurityInfo) {
if (!conversationSecurityInfo.isInitialized()) {
Log.i(TAG, "Ignoring blocked state update for uninitialized security info.");
@ -2972,6 +2853,8 @@ public class ConversationParentFragment extends Fragment
updateLinkPreviewState();
callback.onSendComplete(threadId);
draftViewModel.onSendComplete(threadId);
}
private void sendMessage(@Nullable String metricId) {
@ -2985,7 +2868,6 @@ public class ConversationParentFragment extends Fragment
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote);
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
draftViewModel.clearVoiceNoteDraft();
return;
}
@ -3205,7 +3087,7 @@ public class ConversationParentFragment extends Fragment
return;
}
if (draftViewModel.hasVoiceNoteDraft()) {
if (draftViewModel.getVoiceNoteDraft() != null) {
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
inlineAttachmentToggle.hide();
@ -3329,7 +3211,7 @@ public class ConversationParentFragment extends Fragment
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
future.addListener(new DeleteCanceledVoiceNoteListener());
} else {
draftViewModel.setVoiceNoteDraftFuture(future);
draftViewModel.saveEphemeralVoiceNoteDraft(future);
}
}
@ -3359,6 +3241,16 @@ public class ConversationParentFragment extends Fragment
sendSticker(sticker, true);
}
@Override
public void onQuoteChanged(long id, @NonNull RecipientId author) {
draftViewModel.setQuoteDraft(id, author);
}
@Override
public void onQuoteCleared() {
draftViewModel.clearQuoteDraft();
}
@Override
public void onMediaSelected(@NonNull Uri uri, String contentType) {
if (MediaUtil.isGif(contentType) || MediaUtil.isImageType(contentType)) {
@ -3389,32 +3281,26 @@ public class ConversationParentFragment extends Fragment
}
private void sendVoiceNote(@NonNull Uri uri, long size) {
boolean initiating = threadId == -1;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
SlideDeck slideDeck = new SlideDeck();
boolean initiating = threadId == -1;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide);
ListenableFuture<Void> sendResult = sendMediaMessage(recipient.getId(),
sendButton.getSelectedSendType(),
"",
slideDeck,
inputPanel.getQuote().orElse(null),
Collections.emptyList(),
Collections.emptyList(),
composeText.getMentions(),
expiresIn,
false,
initiating,
true,
null);
sendResult.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void nothing) {
draftViewModel.deleteBlob(uri);
}
});
sendMediaMessage(recipient.getId(),
sendButton.getSelectedSendType(),
"",
slideDeck,
inputPanel.getQuote().orElse(null),
Collections.emptyList(),
Collections.emptyList(),
composeText.getMentions(),
expiresIn,
false,
initiating,
true,
null);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@ -3446,9 +3332,9 @@ public class ConversationParentFragment extends Fragment
}
private void silentlySetComposeText(String text) {
typingTextWatcher.setEnabled(false);
typingTextWatcher.setTypingStatusEnabled(false);
composeText.setText(text);
typingTextWatcher.setEnabled(true);
typingTextWatcher.setTypingStatusEnabled(true);
}
@Override
@ -3584,7 +3470,7 @@ public class ConversationParentFragment extends Fragment
private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener<VoiceNoteDraft> {
@Override
public void onSuccess(final VoiceNoteDraft result) {
draftViewModel.deleteBlob(result.getUri());
draftViewModel.cancelEphemeralVoiceNoteDraft(result.asDraft());
}
@Override
@ -3691,15 +3577,24 @@ public class ConversationParentFragment extends Fragment
}
}
private class TypingStatusTextWatcher extends SimpleTextWatcher {
private class ComposeTextWatcher extends SimpleTextWatcher {
private boolean enabled = true;
private boolean typingStatusEnabled = true;
private String previousText = "";
@Override
public void onTextChanged(String text) {
if (enabled && threadId > 0 && viewModel.isPushAvailable() && !isSmsForced() && !recipient.get().isBlocked() && !recipient.get().isSelf()) {
public void onTextChanged(@NonNull CharSequence text) {
handleSaveDraftOnTextChange(text);
handleTypingIndicatorOnTextChange(text.toString());
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text)));
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
if (typingStatusEnabled && threadId > 0 && viewModel.isPushAvailable() && !isSmsForced() && !recipient.get().isBlocked() && !recipient.get().isSelf()) {
TypingStatusSender typingStatusSender = ApplicationDependencies.getTypingStatusSender();
if (text.length() == 0) {
@ -3714,8 +3609,8 @@ public class ConversationParentFragment extends Fragment
}
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
public void setTypingStatusEnabled(boolean enabled) {
this.typingStatusEnabled = enabled;
}
}
@ -3931,6 +3826,7 @@ public class ConversationParentFragment extends Fragment
@Override
public void setThreadId(long threadId) {
this.threadId = threadId;
draftViewModel.setThreadId(threadId);
}
@Override
@ -4028,6 +3924,11 @@ public class ConversationParentFragment extends Fragment
updateLinkPreviewState();
}
@Override
public void onLocationRemoved() {
draftViewModel.clearLocationDraft();
}
private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) {
Recipient recipient = requestModel.getRecipient().getValue();
if (recipient == null) {

Wyświetl plik

@ -1,23 +1,17 @@
package org.thoughtcrime.securesms.conversation;
import android.content.Intent;
import android.os.Bundle;
import android.view.Display;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityOptionsCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
public class ConversationPopupActivity extends ConversationActivity {
@ -73,33 +67,6 @@ public class ConversationPopupActivity extends ConversationActivity {
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_expand:
saveDraft().addListener(new ListenableFuture.Listener<Long>() {
@Override
public void onSuccess(Long result) {
ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height);
Intent intent = ConversationIntents.createBuilder(ConversationPopupActivity.this, getRecipient().getId(), result)
.build();
startActivity(intent, transition.toBundle());
finish();
}
@Override
public void onFailure(ExecutionException e) {
Log.w(TAG, e);
}
});
return true;
}
return false;
}
@Override
public void onInitializeToolbar(Toolbar toolbar) {
}

Wyświetl plik

@ -2,18 +2,75 @@ package org.thoughtcrime.securesms.conversation.drafts
import android.content.Context
import android.net.Uri
import android.text.Spannable
import android.text.SpannableString
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.DraftDatabase.Drafts
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
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 java.util.concurrent.Executor
class DraftRepository(private val context: Context) {
fun deleteVoiceNoteDraft(draft: DraftDatabase.Draft) {
deleteBlob(Uri.parse(draft.value).buildUpon().clearQuery().build())
}
class DraftRepository(
private val context: Context = ApplicationDependencies.getApplication(),
private val threadDatabase: ThreadDatabase = SignalDatabase.threads,
private val draftDatabase: DraftDatabase = SignalDatabase.drafts,
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
) {
fun deleteBlob(uri: Uri) {
SignalExecutors.BOUNDED.execute {
BlobProvider.getInstance().delete(context, uri)
fun deleteVoiceNoteDraftData(draft: DraftDatabase.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
BlobProvider.getInstance().delete(context, Uri.parse(draft.value).buildUpon().clearQuery().build())
}
}
}
fun saveDrafts(recipient: Recipient, threadId: Long, distributionType: Int, drafts: Drafts) {
saveDraftsExecutor.execute {
if (drafts.isNotEmpty()) {
val actualThreadId = if (threadId == -1L) {
threadDatabase.getOrCreateThreadIdFor(recipient, distributionType)
} else {
threadId
}
draftDatabase.replaceDrafts(actualThreadId, drafts)
threadDatabase.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.uriSnippet, System.currentTimeMillis(), MmsSmsColumns.Types.BASE_DRAFT_TYPE, true)
} else if (threadId > 0) {
threadDatabase.update(threadId, false)
draftDatabase.clearDrafts(threadId)
}
}
}
fun loadDrafts(threadId: Long): Single<DatabaseDraft> {
return Single.fromCallable {
val drafts: Drafts = draftDatabase.getDrafts(threadId)
val mentionsDraft = drafts.getDraftOfType(DraftDatabase.Draft.MENTION)
var updatedText: Spannable? = null
if (mentionsDraft != null) {
val text = drafts.getDraftOfType(DraftDatabase.Draft.TEXT)!!.value
val mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.value))
val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions)
updatedText = SpannableString(updated.body)
MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions)
}
DatabaseDraft(drafts, updatedText)
}.subscribeOn(Schedulers.io())
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
}

Wyświetl plik

@ -1,7 +1,7 @@
package org.thoughtcrime.securesms.conversation.drafts
import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.database.DraftDatabase.Drafts
import org.thoughtcrime.securesms.recipients.RecipientId
/**
@ -10,6 +10,38 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* management pattern going forward for drafts.
*/
data class DraftState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val voiceNoteDraft: DraftDatabase.Draft? = null
)
val recipientId: RecipientId = RecipientId.UNKNOWN,
val threadId: Long = -1,
val distributionType: Int = 0,
val textDraft: DraftDatabase.Draft? = null,
val mentionsDraft: DraftDatabase.Draft? = null,
val quoteDraft: DraftDatabase.Draft? = null,
val locationDraft: DraftDatabase.Draft? = null,
val voiceNoteDraft: DraftDatabase.Draft? = null,
) {
fun copyAndClearDrafts(threadId: Long = this.threadId): DraftState {
return DraftState(recipientId = recipientId, threadId = threadId, distributionType = distributionType)
}
fun toDrafts(): Drafts {
return Drafts().apply {
addIfNotNull(textDraft)
addIfNotNull(mentionsDraft)
addIfNotNull(quoteDraft)
addIfNotNull(locationDraft)
addIfNotNull(voiceNoteDraft)
}
}
fun copyAndSetDrafts(threadId: Long, drafts: Drafts): DraftState {
return copy(
threadId = threadId,
textDraft = drafts.getDraftOfType(DraftDatabase.Draft.TEXT),
mentionsDraft = drafts.getDraftOfType(DraftDatabase.Draft.MENTION),
quoteDraft = drafts.getDraftOfType(DraftDatabase.Draft.QUOTE),
locationDraft = drafts.getDraftOfType(DraftDatabase.Draft.LOCATION),
voiceNoteDraft = drafts.getDraftOfType(DraftDatabase.Draft.VOICE_NOTE),
)
}
}

Wyświetl plik

@ -1,87 +1,126 @@
package org.thoughtcrime.securesms.conversation.drafts
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
import org.thoughtcrime.securesms.database.DraftDatabase
import org.thoughtcrime.securesms.database.DraftDatabase.Draft
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.rx.RxStore
/**
* ViewModel responsible for holding Voice Note draft state. The intention is to allow
* other pieces of draft state to be held here as well in the future, and to serve as a
* management pattern going forward for drafts.
*/
class DraftViewModel(
private val repository: DraftRepository
class DraftViewModel @JvmOverloads constructor(
private val repository: DraftRepository = DraftRepository()
) : ViewModel() {
private val store = Store<DraftState>(DraftState())
private val store = RxStore(DraftState())
val state: LiveData<DraftState> = store.stateLiveData
val state: Flowable<DraftState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private var voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>? = null
val voiceNoteDraft: DraftDatabase.Draft?
val voiceNoteDraft: Draft?
get() = store.state.voiceNoteDraft
fun consumeVoiceNoteDraftFuture(): ListenableFuture<VoiceNoteDraft>? {
val future = voiceNoteDraftFuture
voiceNoteDraftFuture = null
return future
fun setThreadId(threadId: Long) {
store.update { it.copy(threadId = threadId) }
}
fun setVoiceNoteDraftFuture(voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>) {
this.voiceNoteDraftFuture = voiceNoteDraftFuture
fun setDistributionType(distributionType: Int) {
store.update { it.copy(distributionType = distributionType) }
}
fun setVoiceNoteDraft(recipientId: RecipientId, draft: DraftDatabase.Draft) {
fun saveEphemeralVoiceNoteDraft(voiceNoteDraftFuture: ListenableFuture<VoiceNoteDraft>) {
store.update {
it.copy(recipientId = recipientId, voiceNoteDraft = draft)
saveDrafts(it.copy(voiceNoteDraft = voiceNoteDraftFuture.get().asDraft()))
}
}
@get:JvmName("hasVoiceNoteDraft")
val hasVoiceNoteDraft: Boolean
get() = store.state.voiceNoteDraft != null
fun clearVoiceNoteDraft() {
store.update {
it.copy(voiceNoteDraft = null)
}
fun cancelEphemeralVoiceNoteDraft(draft: Draft) {
repository.deleteVoiceNoteDraftData(draft)
}
fun deleteVoiceNoteDraft() {
val draft = store.state.voiceNoteDraft
if (draft != null) {
clearVoiceNoteDraft()
repository.deleteVoiceNoteDraft(draft)
store.update {
repository.deleteVoiceNoteDraftData(it.voiceNoteDraft)
saveDrafts(it.copy(voiceNoteDraft = null))
}
}
fun onRecipientChanged(recipient: Recipient) {
store.update { it.copy(recipientId = recipient.id) }
}
fun setTextDraft(text: String, mentions: List<Mention>) {
store.update {
if (recipient.id != it.recipientId) {
it.copy(recipientId = recipient.id, voiceNoteDraft = null)
} else {
it
saveDrafts(it.copy(textDraft = text.toTextDraft(), mentionsDraft = mentions.toMentionsDraft()))
}
}
fun setLocationDraft(place: SignalPlace) {
store.update {
saveDrafts(it.copy(locationDraft = Draft(Draft.LOCATION, place.serialize())))
}
}
fun clearLocationDraft() {
store.update {
saveDrafts(it.copy(locationDraft = null))
}
}
fun setQuoteDraft(id: Long, author: RecipientId) {
store.update {
saveDrafts(it.copy(quoteDraft = Draft(Draft.QUOTE, QuoteId(id, author).serialize())))
}
}
fun clearQuoteDraft() {
store.update {
saveDrafts(it.copy(quoteDraft = null))
}
}
fun onSendComplete(threadId: Long) {
repository.deleteVoiceNoteDraftData(store.state.voiceNoteDraft)
store.update { saveDrafts(it.copyAndClearDrafts(threadId)) }
}
private fun saveDrafts(state: DraftState): DraftState {
repository.saveDrafts(Recipient.resolved(state.recipientId), state.threadId, state.distributionType, state.toDrafts())
return state
}
fun loadDrafts(threadId: Long): Single<DraftRepository.DatabaseDraft> {
return repository
.loadDrafts(threadId)
.doOnSuccess { drafts ->
store.update { it.copyAndSetDrafts(threadId, drafts.drafts) }
}
}
}
fun deleteBlob(uri: Uri) {
repository.deleteBlob(uri)
}
class Factory(private val repository: DraftRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(DraftViewModel(repository)))
}
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {
return if (isNotEmpty()) Draft(Draft.TEXT, this) else null
}
private fun List<Mention>.toMentionsDraft(): Draft? {
val mentions: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(this)
return if (mentions != null) {
Draft(Draft.MENTION, Base64.encodeBytes(mentions.toByteArray()))
} else {
null
}
}

Wyświetl plik

@ -164,6 +164,12 @@ public class DraftDatabase extends Database {
}
public static class Drafts extends LinkedList<Draft> {
public void addIfNotNull(@Nullable Draft draft) {
if (draft != null) {
add(draft);
}
}
public @Nullable Draft getDraftOfType(String type) {
for (Draft draft : this) {
if (type.equals(draft.getType())) {

Wyświetl plik

@ -489,6 +489,12 @@ public class AttachmentManager {
private class RemoveButtonListener implements View.OnClickListener {
@Override
public void onClick(View v) {
slide.ifPresent(oldSlide -> {
if (oldSlide instanceof LocationSlide) {
attachmentListener.onLocationRemoved();
}
});
cleanup();
clear(GlideApp.with(context.getApplicationContext()), true);
}
@ -496,6 +502,7 @@ public class AttachmentManager {
public interface AttachmentListener {
void onAttachmentChanged();
void onLocationRemoved();
}
}