diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java index 3547307463..52a8553b79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaGalleryAllAdapter.java @@ -305,22 +305,26 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { } protected void updateSelectedView() { + boolean selected = isSelected(); + itemView.setSelected(selected); if (selectedIndicator != null) { selectedIndicator.animate().cancel(); - selectedIndicator.setAlpha(isSelected() ? 1f : 0f); + selectedIndicator.setAlpha(selected ? 1f : 0f); } } protected void animateSelectedView() { + boolean selected = isSelected(); + itemView.setSelected(selected); if (selectedIndicator != null) { selectedIndicator.animate() - .alpha(isSelected() ? 1f : 0f) + .alpha(selected ? 1f : 0f) .setDuration(SELECTION_ANIMATION_DURATION); } } boolean onLongClick() { - itemClickListener.onMediaLongClicked(mediaRecord); + itemClickListener.onMediaLongClicked(itemView, mediaRecord); return true; } @@ -817,7 +821,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter { interface ItemClickListener { void onMediaClicked(@NonNull View view, @NonNull MediaTable.MediaRecord mediaRecord); - void onMediaLongClicked(MediaTable.MediaRecord mediaRecord); + void onMediaLongClicked(@NonNull View view, MediaTable.MediaRecord mediaRecord); } interface AudioItemListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewContextMenu.kt new file mode 100644 index 0000000000..e0f3f81b64 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewContextMenu.kt @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.mediaoverview + +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.signal.core.ui.R as CoreUiR + +/** + * Context menu shown when long-pressing a media item in [MediaOverviewPageFragment]. + */ +class MediaOverviewContextMenu( + private val fragment: Fragment, + private val callbacks: Callbacks +) { + + private val lifecycleDisposable by lazy { LifecycleDisposable().bindTo(fragment.viewLifecycleOwner) } + + fun show(anchor: View, mediaRecord: MediaTable.MediaRecord) { + val recyclerView = anchor.parent as? RecyclerView + recyclerView?.suppressLayout(true) + anchor.isSelected = true + + SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup) + .preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW) + .offsetY(4.dp) + .onDismiss { + anchor.isSelected = false + recyclerView?.suppressLayout(false) + } + .show( + listOfNotNull( + getSaveActionItem(mediaRecord), + getDeleteActionItem(mediaRecord), + getSelectActionItem(mediaRecord), + getJumpToMessageActionItem(mediaRecord) + ) + ) + } + + private fun getSaveActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem? { + if (mediaRecord.attachment == null) return null + return ActionItem( + iconRes = R.drawable.symbol_save_android_24, + title = fragment.getString(R.string.save) + ) { + callbacks.onSave(mediaRecord) + } + } + + private fun getDeleteActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem { + return ActionItem( + iconRes = CoreUiR.drawable.symbol_trash_24, + title = fragment.getString(R.string.delete) + ) { + callbacks.onDelete(mediaRecord) + } + } + + private fun getSelectActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem { + return ActionItem( + iconRes = CoreUiR.drawable.symbol_check_circle_24, + title = fragment.getString(R.string.CallContextMenu__select) + ) { + callbacks.onSelect(mediaRecord) + } + } + + private fun getJumpToMessageActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem { + return ActionItem( + iconRes = R.drawable.symbol_open_24, + title = fragment.getString(R.string.MediaOverviewActivity_jump_to_message) + ) { + lifecycleDisposable += Single.fromCallable { + val dateReceived = SignalDatabase.messages.getMessageRecordOrNull(mediaRecord.messageId)?.dateReceived + ?: mediaRecord.date + SignalDatabase.messages.getMessagePositionInConversation(mediaRecord.threadId, dateReceived) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { position -> + fragment.startActivity( + ConversationIntents.createBuilderSync(fragment.requireContext(), mediaRecord.threadRecipientId, mediaRecord.threadId) + .withStartingPosition(maxOf(0, position)) + .build() + ) + } + } + } + + interface Callbacks { + fun onSave(mediaRecord: MediaTable.MediaRecord) + fun onDelete(mediaRecord: MediaTable.MediaRecord) + fun onSelect(mediaRecord: MediaTable.MediaRecord) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index 5bbf36b9b1..dc3f745983 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -63,6 +63,7 @@ import org.json.JSONException; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Objects; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -392,12 +393,52 @@ public final class MediaOverviewPageFragment extends LoggingFragment } @Override - public void onMediaLongClicked(MediaTable.MediaRecord mediaRecord) { - if (actionMode == null) { - enterMultiSelect(); + public void onMediaLongClicked(@NonNull View view, MediaTable.MediaRecord mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + return; } - handleMediaMultiSelectClick(mediaRecord); + new MediaOverviewContextMenu(this, new MediaOverviewContextMenu.Callbacks() { + @Override + public void onSave(@NonNull MediaTable.MediaRecord record) { + handleSaveSingleMedia(record); + } + + @Override + public void onDelete(@NonNull MediaTable.MediaRecord record) { + handleDeleteSingleMedia(record); + } + + @Override + public void onSelect(@NonNull MediaTable.MediaRecord record) { + enterMultiSelect(); + handleMediaMultiSelectClick(record); + } + }).show(view, mediaRecord); + } + + private void handleSaveSingleMedia(@NonNull MediaTable.MediaRecord mediaRecord) { + if (SignalStore.backup().getOptimizeStorage() && mediaRecord.getAttachment() != null && !mediaRecord.getAttachment().hasData) { + OffloadedMediaDialogUtil.showAllOffloaded(requireContext()); + return; + } + lifecycleDisposable.add( + MediaActions.handleSaveMedia(this, Collections.singleton(mediaRecord)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ); + } + + private void handleDeleteSingleMedia(@NonNull MediaTable.MediaRecord mediaRecord) { + if (DeleteSyncEducationDialog.shouldShow()) { + lifecycleDisposable.add( + DeleteSyncEducationDialog.show(getChildFragmentManager()) + .subscribe(() -> handleDeleteSingleMedia(mediaRecord)) + ); + return; + } + MediaActions.handleDeleteMedia(requireContext(), Collections.singleton(mediaRecord)); } private void handleDeleteSelectedMedia() { diff --git a/app/src/main/res/drawable/media_overview_detail_item_background.xml b/app/src/main/res/drawable/media_overview_detail_item_background.xml new file mode 100644 index 0000000000..4717539f0c --- /dev/null +++ b/app/src/main/res/drawable/media_overview_detail_item_background.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/media_overview_item_selected_foreground.xml b/app/src/main/res/drawable/media_overview_item_selected_foreground.xml new file mode 100644 index 0000000000..1d875f9ac7 --- /dev/null +++ b/app/src/main/res/drawable/media_overview_item_selected_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/media_overview_detail_item_audio.xml b/app/src/main/res/layout/media_overview_detail_item_audio.xml index 4a3cecc2d7..5656ef87e1 100644 --- a/app/src/main/res/layout/media_overview_detail_item_audio.xml +++ b/app/src/main/res/layout/media_overview_detail_item_audio.xml @@ -5,6 +5,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" + android:background="@drawable/media_overview_detail_item_background" android:minHeight="@dimen/media_overview_detail_item_height"> + android:layout_height="match_parent" + android:foreground="@drawable/media_overview_item_selected_foreground"> Sent by you to %1$s This media is not sent yet. + Jump to message Remind me later