kopia lustrzana https://github.com/ryukoposting/Signal-Android
Fix several issues with multiforwarding.
* Better forwarding and animations. * Handle audio with text. * Increase max forwardable count to 32 * Onboarding dialog. * Send forth link previews. * Safety number support. * Fix slide behaviour.fork-5.53.8
rodzic
0b37b0ee16
commit
c65761a034
|
@ -26,12 +26,9 @@ import org.signal.core.util.logging.Log;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
|
||||
|
@ -48,15 +45,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
|||
private static final String TAG = Log.tag(ContactsCursorLoader.class);
|
||||
|
||||
public static final class DisplayMode {
|
||||
public static final int FLAG_PUSH = 1;
|
||||
public static final int FLAG_SMS = 1 << 1;
|
||||
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
|
||||
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
|
||||
public static final int FLAG_SELF = 1 << 4;
|
||||
public static final int FLAG_BLOCK = 1 << 5;
|
||||
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
|
||||
public static final int FLAG_HIDE_NEW = 1 << 6;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
|
||||
public static final int FLAG_PUSH = 1;
|
||||
public static final int FLAG_SMS = 1 << 1;
|
||||
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
|
||||
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
|
||||
public static final int FLAG_SELF = 1 << 4;
|
||||
public static final int FLAG_BLOCK = 1 << 5;
|
||||
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
|
||||
public static final int FLAG_HIDE_NEW = 1 << 6;
|
||||
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
|
||||
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
|
||||
}
|
||||
|
||||
private static final int RECENT_CONVERSATION_MAX = 25;
|
||||
|
@ -115,7 +113,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
|||
Cursor recentConversations = getRecentConversationsCursor();
|
||||
|
||||
if (recentConversations.getCount() > 0) {
|
||||
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
|
||||
if (!hideRecentsHeader(mode)) {
|
||||
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
|
||||
}
|
||||
cursorList.add(recentConversations);
|
||||
}
|
||||
}
|
||||
|
@ -139,7 +139,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
|||
Cursor groups = getRecentConversationsCursor(true);
|
||||
|
||||
if (groups.getCount() > 0) {
|
||||
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
|
||||
if (!hideRecentsHeader(mode)) {
|
||||
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
|
||||
}
|
||||
cursorList.add(groups);
|
||||
}
|
||||
}
|
||||
|
@ -279,6 +281,10 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
|||
return flagSet(mode, DisplayMode.FLAG_HIDE_NEW);
|
||||
}
|
||||
|
||||
private static boolean hideRecentsHeader(int mode) {
|
||||
return flagSet(mode, DisplayMode.FLAG_HIDE_RECENT_HEADER);
|
||||
}
|
||||
|
||||
private static boolean flagSet(int mode, int flag) {
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
|
@ -567,6 +568,10 @@ public class ConversationAdapter
|
|||
return new HashSet<>(selected);
|
||||
}
|
||||
|
||||
public void removeFromSelection(@NonNull Set<MultiselectPart> parts) {
|
||||
selected.removeAll(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all selected records from multi-select mode.
|
||||
*/
|
||||
|
|
|
@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderV
|
|||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemAnimator;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
|
||||
|
@ -169,6 +170,7 @@ import java.io.IOException;
|
|||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
@ -258,11 +260,33 @@ public class ConversationFragment extends LoggingFragment {
|
|||
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
|
||||
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
|
||||
|
||||
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
|
||||
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
|
||||
final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> {
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null) {
|
||||
return false;
|
||||
} else {
|
||||
return Util.hasItems(adapter.getSelectedItems());
|
||||
}
|
||||
}, multiselectPart -> {
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null) {
|
||||
return false;
|
||||
} else {
|
||||
return adapter.getSelectedItems().contains(multiselectPart);
|
||||
}
|
||||
});
|
||||
MultiselectItemDecoration multiselectItemDecoration = new MultiselectItemDecoration(requireContext(),
|
||||
() -> conversationViewModel.getWallpaper().getValue(),
|
||||
multiselectItemAnimator::getSelectedProgressForPart,
|
||||
multiselectItemAnimator::isInitialAnimation);
|
||||
|
||||
list.setHasFixedSize(false);
|
||||
list.setLayoutManager(layoutManager);
|
||||
list.addItemDecoration(new MultiselectItemDecoration(requireContext(), () -> conversationViewModel.getWallpaper().getValue()));
|
||||
list.setItemAnimator(null);
|
||||
list.addItemDecoration(multiselectItemDecoration);
|
||||
list.setItemAnimator(multiselectItemAnimator);
|
||||
|
||||
getViewLifecycleOwner().getLifecycle().addObserver(multiselectItemDecoration);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
list.setOverScrollMode(View.OVER_SCROLL_NEVER);
|
||||
|
@ -675,6 +699,7 @@ public class ConversationFragment extends LoggingFragment {
|
|||
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
||||
|
||||
adapter.registerAdapterDataObserver(snapToTopDataObserver);
|
||||
adapter.registerAdapterDataObserver(new CheckExpirationDataObserver());
|
||||
|
||||
setLastSeen(conversationViewModel.getLastSeen());
|
||||
|
||||
|
@ -1706,6 +1731,33 @@ public class ConversationFragment extends LoggingFragment {
|
|||
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
|
||||
}
|
||||
|
||||
private final class CheckExpirationDataObserver extends RecyclerView.AdapterDataObserver {
|
||||
@Override
|
||||
public void onItemRangeRemoved(int positionStart, int itemCount) {
|
||||
ConversationAdapter adapter = getListAdapter();
|
||||
if (adapter == null || actionMode == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<MultiselectPart> selected = adapter.getSelectedItems();
|
||||
Set<MultiselectPart> expired = new HashSet<>();
|
||||
|
||||
for (final MultiselectPart multiselectPart : selected) {
|
||||
if (multiselectPart.isExpired()) {
|
||||
expired.add(multiselectPart);
|
||||
}
|
||||
}
|
||||
|
||||
adapter.removeFromSelection(expired);
|
||||
|
||||
if (adapter.getSelectedItems().isEmpty()) {
|
||||
actionMode.finish();
|
||||
} else {
|
||||
actionMode.setTitle(String.valueOf(adapter.getSelectedItems().size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ConversationSnapToTopDataObserver extends SnapToTopDataObserver {
|
||||
|
||||
public ConversationSnapToTopDataObserver(@NonNull RecyclerView recyclerView,
|
||||
|
|
|
@ -24,7 +24,6 @@ import android.content.ActivityNotFoundException;
|
|||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.Typeface;
|
||||
|
@ -141,7 +140,6 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
@ -533,63 +531,69 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
MultiselectPart bottom = parts.asDouble().getBottomPart();
|
||||
|
||||
if (hasThumbnail(messageRecord)) {
|
||||
Projection thumbnailProjection = Projection.relativeToParent(this, mediaThumbnailStub.require(), null);
|
||||
float mediaBoundary = thumbnailProjection.getY() + thumbnailProjection.getHeight();
|
||||
|
||||
if (lastYDownRelativeToThis > mediaBoundary) {
|
||||
return bottom;
|
||||
} else {
|
||||
return top;
|
||||
}
|
||||
return isTouchBelowBoundary(mediaThumbnailStub.require()) ? bottom : top;
|
||||
} else if (hasDocument(messageRecord)) {
|
||||
Projection documentProjection = Projection.relativeToParent(this, documentViewStub.get(), null);
|
||||
float documentBoundary = documentProjection.getY() + documentProjection.getHeight();
|
||||
|
||||
if (lastYDownRelativeToThis > documentBoundary) {
|
||||
return bottom;
|
||||
} else {
|
||||
return top;
|
||||
}
|
||||
} else {
|
||||
return isTouchBelowBoundary(documentViewStub.get()) ? bottom : top;
|
||||
} else if (hasAudio(messageRecord)) {
|
||||
return isTouchBelowBoundary(audioViewStub.get()) ? bottom : top;
|
||||
} {
|
||||
throw new IllegalStateException("Found a situation where we have something other than a thumbnail or a document.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTouchBelowBoundary(@NonNull View child) {
|
||||
Projection childProjection = Projection.relativeToParent(this, child, null);
|
||||
float childBoundary = childProjection.getY() + childProjection.getHeight();
|
||||
|
||||
return lastYDownRelativeToThis > childBoundary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTopBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) {
|
||||
if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
|
||||
return (int) projection.getY();
|
||||
} if (multiselectPart instanceof MultiselectPart.Attachments && hasDocument(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null);
|
||||
return (int) projection.getY();
|
||||
} else if (multiselectPart instanceof MultiselectPart.Text && hasThumbnail(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
} else if (multiselectPart instanceof MultiselectPart.Text && hasDocument(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
|
||||
boolean isTextPart = multiselectPart instanceof MultiselectPart.Text;
|
||||
boolean isAttachmentPart = multiselectPart instanceof MultiselectPart.Attachments;
|
||||
|
||||
if (hasThumbnail(messageRecord) && isAttachmentPart) {
|
||||
return getProjectionTop(mediaThumbnailStub.require());
|
||||
} else if (hasThumbnail(messageRecord) && isTextPart) {
|
||||
return getProjectionBottom(mediaThumbnailStub.require());
|
||||
} else if (hasDocument(messageRecord) && isAttachmentPart) {
|
||||
return getProjectionTop(documentViewStub.get());
|
||||
} else if (hasDocument(messageRecord) && isTextPart) {
|
||||
return getProjectionBottom(documentViewStub.get());
|
||||
} else if (hasAudio(messageRecord) && isAttachmentPart) {
|
||||
return getProjectionTop(audioViewStub.get());
|
||||
} else if (hasAudio(messageRecord) && isTextPart) {
|
||||
return getProjectionBottom(audioViewStub.get());
|
||||
} else if (hasNoBubble(messageRecord)) {
|
||||
return getTop();
|
||||
} else {
|
||||
Projection projection = Projection.relativeToViewRoot(bodyBubble, null);
|
||||
return (int) projection.getY();
|
||||
return getProjectionTop(bodyBubble);
|
||||
}
|
||||
}
|
||||
|
||||
private static int getProjectionTop(@NonNull View child) {
|
||||
return (int) Projection.relativeToViewRoot(child, null).getY();
|
||||
}
|
||||
|
||||
private static int getProjectionBottom(@NonNull View child) {
|
||||
Projection projection = Projection.relativeToViewRoot(child, null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBottomBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) {
|
||||
if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
return getProjectionBottom(mediaThumbnailStub.require());
|
||||
} else if (multiselectPart instanceof MultiselectPart.Attachments && hasDocument(messageRecord)) {
|
||||
Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
return getProjectionBottom(documentViewStub.get());
|
||||
} else if (multiselectPart instanceof MultiselectPart.Attachments && hasAudio(messageRecord)) {
|
||||
return getProjectionBottom(audioViewStub.get());
|
||||
} else if (hasNoBubble(messageRecord)) {
|
||||
return getBottom();
|
||||
} else {
|
||||
Projection projection = Projection.relativeToViewRoot(bodyBubble, null);
|
||||
return (int) projection.getY() + projection.getHeight();
|
||||
return getProjectionBottom(bodyBubble);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1731,6 +1735,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
|||
return projections;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View getHorizontalTranslationTarget() {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
return null;
|
||||
} else if (groupThread) {
|
||||
return contactPhotoHolder;
|
||||
} else {
|
||||
return bodyBubble;
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedContactEventListener implements SharedContactView.EventListener {
|
||||
@Override
|
||||
public void onAddToContactsClicked(@NonNull Contact contact) {
|
||||
|
|
|
@ -230,6 +230,11 @@ public final class ConversationUpdateItem extends FrameLayout
|
|||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View getHorizontalTranslationTarget() {
|
||||
return null;
|
||||
}
|
||||
|
||||
static final class RecipientObserverManager {
|
||||
|
||||
private final Observer<Recipient> recipientObserver;
|
||||
|
|
|
@ -14,6 +14,8 @@ import java.util.stream.Collectors;
|
|||
|
||||
final class MenuState {
|
||||
|
||||
private static final int MAX_FORWARDABLE_COUNT = 32;
|
||||
|
||||
private final boolean forward;
|
||||
private final boolean reply;
|
||||
private final boolean details;
|
||||
|
@ -114,7 +116,7 @@ final class MenuState {
|
|||
!viewOnce &&
|
||||
!remoteDelete &&
|
||||
!hasPendingMedia &&
|
||||
((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= 5) || selectedParts.size() == 1);
|
||||
((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= MAX_FORWARDABLE_COUNT) || selectedParts.size() == 1);
|
||||
|
||||
int uniqueRecords = selectedParts.stream()
|
||||
.map(MultiselectPart::getMessageRecord)
|
||||
|
|
|
@ -35,6 +35,8 @@ sealed class MultiselectCollection {
|
|||
}
|
||||
}
|
||||
|
||||
fun isExpired(): Boolean = toSet().any(MultiselectPart::isExpired)
|
||||
|
||||
fun isTextSelected(selectedParts: Set<MultiselectPart>): Boolean {
|
||||
val textParts: Set<MultiselectPart> = toSet().filter(this::couldContainText).toSet()
|
||||
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Class for managing the triggering of item animations (here in the form of decoration redraws) whenever
|
||||
* there is a "selection" edge detected.
|
||||
*
|
||||
* Can be expanded upon in the future to animate other things, such as message sends.
|
||||
*/
|
||||
class MultiselectItemAnimator(
|
||||
private val isInMultiSelectMode: () -> Boolean,
|
||||
private val isPartSelected: (MultiselectPart) -> Boolean
|
||||
) : RecyclerView.ItemAnimator() {
|
||||
|
||||
private data class Selection(
|
||||
val multiselectPart: MultiselectPart,
|
||||
val viewHolder: RecyclerView.ViewHolder
|
||||
)
|
||||
|
||||
var isInitialAnimation: Boolean = true
|
||||
private set
|
||||
|
||||
private val selected: MutableSet<MultiselectPart> = mutableSetOf()
|
||||
|
||||
private val pendingSelectedAnimations: MutableSet<Selection> = mutableSetOf()
|
||||
|
||||
private val selectedAnimations: MutableMap<Selection, ValueAnimator> = mutableMapOf()
|
||||
|
||||
fun getSelectedProgressForPart(multiselectPart: MultiselectPart): Float {
|
||||
return if (pendingSelectedAnimations.any { it.multiselectPart == multiselectPart }) {
|
||||
0f
|
||||
} else {
|
||||
selectedAnimations.filter { it.key.multiselectPart == multiselectPart }.values.firstOrNull()?.animatedFraction ?: 1f
|
||||
}
|
||||
}
|
||||
|
||||
override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
|
||||
if (oldHolder != newHolder) {
|
||||
dispatchAnimationFinished(oldHolder)
|
||||
}
|
||||
|
||||
val isInMultiSelectMode = isInMultiSelectMode()
|
||||
if (!isInMultiSelectMode) {
|
||||
selected.clear()
|
||||
isInitialAnimation = true
|
||||
dispatchAnimationFinished(newHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
var isAnimationStarted = false
|
||||
val parts: MultiselectCollection? = (newHolder.itemView as? Multiselectable)?.conversationMessage?.multiselectCollection
|
||||
|
||||
if (parts == null || parts.isExpired()) {
|
||||
dispatchAnimationFinished(newHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
parts.toSet().forEach { part ->
|
||||
val partIsSelected = isPartSelected(part)
|
||||
if (selected.contains(part) && !partIsSelected) {
|
||||
pendingSelectedAnimations.add(Selection(part, newHolder))
|
||||
selected.remove(part)
|
||||
isAnimationStarted = true
|
||||
} else if (!selected.contains(part) && partIsSelected) {
|
||||
pendingSelectedAnimations.add(Selection(part, newHolder))
|
||||
selected.add(part)
|
||||
isAnimationStarted = true
|
||||
} else if (isInitialAnimation) {
|
||||
pendingSelectedAnimations.add(Selection(part, newHolder))
|
||||
isAnimationStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isAnimationStarted) {
|
||||
dispatchAnimationStarted(newHolder)
|
||||
} else {
|
||||
dispatchAnimationFinished(newHolder)
|
||||
}
|
||||
|
||||
return isAnimationStarted
|
||||
}
|
||||
|
||||
override fun runPendingAnimations() {
|
||||
for (selection in pendingSelectedAnimations) {
|
||||
val animator = ValueAnimator.ofFloat(0f, 1f)
|
||||
selectedAnimations[selection] = animator
|
||||
animator.duration = 150L
|
||||
animator.addUpdateListener {
|
||||
(selection.viewHolder.itemView.parent as RecyclerView).invalidateItemDecorations()
|
||||
}
|
||||
animator.doOnEnd {
|
||||
dispatchAnimationFinished(selection.viewHolder)
|
||||
selectedAnimations.remove(selection)
|
||||
isInitialAnimation = false
|
||||
}
|
||||
animator.start()
|
||||
}
|
||||
|
||||
pendingSelectedAnimations.clear()
|
||||
}
|
||||
|
||||
override fun endAnimation(item: RecyclerView.ViewHolder) {
|
||||
endSelectedAnimation(item)
|
||||
}
|
||||
|
||||
override fun endAnimations() {
|
||||
endSelectedAnimations()
|
||||
dispatchAnimationsFinished()
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return selectedAnimations.values.any { it.isRunning }
|
||||
}
|
||||
|
||||
override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) {
|
||||
dispatchItemDecorationRedraw(viewHolder)
|
||||
}
|
||||
|
||||
private fun dispatchItemDecorationRedraw(viewHolder: RecyclerView.ViewHolder) {
|
||||
val parent = (viewHolder.itemView.parent as RecyclerView)
|
||||
parent.post { parent.invalidateItemDecorations() }
|
||||
}
|
||||
|
||||
private fun endSelectedAnimation(item: RecyclerView.ViewHolder) {
|
||||
val selections = selectedAnimations.filter { (k, _) -> k.viewHolder == item }
|
||||
selections.forEach { (k, v) ->
|
||||
v.end()
|
||||
selectedAnimations.remove(k)
|
||||
}
|
||||
}
|
||||
|
||||
fun endSelectedAnimations() {
|
||||
selectedAnimations.values.forEach { it.end() }
|
||||
selectedAnimations.clear()
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
|
@ -11,6 +12,8 @@ import android.view.View
|
|||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
@ -20,11 +23,17 @@ import org.thoughtcrime.securesms.util.SetUtil
|
|||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.lang.Integer.max
|
||||
|
||||
/**
|
||||
* Decoration which renders the background shade and selection bubble for a {@link Multiselectable} item.
|
||||
*/
|
||||
class MultiselectItemDecoration(context: Context, private val chatWallpaperProvider: () -> ChatWallpaper?) : RecyclerView.ItemDecoration() {
|
||||
class MultiselectItemDecoration(
|
||||
context: Context,
|
||||
private val chatWallpaperProvider: () -> ChatWallpaper?,
|
||||
private val selectedAnimationProgressProvider: (MultiselectPart) -> Float,
|
||||
private val isInitialAnimation: () -> Boolean
|
||||
) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
|
||||
|
||||
private val path = Path()
|
||||
private val rect = Rect()
|
||||
|
@ -43,6 +52,21 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
private val ultramarine30 = ContextCompat.getColor(context, R.color.core_ultramarine_33)
|
||||
private val ultramarine = ContextCompat.getColor(context, R.color.signal_accent_primary)
|
||||
|
||||
private var checkedBitmap: Bitmap? = null
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
val bitmap = Bitmap.createBitmap(circleRadius * 2, circleRadius * 2, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
|
||||
checkDrawable.draw(canvas)
|
||||
checkedBitmap = bitmap
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
checkedBitmap?.recycle()
|
||||
checkedBitmap = null
|
||||
}
|
||||
|
||||
private val unselectedPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
strokeWidth = 1.5f
|
||||
|
@ -60,20 +84,43 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
color = transparentBlack20
|
||||
}
|
||||
|
||||
private val checkPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
val adapter = parent.adapter as ConversationAdapter
|
||||
val isLtr = ViewUtil.isLtr(view)
|
||||
|
||||
if (adapter.selectedItems.isNotEmpty() && view is Multiselectable) {
|
||||
outRect.set(
|
||||
if (isLtr) gutter else 0,
|
||||
0,
|
||||
if (isLtr) 0 else gutter,
|
||||
0
|
||||
)
|
||||
} else {
|
||||
outRect.setEmpty()
|
||||
val firstPart = view.conversationMessage.multiselectCollection.toSet().first()
|
||||
val target = view.getHorizontalTranslationTarget()
|
||||
|
||||
if (target != null) {
|
||||
val start = if (isLtr) {
|
||||
target.left
|
||||
} else {
|
||||
parent.right - target.right
|
||||
}
|
||||
|
||||
val translation: Float = if (isInitialAnimation()) {
|
||||
max(0, gutter - start) * selectedAnimationProgressProvider(firstPart)
|
||||
} else {
|
||||
max(0, gutter - start).toFloat()
|
||||
}
|
||||
|
||||
view.translationX = if (isLtr) {
|
||||
translation
|
||||
} else {
|
||||
-translation
|
||||
}
|
||||
}
|
||||
} else if (view is Multiselectable) {
|
||||
view.translationX = 0f
|
||||
}
|
||||
|
||||
outRect.setEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -111,7 +158,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia())
|
||||
|
||||
if (shadeAll) {
|
||||
rect.set(0, view.top, parent.right, view.bottom)
|
||||
rect.set(0, view.top, view.right, view.bottom)
|
||||
} else {
|
||||
rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart))
|
||||
}
|
||||
|
@ -144,9 +191,9 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
}
|
||||
|
||||
if (chatWallpaperProvider() == null && !isDarkTheme) {
|
||||
checkDrawable.colorFilter = SimpleColorFilter(ultramarine)
|
||||
checkPaint.colorFilter = SimpleColorFilter(ultramarine)
|
||||
} else {
|
||||
checkDrawable.clearColorFilter()
|
||||
checkPaint.colorFilter = null
|
||||
}
|
||||
|
||||
multiselectChildren.forEach { child ->
|
||||
|
@ -159,10 +206,15 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
drawPhotoCircle(canvas, parent, topBoundary, bottomBoundary)
|
||||
}
|
||||
|
||||
val alphaProgress = selectedAnimationProgressProvider(it)
|
||||
if (adapter.selectedItems.contains(it)) {
|
||||
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary)
|
||||
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress)
|
||||
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress)
|
||||
} else {
|
||||
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary)
|
||||
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress)
|
||||
if (!isInitialAnimation()) {
|
||||
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -187,7 +239,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
/**
|
||||
* Draws the checkmark for selected content
|
||||
*/
|
||||
private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) {
|
||||
private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) {
|
||||
val topX: Float = if (ViewUtil.isLtr(parent)) {
|
||||
paddingStart
|
||||
} else {
|
||||
|
@ -195,25 +247,33 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
|
|||
}.toFloat()
|
||||
|
||||
val topY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2 - circleRadius
|
||||
val bitmap = checkedBitmap
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(topX, topY)
|
||||
checkDrawable.draw(canvas)
|
||||
canvas.restore()
|
||||
val alpha = checkPaint.alpha
|
||||
checkPaint.alpha = (alpha * alphaProgress).toInt()
|
||||
|
||||
if (bitmap != null) {
|
||||
canvas.drawBitmap(bitmap, topX, topY, checkPaint)
|
||||
}
|
||||
|
||||
checkPaint.alpha = alpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the empty circle for unselected content
|
||||
*/
|
||||
private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) {
|
||||
private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) {
|
||||
val centerX: Float = if (ViewUtil.isLtr(parent)) {
|
||||
paddingStart + circleRadius
|
||||
} else {
|
||||
parent.right - circleRadius - paddingStart
|
||||
}.toFloat()
|
||||
|
||||
val alpha = unselectedPaint.alpha
|
||||
unselectedPaint.alpha = (alpha * alphaProgress).toInt()
|
||||
val centerY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2
|
||||
|
||||
c.drawCircle(centerX, centerY, circleRadius.toFloat(), unselectedPaint)
|
||||
unselectedPaint.alpha = alpha
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,12 @@ sealed class MultiselectPart(open val conversationMessage: ConversationMessage)
|
|||
|
||||
fun getMessageRecord(): MessageRecord = conversationMessage.messageRecord
|
||||
|
||||
fun isExpired(): Boolean {
|
||||
val expiresAt = conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn
|
||||
|
||||
return expiresAt > 0 && expiresAt < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the body of the message
|
||||
*/
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable
|
||||
|
||||
|
@ -12,5 +13,7 @@ interface Multiselectable : Colorizable {
|
|||
|
||||
fun getMultiselectPartForLatestTouch(): MultiselectPart
|
||||
|
||||
fun getHorizontalTranslationTarget(): View?
|
||||
|
||||
fun hasNonSelectableMedia(): Boolean
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -8,18 +10,23 @@ import android.view.animation.AnimationUtils
|
|||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
|
||||
|
@ -30,13 +37,18 @@ import org.thoughtcrime.securesms.util.ViewUtil
|
|||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.lang.UnsupportedOperationException
|
||||
import java.util.function.Consumer
|
||||
|
||||
private const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
|
||||
private const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
|
||||
private val TAG = Log.tag(MultiselectForwardFragment::class.java)
|
||||
|
||||
class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment(), ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.OnSelectionLimitReachedListener {
|
||||
class MultiselectForwardFragment :
|
||||
FixedRoundedCornerBottomSheetDialogFragment(),
|
||||
ContactSelectionListFragment.OnContactSelectedListener,
|
||||
ContactSelectionListFragment.OnSelectionLimitReachedListener,
|
||||
SafetyNumberChangeDialog.Callback {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.67f
|
||||
|
||||
|
@ -44,9 +56,12 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
|
|||
|
||||
private lateinit var selectionFragment: ContactSelectionListFragment
|
||||
private lateinit var contactFilterView: ContactFilterView
|
||||
private lateinit var addMessage: EditText
|
||||
|
||||
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
|
||||
|
||||
private var handler: Handler? = null
|
||||
|
||||
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
|
||||
return MultiselectForwardViewModel.Factory(getMultiShareArgs(), MultiselectForwardRepository(requireContext()))
|
||||
}
|
||||
|
@ -99,15 +114,14 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
|
|||
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
|
||||
val shareSelectionAdapter = ShareSelectionAdapter()
|
||||
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
|
||||
val addMessage: EditText = bottomBar.findViewById(R.id.add_message)
|
||||
val addMessageWrapper: View = bottomBar.findViewById(R.id.add_message_wrapper)
|
||||
|
||||
addMessage = bottomBar.findViewById(R.id.add_message)
|
||||
|
||||
addMessageWrapper.visible = FeatureFlags.forwardMultipleMessages()
|
||||
|
||||
sendButton.setOnClickListener {
|
||||
it.isEnabled = false
|
||||
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
|
||||
|
||||
sendButton.isEnabled = false
|
||||
viewModel.send(addMessage.text.toString())
|
||||
}
|
||||
|
||||
|
@ -130,20 +144,22 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
|
|||
}
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
val toastTextResId: Int? = when (it.stage) {
|
||||
MultiselectForwardState.Stage.SELECTION -> null
|
||||
MultiselectForwardState.Stage.SOME_FAILED -> R.plurals.MultiselectForwardFragment_messages_sent
|
||||
MultiselectForwardState.Stage.ALL_FAILED -> R.plurals.MultiselectForwardFragment_messages_failed_to_send
|
||||
MultiselectForwardState.Stage.SUCCESS -> R.plurals.MultiselectForwardFragment_messages_sent
|
||||
when (it.stage) {
|
||||
MultiselectForwardState.Stage.Selection -> { }
|
||||
MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation()
|
||||
is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities)
|
||||
MultiselectForwardState.Stage.LoadingIdentities -> {}
|
||||
MultiselectForwardState.Stage.SendPending -> {
|
||||
handler?.removeCallbacksAndMessages(null)
|
||||
dismissibleDialog?.dismiss()
|
||||
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
|
||||
}
|
||||
MultiselectForwardState.Stage.SomeFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
|
||||
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
|
||||
MultiselectForwardState.Stage.Success -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
|
||||
}
|
||||
|
||||
if (toastTextResId != null) {
|
||||
val argCount = getMultiShareArgs().size
|
||||
|
||||
dismissibleDialog?.dismiss()
|
||||
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
|
||||
}
|
||||
|
||||
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
|
||||
|
@ -151,8 +167,75 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
|
|||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val expiringMessages = getMultiShareArgs().filter { it.expiresAt > 0L }
|
||||
val firstToExpire = expiringMessages.minByOrNull { it.expiresAt }
|
||||
val earliestExpiration = firstToExpire?.expiresAt ?: -1L
|
||||
|
||||
if (earliestExpiration > 0) {
|
||||
if (earliestExpiration <= now) {
|
||||
handleMessageExpired()
|
||||
} else {
|
||||
handler = Handler(Looper.getMainLooper())
|
||||
handler?.postDelayed(this::handleMessageExpired, earliestExpiration - now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
|
||||
handler?.removeCallbacksAndMessages(null)
|
||||
}
|
||||
|
||||
private fun displayFirstSendConfirmation() {
|
||||
SignalStore.tooltips().markMultiForwardDialogSeen()
|
||||
|
||||
val messageCount = getMessageCount()
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.MultiselectForwardFragment__faster_forwards)
|
||||
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
|
||||
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
|
||||
d.dismiss()
|
||||
viewModel.confirmFirstSend(addMessage.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
viewModel.cancelSend()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityDatabase.IdentityRecord>) {
|
||||
SafetyNumberChangeDialog.show(childFragmentManager, identityRecords)
|
||||
}
|
||||
|
||||
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
|
||||
val argCount = getMessageCount()
|
||||
|
||||
dismissibleDialog?.dismiss()
|
||||
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0
|
||||
|
||||
private fun handleMessageExpired() {
|
||||
dismissAllowingStateLoss()
|
||||
dismissibleDialog?.dismiss()
|
||||
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun getDefaultDisplayMode(): Int {
|
||||
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or ContactsCursorLoader.DisplayMode.FLAG_SELF or ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW
|
||||
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_SELF or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
|
||||
|
||||
if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) {
|
||||
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
|
||||
|
@ -186,6 +269,18 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
|
|||
Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
|
||||
viewModel.confirmSafetySend(addMessage.text.toString())
|
||||
}
|
||||
|
||||
override fun onMessageResentAfterSafetyNumberChange() {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun onCanceled() {
|
||||
viewModel.cancelSend()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
|
||||
|
|
|
@ -42,7 +42,10 @@ class MultiselectForwardFragmentArgs(
|
|||
|
||||
@WorkerThread
|
||||
private fun buildMultiShareArgs(context: Context, conversationMessage: ConversationMessage, selectedParts: Set<MultiselectPart>): MultiShareArgs {
|
||||
val builder = MultiShareArgs.Builder(setOf()).withMentions(conversationMessage.mentions)
|
||||
val builder = MultiShareArgs.Builder(setOf())
|
||||
.withMentions(conversationMessage.mentions)
|
||||
.withTimestamp(conversationMessage.messageRecord.timestamp)
|
||||
.withExpiration(conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn)
|
||||
|
||||
if (conversationMessage.multiselectCollection.isTextSelected(selectedParts)) {
|
||||
val mediaMessage: MmsMessageRecord? = conversationMessage.messageRecord as? MmsMessageRecord
|
||||
|
@ -56,6 +59,9 @@ class MultiselectForwardFragmentArgs(
|
|||
} else {
|
||||
builder.withDraftText(conversationMessage.getDisplayBody(context).toString())
|
||||
}
|
||||
|
||||
val linkPreview = mediaMessage?.linkPreviews?.firstOrNull()
|
||||
builder.withLinkPreview(linkPreview)
|
||||
}
|
||||
|
||||
if (conversationMessage.messageRecord.isMms && conversationMessage.multiselectCollection.isMediaSelected(selectedParts)) {
|
||||
|
@ -96,6 +102,7 @@ class MultiselectForwardFragmentArgs(
|
|||
val media = firstSlide.asAttachment().toMedia()
|
||||
|
||||
if (media != null) {
|
||||
builder.asBorderless(media.isBorderless)
|
||||
builder.withMedia(listOf(media))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.util.Consumer
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareSender
|
||||
|
@ -20,6 +23,16 @@ class MultiselectForwardRepository(context: Context) {
|
|||
val onAllMessagesFailed: () -> Unit
|
||||
)
|
||||
|
||||
fun checkForBadIdentityRecords(shareContacts: List<ShareContact>, consumer: Consumer<List<IdentityDatabase.IdentityRecord>>) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val identityDatabase: IdentityDatabase = DatabaseFactory.getIdentityDatabase(context)
|
||||
val recipients: List<Recipient> = shareContacts.map { Recipient.resolved(it.recipientId.get()) }
|
||||
val identityRecordList: IdentityRecordList = identityDatabase.getIdentities(recipients)
|
||||
|
||||
consumer.accept(identityRecordList.untrustedRecords)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(
|
||||
additionalMessage: String,
|
||||
multiShareArgs: List<MultiShareArgs>,
|
||||
|
@ -38,7 +51,7 @@ class MultiselectForwardRepository(context: Context) {
|
|||
.toSet()
|
||||
|
||||
val mappedArgs: List<MultiShareArgs> = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() }
|
||||
val results = mappedArgs.map { MultiShareSender.sendSync(it) }
|
||||
val results = mappedArgs.sortedBy { it.timestamp }.map { MultiShareSender.sendSync(it) }
|
||||
|
||||
if (additionalMessage.isNotEmpty()) {
|
||||
val additional = MultiShareArgs.Builder(sharedContactsAndThreads)
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
package org.thoughtcrime.securesms.conversation.mutiselect.forward
|
||||
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact
|
||||
|
||||
data class MultiselectForwardState(
|
||||
val selectedContacts: List<ShareContact> = emptyList(),
|
||||
val stage: Stage = Stage.SELECTION
|
||||
val stage: Stage = Stage.Selection
|
||||
) {
|
||||
enum class Stage {
|
||||
SELECTION,
|
||||
SOME_FAILED,
|
||||
ALL_FAILED,
|
||||
SUCCESS
|
||||
sealed class Stage {
|
||||
object Selection : Stage()
|
||||
object FirstConfirmation : Stage()
|
||||
object LoadingIdentities : Stage()
|
||||
data class SafetyConfirmation(val identities: List<IdentityDatabase.IdentityRecord>) : Stage()
|
||||
object SendPending : Stage()
|
||||
object SomeFailed : Stage()
|
||||
object AllFailed : Stage()
|
||||
object Success : Stage()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact
|
||||
|
@ -31,14 +32,43 @@ class MultiselectForwardViewModel(
|
|||
}
|
||||
|
||||
fun send(additionalMessage: String) {
|
||||
if (SignalStore.tooltips().showMultiForwardDialog()) {
|
||||
SignalStore.tooltips().markMultiForwardDialogSeen()
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
|
||||
} else {
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
|
||||
repository.checkForBadIdentityRecords(store.state.selectedContacts) { identityRecords ->
|
||||
if (identityRecords.isEmpty()) {
|
||||
performSend(additionalMessage)
|
||||
} else {
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmFirstSend(additionalMessage: String) {
|
||||
send(additionalMessage)
|
||||
}
|
||||
|
||||
fun confirmSafetySend(additionalMessage: String) {
|
||||
send(additionalMessage)
|
||||
}
|
||||
|
||||
fun cancelSend() {
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
|
||||
}
|
||||
|
||||
private fun performSend(additionalMessage: String) {
|
||||
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
|
||||
repository.send(
|
||||
additionalMessage = additionalMessage,
|
||||
multiShareArgs = records,
|
||||
shareContacts = store.state.selectedContacts,
|
||||
MultiselectForwardRepository.MultiselectForwardResultHandlers(
|
||||
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.SUCCESS) } },
|
||||
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.ALL_FAILED) } },
|
||||
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SOME_FAILED) } }
|
||||
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
|
||||
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },
|
||||
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SomeFailed) } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -214,7 +214,12 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
|||
if (activity instanceof Callback && !skipCallbacks) {
|
||||
callback = (Callback) activity;
|
||||
} else {
|
||||
callback = null;
|
||||
Fragment parent = getParentFragment();
|
||||
if (parent instanceof Callback && !skipCallbacks) {
|
||||
callback = (Callback) parent;
|
||||
} else {
|
||||
callback = null;
|
||||
}
|
||||
}
|
||||
|
||||
LiveData<TrustAndVerifyResult> trustOrVerifyResultLiveData = viewModel.trustOrVerifyChangedRecipients();
|
||||
|
@ -244,6 +249,8 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
|||
private void handleCancel(@NonNull DialogInterface dialogInterface, int which) {
|
||||
if (getActivity() instanceof Callback) {
|
||||
((Callback) getActivity()).onCanceled();
|
||||
} else if (getParentFragment() instanceof Callback) {
|
||||
((Callback) getParentFragment()).onCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ public class TooltipValues extends SignalStoreValues {
|
|||
private static final String BLUR_HUD_ICON = "tooltip.blur_hud_icon";
|
||||
private static final String GROUP_CALL_SPEAKER_VIEW = "tooltip.group_call_speaker_view";
|
||||
private static final String GROUP_CALL_TOOLTIP_DISPLAY_COUNT = "tooltip.group_call_tooltip_display_count";
|
||||
private static final String MULTI_FORWARD_DIALOG = "tooltip.multi.forward.dialog";
|
||||
|
||||
|
||||
TooltipValues(@NonNull KeyValueStore store) {
|
||||
|
@ -20,6 +21,7 @@ public class TooltipValues extends SignalStoreValues {
|
|||
|
||||
@Override
|
||||
public void onFirstEverAppLaunch() {
|
||||
markMultiForwardDialogSeen();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -54,4 +56,12 @@ public class TooltipValues extends SignalStoreValues {
|
|||
public void markGroupCallingLobbyEntered() {
|
||||
putInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
public boolean showMultiForwardDialog() {
|
||||
return getBoolean(MULTI_FORWARD_DIALOG, true);
|
||||
}
|
||||
|
||||
public void markMultiForwardDialogSeen() {
|
||||
putBoolean(MULTI_FORWARD_DIALOG, false);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import androidx.annotation.Nullable;
|
|||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
|
@ -35,6 +36,8 @@ public final class MultiShareArgs implements Parcelable {
|
|||
private final boolean viewOnce;
|
||||
private final LinkPreview linkPreview;
|
||||
private final List<Mention> mentions;
|
||||
private final long timestamp;
|
||||
private final long expiresAt;
|
||||
|
||||
private MultiShareArgs(@NonNull Builder builder) {
|
||||
shareContactAndThreads = builder.shareContactAndThreads;
|
||||
|
@ -47,6 +50,8 @@ public final class MultiShareArgs implements Parcelable {
|
|||
viewOnce = builder.viewOnce;
|
||||
linkPreview = builder.linkPreview;
|
||||
mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions);
|
||||
timestamp = builder.timestamp;
|
||||
expiresAt = builder.expiresAt;
|
||||
}
|
||||
|
||||
protected MultiShareArgs(Parcel in) {
|
||||
|
@ -59,6 +64,8 @@ public final class MultiShareArgs implements Parcelable {
|
|||
dataType = in.readString();
|
||||
viewOnce = in.readByte() != 0;
|
||||
mentions = in.createTypedArrayList(Mention.CREATOR);
|
||||
timestamp = in.readLong();
|
||||
expiresAt = in.readLong();
|
||||
|
||||
String linkedPreviewString = in.readString();
|
||||
LinkPreview preview;
|
||||
|
@ -111,6 +118,14 @@ public final class MultiShareArgs implements Parcelable {
|
|||
return mentions;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public long getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public @NonNull InterstitialContentType getInterstitialContentType() {
|
||||
if (!requiresInterstitial()) {
|
||||
return InterstitialContentType.NONE;
|
||||
|
@ -154,6 +169,8 @@ public final class MultiShareArgs implements Parcelable {
|
|||
dest.writeString(dataType);
|
||||
dest.writeByte((byte) (viewOnce ? 1 : 0));
|
||||
dest.writeTypedList(mentions);
|
||||
dest.writeLong(timestamp);
|
||||
dest.writeLong(expiresAt);
|
||||
|
||||
if (linkPreview != null) {
|
||||
try {
|
||||
|
@ -179,7 +196,9 @@ public final class MultiShareArgs implements Parcelable {
|
|||
.withLinkPreview(linkPreview)
|
||||
.withMedia(media)
|
||||
.withStickerLocator(stickerLocator)
|
||||
.withMentions(mentions);
|
||||
.withMentions(mentions)
|
||||
.withTimestamp(timestamp)
|
||||
.withExpiration(expiresAt);
|
||||
}
|
||||
|
||||
private boolean requiresInterstitial() {
|
||||
|
@ -200,6 +219,8 @@ public final class MultiShareArgs implements Parcelable {
|
|||
private LinkPreview linkPreview;
|
||||
private boolean viewOnce;
|
||||
private List<Mention> mentions;
|
||||
private long timestamp;
|
||||
private long expiresAt;
|
||||
|
||||
public Builder(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
|
||||
this.shareContactAndThreads = shareContactAndThreads;
|
||||
|
@ -250,6 +271,16 @@ public final class MultiShareArgs implements Parcelable {
|
|||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withTimestamp(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withExpiration(long expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull MultiShareArgs build() {
|
||||
return new MultiShareArgs(this);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.model.Mention;
|
|||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.SlideFactory;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
|
@ -58,12 +59,23 @@ public final class MultiShareSender {
|
|||
|
||||
@WorkerThread
|
||||
public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
boolean isMmsEnabled = Util.isMmsCapable(context);
|
||||
String message = multiShareArgs.getDraftText();
|
||||
SlideDeck slideDeck = buildSlideDeck(context, multiShareArgs);
|
||||
List<MultiShareSendResult> results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size());
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
boolean isMmsEnabled = Util.isMmsCapable(context);
|
||||
String message = multiShareArgs.getDraftText();
|
||||
SlideDeck slideDeck;
|
||||
|
||||
try {
|
||||
slideDeck = buildSlideDeck(context, multiShareArgs);
|
||||
} catch (SlideNotFoundException e) {
|
||||
Log.w(TAG, "Could not create slide for media message");
|
||||
for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) {
|
||||
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.GENERIC_ERROR));
|
||||
}
|
||||
|
||||
return new MultiShareSendResultCollection(results);
|
||||
}
|
||||
|
||||
List<MultiShareSendResult> results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size());
|
||||
|
||||
for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) {
|
||||
Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId());
|
||||
|
@ -91,7 +103,7 @@ public final class MultiShareSender {
|
|||
sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions);
|
||||
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS));
|
||||
} else {
|
||||
sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId() ,forceSms, expiresIn, subscriptionId);
|
||||
sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId(), forceSms, expiresIn, subscriptionId);
|
||||
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS));
|
||||
}
|
||||
|
||||
|
@ -180,16 +192,26 @@ public final class MultiShareSender {
|
|||
MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null);
|
||||
}
|
||||
|
||||
private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) {
|
||||
private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) throws SlideNotFoundException {
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
if (multiShareArgs.getStickerLocator() != null) {
|
||||
slideDeck.addSlide(new StickerSlide(context, multiShareArgs.getDataUri(), 0, multiShareArgs.getStickerLocator(), multiShareArgs.getDataType()));
|
||||
} else if (!multiShareArgs.getMedia().isEmpty()) {
|
||||
for (Media media : multiShareArgs.getMedia()) {
|
||||
slideDeck.addSlide(SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight()));
|
||||
Slide slide = SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight());
|
||||
if (slide != null) {
|
||||
slideDeck.addSlide(slide);
|
||||
} else {
|
||||
throw new SlideNotFoundException();
|
||||
}
|
||||
}
|
||||
} else if (multiShareArgs.getDataUri() != null) {
|
||||
slideDeck.addSlide(SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0));
|
||||
Slide slide = SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0);
|
||||
if (slide != null) {
|
||||
slideDeck.addSlide(slide);
|
||||
} else {
|
||||
throw new SlideNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
return slideDeck;
|
||||
|
@ -244,8 +266,12 @@ public final class MultiShareSender {
|
|||
}
|
||||
|
||||
private enum Type {
|
||||
GENERIC_ERROR,
|
||||
MMS_NOT_ENABLED,
|
||||
SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlideNotFoundException extends Exception {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/rounded_rectangle_secondary"
|
||||
android:hint="@string/MultiselectForwardFragment__add_a_message"
|
||||
android:inputType="textCapSentences"
|
||||
android:minHeight="44dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
|
|
|
@ -3691,6 +3691,12 @@
|
|||
<string name="DSLSettingsToolbar__navigate_up">Navigate up</string>
|
||||
<string name="MultiselectForwardFragment__forward_to">Forward to</string>
|
||||
<string name="MultiselectForwardFragment__add_a_message">Add a message</string>
|
||||
<string name="MultiselectForwardFragment__faster_forwards">Faster forwards</string>
|
||||
<string name="MultiselectForwardFragment__forwarded_messages_are_now">Forwarded messages are now sent immediately.</string>
|
||||
<plurals name="MultiselectForwardFragment_send_d_messages">
|
||||
<item quantity="one">Send %1$d message</item>
|
||||
<item quantity="other">Send %1$d messages</item>
|
||||
</plurals>
|
||||
<plurals name="MultiselectForwardFragment_messages_sent">
|
||||
<item quantity="one">Message sent</item>
|
||||
<item quantity="other">Messages sent</item>
|
||||
|
@ -3699,6 +3705,10 @@
|
|||
<item quantity="one">Message failed to send</item>
|
||||
<item quantity="other">Messages failed to send</item>
|
||||
</plurals>
|
||||
<plurals name="MultiselectForwardFragment__couldnt_forward_messages">
|
||||
<item quantity="one">Couldn\'t forward message because it\'s no longer available.</item>
|
||||
<item quantity="other">Couldn\'t forward messages because they\'re no longer available.</item>
|
||||
</plurals>
|
||||
<string name="MultiselectForwardFragment__limit_reached">Limit reached</string>
|
||||
|
||||
<!-- EOF -->
|
||||
|
|
Ładowanie…
Reference in New Issue