Add long-press context menu in all media screen.

This commit is contained in:
Greyson Parrelli
2026-04-07 12:15:50 -04:00
parent 4c76cb682e
commit 38eed43046
11 changed files with 218 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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<Int> {
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)
}
}

View File

@@ -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() {

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_selected="true">
<inset
android:insetBottom="2dp"
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/conversation_list_selected_color" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item>
<ripple android:color="@color/conversation_list_selected_color">
<item android:id="@android:id/mask">
<inset
android:insetBottom="2dp"
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/transparent_black_60" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
</ripple>
</item>
</selector>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_selected="true">
<color android:color="@color/transparent_black_20" />
</item>
<item>
<ripple android:color="@color/transparent_black_30">
<item android:id="@android:id/mask">
<color android:color="@android:color/white" />
</item>
</ripple>
</item>
</selector>

View File

@@ -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">
<FrameLayout

View File

@@ -5,6 +5,7 @@
xmlns:tools="http://schemas.android.com/tools"
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">
<FrameLayout

View File

@@ -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">
<FrameLayout

View File

@@ -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">
<FrameLayout

View File

@@ -4,7 +4,8 @@
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:foreground="@drawable/media_overview_item_selected_foreground">
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/image"

View File

@@ -1834,6 +1834,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">Sent by you to %1$s</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">This media is not sent yet.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Remind me later</string>