From cb8bcba86b0b5c5b93a6210eece56d4c37a5b753 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 13 Aug 2026 20:39:01 -0400 Subject: [PATCH] Migrate recent media rail to compose. --- app/build.gradle.kts | 41 +++--- .../components/ThreadPhotoRailView.java | 132 ------------------ .../ConversationSettingsAction.kt | 5 +- .../ConversationSettingsFragment.kt | 62 ++++---- .../conversation/group/GroupSettingsEvent.kt | 31 ++-- .../conversation/group/GroupSettingsScreen.kt | 12 +- .../conversation/group/GroupSettingsState.kt | 5 +- .../group/GroupSettingsViewModel.kt | 42 +++--- .../individual/IndividualSettingsEvent.kt | 31 ++-- .../individual/IndividualSettingsScreen.kt | 12 +- .../individual/IndividualSettingsState.kt | 5 +- .../individual/IndividualSettingsViewModel.kt | 44 +++--- .../individual/NoteToSelfSettingsScreen.kt | 12 +- .../individual/ReleaseNotesSettingsScreen.kt | 14 +- .../shared/RecentMediaRailActionExtensions.kt | 30 ++++ .../shared/SharedMediaClickAction.kt | 35 ----- .../conversation/shared/SharedMediaLoader.kt | 69 ++++++--- .../conversation/shared/SharedMediaSection.kt | 63 ++------- .../recipient_preference_photo_rail.xml | 18 --- .../recipient_preference_photo_rail_item.xml | 19 --- .../group/GroupSettingsViewModelTest.kt | 7 +- .../IndividualSettingsViewModelTest.kt | 46 ++++-- .../core/ui/compose/EventDrivenPresenter.kt | 44 ++++++ lib/ui-components/build.gradle.kts | 23 +++ .../src/main/AndroidManifest.xml | 2 + .../recentmediarail/RecentMediaRail.kt | 124 ++++++++++++++++ .../recentmediarail/RecentMediaRailAction.kt | 28 ++++ .../recentmediarail/RecentMediaRailEvents.kt | 35 +++++ .../RecentMediaRailPresenter.kt | 83 +++++++++++ .../recentmediarail/RecentMediaRailState.kt | 46 ++++++ settings.gradle.kts | 1 + 31 files changed, 666 insertions(+), 455 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/RecentMediaRailActionExtensions.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt delete mode 100644 app/src/main/res/layout/recipient_preference_photo_rail.xml delete mode 100644 app/src/main/res/layout/recipient_preference_photo_rail_item.xml create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/EventDrivenPresenter.kt create mode 100644 lib/ui-components/build.gradle.kts create mode 100644 lib/ui-components/src/main/AndroidManifest.xml create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRail.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailAction.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailEvents.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailPresenter.kt create mode 100644 lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76342b6809..b0b496f573 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -683,30 +683,33 @@ dependencies { ktlintRuleset(libs.ktlint.twitter.compose) coreLibraryDesugaring(libs.android.tools.desugar) - implementation(project(":lib:archive")) - implementation(project(":lib:libsignal-service")) - implementation(project(":lib:network")) - implementation(project(":lib:paging")) - implementation(project(":core:util")) - implementation(project(":lib:glide")) - implementation(project(":lib:video")) - implementation(project(":lib:device-transfer")) - implementation(project(":lib:image-editor")) - implementation(project(":lib:donations")) - implementation(project(":lib:debuglogs-viewer")) - implementation(project(":lib:contacts")) - implementation(project(":lib:qr")) - implementation(project(":lib:sticky-header-grid")) - implementation(project(":lib:photoview")) - implementation(project(":lib:blurhash")) - implementation(project(":core:ui")) implementation(project(":core:models")) implementation(project(":core:models-jvm")) implementation(project(":core:serialization")) + implementation(project(":core:ui")) + implementation(project(":core:util")) + + implementation(project(":lib:apng")) + implementation(project(":lib:archive")) + implementation(project(":lib:contacts")) + implementation(project(":lib:blurhash")) + implementation(project(":lib:debuglogs-viewer")) + implementation(project(":lib:device-transfer")) + implementation(project(":lib:donations")) + implementation(project(":lib:emoji")) + implementation(project(":lib:glide")) + implementation(project(":lib:image-editor")) + implementation(project(":lib:libsignal-service")) + implementation(project(":lib:network")) + implementation(project(":lib:paging")) + implementation(project(":lib:photoview")) + implementation(project(":lib:qr")) + implementation(project(":lib:sticky-header-grid")) + implementation(project(":lib:ui-components")) + implementation(project(":lib:video")) + implementation(project(":feature:camera")) implementation(project(":feature:registration")) - implementation(project(":lib:apng")) - implementation(project(":lib:emoji")) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java deleted file mode 100644 index 6671fdf5ae..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThreadPhotoRailView.java +++ /dev/null @@ -1,132 +0,0 @@ -package org.thoughtcrime.securesms.components; - - -import android.content.Context; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.bumptech.glide.RequestManager; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.MediaTable; -import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.util.MediaUtil; - -import java.util.ArrayList; -import java.util.List; - -public class ThreadPhotoRailView extends FrameLayout { - - @NonNull private final RecyclerView recyclerView; - @Nullable private OnItemClickedListener listener; - - public ThreadPhotoRailView(Context context) { - this(context, null); - } - - public ThreadPhotoRailView(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ThreadPhotoRailView(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - inflate(context, R.layout.recipient_preference_photo_rail, this); - - this.recyclerView = findViewById(R.id.photo_list); - this.recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)); - this.recyclerView.setItemAnimator(new DefaultItemAnimator()); - this.recyclerView.setNestedScrollingEnabled(false); - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.listener = listener; - - if (this.recyclerView.getAdapter() != null) { - ((ThreadPhotoRailAdapter)this.recyclerView.getAdapter()).setListener(listener); - } - } - - public void setMediaRecords(@NonNull RequestManager requestManager, @NonNull List mediaRecords) { - this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), requestManager, mediaRecords, this.listener)); - } - - private static class ThreadPhotoRailAdapter extends RecyclerView.Adapter { - - @SuppressWarnings("unused") - private static final String TAG = Log.tag(ThreadPhotoRailAdapter.class); - - @NonNull private final RequestManager requestManager; - - @Nullable private OnItemClickedListener clickedListener; - - private final List mediaRecords = new ArrayList<>(); - - private ThreadPhotoRailAdapter(@NonNull Context context, - @NonNull RequestManager requestManager, - @NonNull List mediaRecords, - @Nullable OnItemClickedListener listener) - { - this.requestManager = requestManager; - this.clickedListener = listener; - - this.mediaRecords.clear(); - this.mediaRecords.addAll(mediaRecords); - } - - @Override - public int getItemCount() { - return mediaRecords.size(); - } - - @Override - public @NonNull ThreadPhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View itemView = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.recipient_preference_photo_rail_item, parent, false); - - return new ThreadPhotoViewHolder(itemView); - } - - @Override - public void onBindViewHolder(@NonNull ThreadPhotoViewHolder viewHolder, int position) { - MediaTable.MediaRecord mediaRecord = mediaRecords.get(position); - Slide slide = MediaUtil.getSlideForAttachment(mediaRecord.getAttachment()); - - viewHolder.imageView.setImageResource(requestManager, slide, false, false); - viewHolder.imageView.setOnClickListener(v -> { - MediaPreviewCache.INSTANCE.setDrawable(viewHolder.imageView.getImageDrawable()); - if (clickedListener != null) clickedListener.onItemClicked(viewHolder.imageView, mediaRecord); - }); - } - - public void setListener(@Nullable OnItemClickedListener listener) { - this.clickedListener = listener; - } - - static class ThreadPhotoViewHolder extends RecyclerView.ViewHolder { - - ThumbnailView imageView; - - ThreadPhotoViewHolder(View itemView) { - super(itemView); - - this.imageView = itemView.findViewById(R.id.thumbnail); - } - } - } - - public interface OnItemClickedListener { - void onItemClicked(View itemView, MediaTable.MediaRecord mediaRecord); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt index 9e47eeee09..0017697a35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.settings.conversation +import androidx.compose.ui.unit.IntRect import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.model.IdentityRecord @@ -105,8 +106,8 @@ sealed interface ConversationSettingsAction { override fun toString(): String = "ShowSafetyNumber(hasIdentityRecord=${identityRecord != null})" } - /** Open the media viewer on the item the user tapped in the shared media rail. */ - data class ShowMediaPreview(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : ConversationSettingsAction { + /** Open the media viewer on the item the user tapped in the shared media rail, scaling up out of [bounds]. */ + data class ShowMediaPreview(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean, val bounds: IntRect) : ConversationSettingsAction { override fun toString(): String = "ShowMediaPreview(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 2ef4e3a446..5587a81a39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -17,6 +17,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.IntRect import androidx.core.view.doOnPreDraw import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -30,6 +31,7 @@ import org.signal.core.util.getParcelableArrayListExtraCompat import org.signal.core.util.logging.Log import org.signal.core.util.requireParcelableCompat import org.signal.donations.InAppPaymentType +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents import org.thoughtcrime.securesms.AvatarPreviewActivity import org.thoughtcrime.securesms.BlockUnblockDialog import org.thoughtcrime.securesms.MainActivity @@ -66,6 +68,7 @@ import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory +import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache import org.thoughtcrime.securesms.nicknames.NicknameActivity import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.recipients.RecipientExporter @@ -117,9 +120,8 @@ class ConversationSettingsFragment : ComposeFragment() { private var transitionCallback: TransitionCallback? = null private var chatRouter: MainNavigationChatDetailRouter? = null - /** The avatar and shared media views own the shared element transitions out of this screen. */ + /** The avatar view owns the shared element transition out of this screen. */ private var avatarView: View? = null - private var lastClickedSharedMediaView: View? = null private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate private lateinit var nicknameLauncher: ActivityResultLauncher @@ -163,7 +165,6 @@ class ConversationSettingsFragment : ComposeFragment() { override fun onDestroyView() { super.onDestroyView() avatarView = null - lastClickedSharedMediaView = null } /** @@ -202,8 +203,7 @@ class ConversationSettingsFragment : ComposeFragment() { state = state, onEvent = individualViewModel::onEvent, onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, - onAvatarViewCreated = { avatarView = it }, - onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + onAvatarViewCreated = { avatarView = it } ) } @@ -218,8 +218,7 @@ class ConversationSettingsFragment : ComposeFragment() { state = state, onEvent = individualViewModel::onEvent, onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, - onAvatarViewCreated = { avatarView = it }, - onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + onAvatarViewCreated = { avatarView = it } ) } @@ -234,8 +233,7 @@ class ConversationSettingsFragment : ComposeFragment() { state = state, onEvent = individualViewModel::onEvent, onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, - onAvatarViewCreated = { avatarView = it }, - onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + onAvatarViewCreated = { avatarView = it } ) } @@ -250,11 +248,25 @@ class ConversationSettingsFragment : ComposeFragment() { state = state, onEvent = groupViewModel::onEvent, onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, - onAvatarViewCreated = { avatarView = it }, - onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + onAvatarViewCreated = { avatarView = it } ) } + /** + * Animates the media viewer up out of the rail item the user tapped. [bounds] arrive in window coordinates, since the + * rail is Compose and has no view of its own to hand over, so they have to be moved into our root view's space first. + */ + private fun scaleUpFromRail(bounds: IntRect): ActivityOptions? { + if (bounds.isEmpty) { + return null + } + + val root = view ?: return null + val rootLocation = IntArray(2).also { root.getLocationInWindow(it) } + + return ActivityOptions.makeScaleUpAnimation(root, bounds.left - rootLocation[0], bounds.top - rootLocation[1], bounds.width, bounds.height) + } + @Composable private fun NotifyWhenLoaded(isLoaded: Boolean) { LaunchedEffect(isLoaded) { @@ -277,8 +289,8 @@ class ConversationSettingsFragment : ComposeFragment() { } REQUEST_CODE_RETURN_FROM_MEDIA -> { dispatch( - individual = { it.onEvent(IndividualSettingsEvent.SharedMediaRefreshRequested) }, - group = { it.onEvent(GroupSettingsEvent.SharedMediaRefreshRequested) } + individual = { it.onEvent(IndividualSettingsEvent.MediaRailEvent(RecentMediaRailEvents.RefreshRequested)) }, + group = { it.onEvent(GroupSettingsEvent.MediaRailEvent(RecentMediaRailEvents.RefreshRequested)) } ) } REQUEST_CODE_ADD_CONTACT, REQUEST_CODE_VIEW_CONTACT -> { @@ -414,21 +426,15 @@ class ConversationSettingsFragment : ComposeFragment() { VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), action.identityRecord) } is ConversationSettingsAction.ShowMediaPreview -> { - val view = lastClickedSharedMediaView - if (view != null) { - view.transitionName = "thumb" - val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), view, "thumb") - startActivityForResult( - MediaIntentFactory.intentFromMediaRecord(requireContext(), action.mediaRecord, action.isLtr, allMediaInRail = true), - REQUEST_CODE_RETURN_FROM_MEDIA, - options.toBundle() - ) - } else { - startActivityForResult( - MediaIntentFactory.intentFromMediaRecord(requireContext(), action.mediaRecord, action.isLtr, allMediaInRail = true), - REQUEST_CODE_RETURN_FROM_MEDIA - ) - } + // The rail is Compose and has no drawable to hand over, so make sure the viewer doesn't try to transition out of + // whatever some other screen left behind. + MediaPreviewCache.drawable = null + + startActivityForResult( + MediaIntentFactory.intentFromMediaRecord(requireContext(), action.mediaRecord, action.isLtr, allMediaInRail = true), + REQUEST_CODE_RETURN_FROM_MEDIA, + scaleUpFromRail(action.bounds)?.toBundle() + ) } is ConversationSettingsAction.DownloadMedia -> { action.mediaRecord.attachment?.let { AttachmentDownloadJob.downloadAttachmentIfNeeded(it) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt index 5179b5d31c..b072e7ca89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt @@ -5,9 +5,11 @@ package org.thoughtcrime.securesms.components.settings.conversation.group +import org.signal.uicomponents.recentmediarail.RecentMediaRailAction +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents +import org.signal.uicomponents.recentmediarail.RecentMediaRailState import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry -import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel import org.thoughtcrime.securesms.recipients.RecipientId @@ -74,14 +76,6 @@ sealed interface GroupSettingsEvent { /** The user tapped the starred messages row. */ data object StarredMessagesClicked : GroupSettingsEvent - /** The user tapped an item in the shared media rail, carrying the media that item stands for. */ - data class SharedMediaClicked(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : GroupSettingsEvent { - override fun toString(): String = "SharedMediaClicked(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" - } - - /** The user tapped "see all" on the shared media rail, which opens the media overview. */ - data object SeeAllSharedMediaClicked : GroupSettingsEvent - /** The user tapped the search button in the member list header. */ data object MemberSearchClicked : GroupSettingsEvent @@ -150,9 +144,6 @@ sealed interface GroupSettingsEvent { override fun toString(): String = "AddMembersSelected(count=${recipientIds.size})" } - /** The user came back from the media viewer, so the shared media rail may be out of date. */ - data object SharedMediaRefreshRequested : GroupSettingsEvent - /** The group's record or recipient changed, alongside whether its chat is currently archived. */ data class GroupDetailsChanged(val details: GroupDetails, val isArchived: Boolean) : GroupSettingsEvent { override fun toString(): String = "GroupDetailsChanged(memberCount=${details.members.size}, isArchived=$isArchived)" @@ -166,11 +157,6 @@ sealed interface GroupSettingsEvent { /** The group's story became unviewed, viewed, or went away entirely. */ data class StoryViewStateChanged(val storyViewState: StoryViewState) : GroupSettingsEvent - /** The shared media rail finished loading, either for the first time or after a refresh. */ - data class SharedMediaChanged(val media: List) : GroupSettingsEvent { - override fun toString(): String = "SharedMediaChanged(count=${media.size})" - } - /** The calls behind the call info variant of this screen finished loading. */ data class CallsChanged(val calls: List) : GroupSettingsEvent { override fun toString(): String = "CallsChanged(count=${calls.size})" @@ -178,4 +164,15 @@ sealed interface GroupSettingsEvent { /** The group's thread id came back, or -1 if it doesn't have a thread yet. */ data class ThreadIdLoaded(val threadId: Long) : GroupSettingsEvent + + /** Received an event from the media rail that we want to forward */ + data class MediaRailEvent(val event: RecentMediaRailEvents) : GroupSettingsEvent + + /** The media rail's presenter emitted new state for us to mirror. */ + data class MediaRailStateChanged(val railState: RecentMediaRailState) : GroupSettingsEvent { + override fun toString(): String = "MediaRailStateChanged(count=${railState.media.size}, loaded=${railState.loaded})" + } + + /** The media rail's presenter decided something needs doing that only this screen can do. */ + data class MediaRailAction(val action: RecentMediaRailAction) : GroupSettingsEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt index 484aafaa47..162a3a19b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt @@ -78,7 +78,6 @@ fun GroupSettingsScreen( onEvent: (GroupSettingsEvent) -> Unit, onNavigationClick: () -> Unit, onAvatarViewCreated: (View) -> Unit, - onSharedMediaViewClicked: (View) -> Unit, modifier: Modifier = Modifier ) { ConversationSettingsScaffold( @@ -198,11 +197,8 @@ fun GroupSettingsScreen( } sharedMediaSection( - media = state.sharedMedia, - loaded = state.sharedMediaLoaded, - onMediaClick = { mediaRecord, isLtr -> onEvent(GroupSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, - onMediaViewClicked = onSharedMediaViewClicked, - onSeeAllClick = { onEvent(GroupSettingsEvent.SeeAllSharedMediaClicked) } + state = state.mediaRail, + onEvent = { onEvent(GroupSettingsEvent.MediaRailEvent(it)) } ) membershipSection(state, onEvent) @@ -668,7 +664,6 @@ private fun previewState( groupId = PREVIEW_GROUP_ID, recipient = recipient, threadId = 1L, - sharedMediaLoaded = true, canModifyBlockedState = true, allMembers = allMembers, membersExpanded = membersExpanded, @@ -698,8 +693,7 @@ private fun GroupSettingsScreenPreview(state: GroupSettingsState) { state = state, onEvent = {}, onNavigationClick = {}, - onAvatarViewCreated = {}, - onSharedMediaViewClicked = {} + onAvatarViewCreated = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt index f84efa948b..8612d836aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt @@ -5,12 +5,12 @@ package org.thoughtcrime.securesms.components.settings.conversation.group +import org.signal.uicomponents.recentmediarail.RecentMediaRailState import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry import org.thoughtcrime.securesms.components.settings.conversation.shared.CollapsibleList import org.thoughtcrime.securesms.components.settings.conversation.shared.GroupMember import org.thoughtcrime.securesms.components.settings.conversation.shared.LegacyGroupState -import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel @@ -31,8 +31,7 @@ data class GroupSettingsState( val disappearingMessagesLifespan: Int = 0, val canModifyBlockedState: Boolean = false, val isArchived: Boolean = false, - val sharedMedia: List = emptyList(), - val sharedMediaLoaded: Boolean = false, + val mediaRail: RecentMediaRailState = RecentMediaRailState(), val calls: List = emptyList(), val callBar: CallBarState = CallBarState(), val title: String = "", diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt index 1a9c64b0cc..174fac5640 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents +import org.signal.uicomponents.recentmediarail.RecentMediaRailPresenter import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails @@ -28,7 +30,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSe import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockAndSpamHandler import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState import org.thoughtcrime.securesms.components.settings.conversation.shared.SharedMediaLoader -import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaClickAction +import org.thoughtcrime.securesms.components.settings.conversation.shared.toConversationSettingsAction import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult @@ -66,6 +68,7 @@ class GroupSettingsViewModel( val actions: Flow = _actions.receiveAsFlow() private val sharedMediaLoader = SharedMediaLoader(repository) + private val mediaRailPresenter = RecentMediaRailPresenter(viewModelScope, sharedMediaLoader) init { repository @@ -81,9 +84,14 @@ class GroupSettingsViewModel( .onEach { onEvent(GroupSettingsEvent.StoryViewStateChanged(it)) } .launchIn(viewModelScope) - sharedMediaLoader - .observe() - .onEach { onEvent(GroupSettingsEvent.SharedMediaChanged(it)) } + mediaRailPresenter + .state + .onEach { onEvent(GroupSettingsEvent.MediaRailStateChanged(it)) } + .launchIn(viewModelScope) + + mediaRailPresenter + .actions + .onEach { onEvent(GroupSettingsEvent.MediaRailAction(it)) } .launchIn(viewModelScope) if (callMessageIds.isNotEmpty()) { @@ -185,10 +193,6 @@ class GroupSettingsViewModel( GroupSettingsEvent.StarredMessagesClicked -> _actions.send(ConversationSettingsAction.OpenStarredMessages(state.threadId)) - is GroupSettingsEvent.SharedMediaClicked -> _actions.send(sharedMediaClickAction(event.mediaRecord, event.isLtr)) - - GroupSettingsEvent.SeeAllSharedMediaClicked -> _actions.send(ConversationSettingsAction.ShowMediaOverview(state.threadId)) - GroupSettingsEvent.MemberSearchClicked -> { _actions.send(ConversationSettingsAction.NavigateToMemberSearch(groupId, state.canAddMembers, state.groupLinkEnabled)) } @@ -283,10 +287,6 @@ class GroupSettingsViewModel( _state.update { it.copy(dialog = Dialog.None) } } - GroupSettingsEvent.SharedMediaRefreshRequested -> { - sharedMediaLoader.refresh() - } - is GroupSettingsEvent.GroupDetailsChanged -> { _state.update { it.applyGroupDetails(event.details, event.isArchived) } } @@ -304,17 +304,25 @@ class GroupSettingsViewModel( _state.update { it.copy(storyViewState = event.storyViewState) } } - is GroupSettingsEvent.SharedMediaChanged -> { - _state.update { it.copy(sharedMedia = event.media, sharedMediaLoaded = true) } - } - is GroupSettingsEvent.CallsChanged -> { _state.update { it.copy(calls = event.calls) } } is GroupSettingsEvent.ThreadIdLoaded -> { _state.update { it.copy(threadId = event.threadId) } - sharedMediaLoader.onThreadIdLoaded(event.threadId) + mediaRailPresenter.onEvent(RecentMediaRailEvents.SourceChanged(event.threadId)) + } + + is GroupSettingsEvent.MediaRailEvent -> { + mediaRailPresenter.onEvent(event.event) + } + + is GroupSettingsEvent.MediaRailStateChanged -> { + _state.update { it.copy(mediaRail = event.railState) } + } + + is GroupSettingsEvent.MediaRailAction -> { + event.action.toConversationSettingsAction(sharedMediaLoader, state.threadId)?.let { _actions.send(it) } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt index ffc200b154..dd2f23fe9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt @@ -5,9 +5,11 @@ package org.thoughtcrime.securesms.components.settings.conversation.individual +import org.signal.uicomponents.recentmediarail.RecentMediaRailAction +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents +import org.signal.uicomponents.recentmediarail.RecentMediaRailState import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry -import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.recipients.Recipient @@ -80,14 +82,6 @@ sealed interface IndividualSettingsEvent { /** The user tapped the safety number row. */ data object ViewSafetyNumberClicked : IndividualSettingsEvent - /** The user tapped an item in the shared media rail, carrying the media that item stands for. */ - data class SharedMediaClicked(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : IndividualSettingsEvent { - override fun toString(): String = "SharedMediaClicked(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" - } - - /** The user tapped "see all" on the shared media rail, which opens the media overview. */ - data object SeeAllSharedMediaClicked : IndividualSettingsEvent - /** The user tapped the support center link, which only the release notes chat offers. */ data object SupportCenterClicked : IndividualSettingsEvent @@ -127,9 +121,6 @@ sealed interface IndividualSettingsEvent { /** Dismisses whatever is in [IndividualSettingsState.dialog]. */ data object DialogDismissed : IndividualSettingsEvent - /** The user came back from the media viewer, so the shared media rail may be out of date. */ - data object SharedMediaRefreshRequested : IndividualSettingsEvent - /** The user came back from adding or viewing a system contact, so the recipient may be out of date. */ data object RecipientRefreshRequested : IndividualSettingsEvent @@ -141,11 +132,6 @@ sealed interface IndividualSettingsEvent { /** The recipient's story became unviewed, viewed, or went away entirely. */ data class StoryViewStateChanged(val storyViewState: StoryViewState) : IndividualSettingsEvent - /** The shared media rail finished loading, either for the first time or after a refresh. */ - data class SharedMediaChanged(val media: List) : IndividualSettingsEvent { - override fun toString(): String = "SharedMediaChanged(count=${media.size})" - } - /** The calls behind the call info variant of this screen finished loading. */ data class CallsChanged(val calls: List) : IndividualSettingsEvent { override fun toString(): String = "CallsChanged(count=${calls.size})" @@ -166,4 +152,15 @@ sealed interface IndividualSettingsEvent { data class IdentityRecordLoaded(val identityRecord: IdentityRecord?) : IndividualSettingsEvent { override fun toString(): String = "IdentityRecordLoaded(present=${identityRecord != null})" } + + /** Received an event from the media rail that we want to forward */ + data class MediaRailEvent(val event: RecentMediaRailEvents) : IndividualSettingsEvent + + /** The media rail's presenter emitted new state for us to mirror. */ + data class MediaRailStateChanged(val railState: RecentMediaRailState) : IndividualSettingsEvent { + override fun toString(): String = "MediaRailStateChanged(count=${railState.media.size}, loaded=${railState.loaded})" + } + + /** The media rail's presenter decided something needs doing that only this screen can do. */ + data class MediaRailAction(val action: RecentMediaRailAction) : IndividualSettingsEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt index 2e66b028c1..86791313ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt @@ -65,7 +65,6 @@ fun IndividualSettingsScreen( onEvent: (IndividualSettingsEvent) -> Unit, onNavigationClick: () -> Unit, onAvatarViewCreated: (View) -> Unit, - onSharedMediaViewClicked: (View) -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -189,11 +188,8 @@ fun IndividualSettingsScreen( } sharedMediaSection( - media = state.sharedMedia, - loaded = state.sharedMediaLoaded, - onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, - onMediaViewClicked = onSharedMediaViewClicked, - onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } + state = state.mediaRail, + onEvent = { onEvent(IndividualSettingsEvent.MediaRailEvent(it)) } ) badgeSection(state, onEvent) @@ -360,7 +356,6 @@ private fun IndividualSettingsScreenPreview() { state = IndividualSettingsState( recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Miles", "Morales"), about = "Just hanging around"), threadId = 1L, - sharedMediaLoaded = true, canModifyBlockedState = true, starredMessagesEnabled = true, selfHasGroups = true, @@ -375,8 +370,7 @@ private fun IndividualSettingsScreenPreview() { ), onEvent = {}, onNavigationClick = {}, - onAvatarViewCreated = {}, - onSharedMediaViewClicked = {} + onAvatarViewCreated = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt index 53753e39f4..b37e863b94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt @@ -5,10 +5,10 @@ package org.thoughtcrime.securesms.components.settings.conversation.individual +import org.signal.uicomponents.recentmediarail.RecentMediaRailState import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry import org.thoughtcrime.securesms.components.settings.conversation.shared.CollapsibleList -import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.recipients.Recipient @@ -30,8 +30,7 @@ data class IndividualSettingsState( val allGroupsInCommon: List = emptyList(), val selfHasGroups: Boolean = false, val groupsInCommonExpanded: Boolean = false, - val sharedMedia: List = emptyList(), - val sharedMediaLoaded: Boolean = false, + val mediaRail: RecentMediaRailState = RecentMediaRailState(), val calls: List = emptyList(), val callBar: CallBarState = CallBarState(), val dialog: Dialog = Dialog.None diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt index 5707cf1ee7..310952d778 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.signal.core.ui.compose.EventDrivenViewModel import org.signal.core.util.logging.Log +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents +import org.signal.uicomponents.recentmediarail.RecentMediaRailPresenter import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsKind import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository @@ -28,7 +30,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.individual.In import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockAndSpamHandler import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState import org.thoughtcrime.securesms.components.settings.conversation.shared.SharedMediaLoader -import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaClickAction +import org.thoughtcrime.securesms.components.settings.conversation.shared.toConversationSettingsAction import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -50,6 +52,9 @@ class IndividualSettingsViewModel( /** Whether we're the call-info variant of this screen, which shows a message button in place of search. */ private val isCallInfoVariant: Boolean = callMessageIds.isNotEmpty() + /** The release notes chat doesn't show a media rail, so there's no reason to go load one. */ + private val showsSharedMedia: Boolean = kind != ConversationSettingsKind.RELEASE_NOTES + private val _state = MutableStateFlow( IndividualSettingsState( isDeprecatedOrUnregistered = repository.isDeprecatedOrUnregistered(), @@ -65,6 +70,7 @@ class IndividualSettingsViewModel( val actions: Flow = _actions.receiveAsFlow() private val sharedMediaLoader = SharedMediaLoader(repository) + private val mediaRailPresenter = RecentMediaRailPresenter(viewModelScope, sharedMediaLoader) init { require(kind != ConversationSettingsKind.GROUP) { "Groups belong to GroupSettingsViewModel" } @@ -79,9 +85,14 @@ class IndividualSettingsViewModel( .onEach { onEvent(IndividualSettingsEvent.StoryViewStateChanged(it)) } .launchIn(viewModelScope) - sharedMediaLoader - .observe() - .onEach { onEvent(IndividualSettingsEvent.SharedMediaChanged(it)) } + mediaRailPresenter + .state + .onEach { onEvent(IndividualSettingsEvent.MediaRailStateChanged(it)) } + .launchIn(viewModelScope) + + mediaRailPresenter + .actions + .onEach { onEvent(IndividualSettingsEvent.MediaRailAction(it)) } .launchIn(viewModelScope) if (callMessageIds.isNotEmpty()) { @@ -181,12 +192,6 @@ class IndividualSettingsViewModel( _state.update { it.copy(identityRecord = identityRecord) } _actions.send(ConversationSettingsAction.ShowSafetyNumber(identityRecord)) } - is IndividualSettingsEvent.SharedMediaClicked -> { - _actions.send(sharedMediaClickAction(event.mediaRecord, event.isLtr)) - } - IndividualSettingsEvent.SeeAllSharedMediaClicked -> { - _actions.send(ConversationSettingsAction.ShowMediaOverview(state.threadId)) - } IndividualSettingsEvent.SupportCenterClicked -> { _actions.send(ConversationSettingsAction.OpenSupportCenter) } @@ -233,9 +238,6 @@ class IndividualSettingsViewModel( IndividualSettingsEvent.DialogDismissed -> { _state.update { it.copy(dialog = Dialog.None) } } - IndividualSettingsEvent.SharedMediaRefreshRequested -> { - sharedMediaLoader.refresh() - } IndividualSettingsEvent.RecipientRefreshRequested -> { repository.refreshRecipient(recipientId) } @@ -245,15 +247,14 @@ class IndividualSettingsViewModel( is IndividualSettingsEvent.StoryViewStateChanged -> { _state.update { it.copy(storyViewState = event.storyViewState) } } - is IndividualSettingsEvent.SharedMediaChanged -> { - _state.update { it.copy(sharedMedia = event.media, sharedMediaLoaded = true) } - } is IndividualSettingsEvent.CallsChanged -> { _state.update { it.copy(calls = event.calls) } } is IndividualSettingsEvent.ThreadIdLoaded -> { _state.update { it.copy(threadId = event.threadId) } - sharedMediaLoader.onThreadIdLoaded(event.threadId) + if (showsSharedMedia) { + mediaRailPresenter.onEvent(RecentMediaRailEvents.SourceChanged(event.threadId)) + } } is IndividualSettingsEvent.GroupsInCommonChanged -> { _state.update { it.copy(allGroupsInCommon = event.groupsInCommon) } @@ -264,6 +265,15 @@ class IndividualSettingsViewModel( is IndividualSettingsEvent.IdentityRecordLoaded -> { _state.update { it.copy(identityRecord = event.identityRecord) } } + is IndividualSettingsEvent.MediaRailEvent -> { + mediaRailPresenter.onEvent(event.event) + } + is IndividualSettingsEvent.MediaRailStateChanged -> { + _state.update { it.copy(mediaRail = event.railState) } + } + is IndividualSettingsEvent.MediaRailAction -> { + event.action.toConversationSettingsAction(sharedMediaLoader, state.threadId)?.let { _actions.send(it) } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt index efcbef4682..72c2328ba8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt @@ -34,7 +34,6 @@ fun NoteToSelfSettingsScreen( onEvent: (IndividualSettingsEvent) -> Unit, onNavigationClick: () -> Unit, onAvatarViewCreated: (View) -> Unit, - onSharedMediaViewClicked: (View) -> Unit, modifier: Modifier = Modifier ) { ConversationSettingsScaffold( @@ -101,11 +100,8 @@ fun NoteToSelfSettingsScreen( } sharedMediaSection( - media = state.sharedMedia, - loaded = state.sharedMediaLoaded, - onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, - onMediaViewClicked = onSharedMediaViewClicked, - onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } + state = state.mediaRail, + onEvent = { onEvent(IndividualSettingsEvent.MediaRailEvent(it)) } ) } } @@ -118,14 +114,12 @@ private fun NoteToSelfSettingsScreenPreview() { state = IndividualSettingsState( recipient = previewRecipient(1L, isSelf = true), threadId = 1L, - sharedMediaLoaded = true, starredMessagesEnabled = true, callBar = CallBarState(isSearchAvailable = true) ), onEvent = {}, onNavigationClick = {}, - onAvatarViewCreated = {}, - onSharedMediaViewClicked = {} + onAvatarViewCreated = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt index 1e2d92a1da..6a9fb26cec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt @@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.components.settings.conversation.shared.Intern import org.thoughtcrime.securesms.components.settings.conversation.shared.SoundsAndNotificationsRow import org.thoughtcrime.securesms.components.settings.conversation.shared.UnmuteDialog import org.thoughtcrime.securesms.components.settings.conversation.shared.previewRecipient -import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaSection import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient @@ -40,7 +39,6 @@ fun ReleaseNotesSettingsScreen( onEvent: (IndividualSettingsEvent) -> Unit, onNavigationClick: () -> Unit, onAvatarViewCreated: (View) -> Unit, - onSharedMediaViewClicked: (View) -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -114,14 +112,6 @@ fun ReleaseNotesSettingsScreen( ) } - sharedMediaSection( - media = state.sharedMedia, - loaded = state.sharedMediaLoaded, - onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, - onMediaViewClicked = onSharedMediaViewClicked, - onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } - ) - item { Dividers.Default() } item { Texts.SectionHeader(text = stringResource(R.string.preferences__help)) } @@ -194,14 +184,12 @@ private fun ReleaseNotesSettingsScreenPreview() { state = IndividualSettingsState( recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Signal", null), isReleaseNotes = true), threadId = 1L, - sharedMediaLoaded = true, canModifyBlockedState = true, callBar = CallBarState(isMuteAvailable = true, isSearchAvailable = true) ), onEvent = {}, onNavigationClick = {}, - onAvatarViewCreated = {}, - onSharedMediaViewClicked = {} + onAvatarViewCreated = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/RecentMediaRailActionExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/RecentMediaRailActionExtensions.kt new file mode 100644 index 0000000000..ceca16f6ad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/RecentMediaRailActionExtensions.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import org.signal.uicomponents.recentmediarail.RecentMediaRailAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction + +/** + * Translates something the shared media rail decided needs doing into the settings action that carries it out, or null + * if the media it refers to has since gone away. + */ +fun RecentMediaRailAction.toConversationSettingsAction(loader: SharedMediaLoader, threadId: Long): ConversationSettingsAction? { + return when (this) { + is RecentMediaRailAction.OpenMedia -> { + loader.recordAt(index)?.let { ConversationSettingsAction.ShowMediaPreview(it, leftToRight, bounds) } + } + is RecentMediaRailAction.DownloadMedia -> { + loader.recordAt(index)?.let { ConversationSettingsAction.DownloadMedia(it) } + } + RecentMediaRailAction.ShowMediaUnavailable -> { + ConversationSettingsAction.ShowMediaNotSentYet + } + RecentMediaRailAction.OpenAllMedia -> { + ConversationSettingsAction.ShowMediaOverview(threadId) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt deleted file mode 100644 index 988d1e8836..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.conversation.shared - -import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction -import org.thoughtcrime.securesms.database.AttachmentTable -import org.thoughtcrime.securesms.database.MediaTable - -/** Where a tap on the shared media rail should go, which depends on whether the attachment is actually here yet. */ -fun sharedMediaClickAction(mediaRecord: MediaTable.MediaRecord, isLtr: Boolean): ConversationSettingsAction { - val attachment = mediaRecord.attachment - - return when { - attachment == null -> { - ConversationSettingsAction.ShowMediaNotSentYet - } - attachment.displayUri == null -> { - if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { - ConversationSettingsAction.DownloadMedia(mediaRecord) - } else { - ConversationSettingsAction.ShowMediaNotSentYet - } - } - attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && - attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED -> { - ConversationSettingsAction.ShowMediaNotSentYet - } - else -> { - ConversationSettingsAction.ShowMediaPreview(mediaRecord, isLtr) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt index a95d34a90a..df2c2238f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt @@ -5,40 +5,65 @@ package org.thoughtcrime.securesms.components.settings.conversation.shared -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map +import org.signal.uicomponents.recentmediarail.RecentMedia +import org.signal.uicomponents.recentmediarail.RecentMedia.Availability +import org.signal.uicomponents.recentmediarail.RecentMediaRailPresenter import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.MediaTable private const val SHARED_MEDIA_LIMIT = 100 /** - * Loads the shared media rail for a thread, and lets the host ask for a reload after the user comes back from the - * media viewer. Held by each conversation settings view model, which applies the results to its own state. + * Feeds the shared media rail for a thread. Held by each conversation settings view model, which hands it to the rail's + * presenter. * - * Nothing is emitted until the thread id has been resolved, so that a screen can tell "still loading" apart from - * "this chat has no media" and reserve space for the rail accordingly. + * The rail itself only ever refers to media by position, so this also hangs onto the records it last loaded so that the + * view model can turn a tap back into the media it stands for. */ -class SharedMediaLoader(private val repository: ConversationSettingsRepository) { +class SharedMediaLoader(private val repository: ConversationSettingsRepository) : RecentMediaRailPresenter.Loader { - private val refreshTrigger = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } - private val threadId = MutableStateFlow(null) + @Volatile + private var records: List = emptyList() - fun onThreadIdLoaded(threadId: Long) { - this.threadId.value = threadId + override suspend fun load(sourceId: Long): List { + val loaded = repository.getSharedMedia(sourceId, SHARED_MEDIA_LIMIT) + records = loaded + return loaded.map { it.toRecentMedia() } } - fun refresh() { - refreshTrigger.tryEmit(Unit) - } + fun recordAt(index: Int): MediaTable.MediaRecord? = records.getOrNull(index) +} - fun observe(): Flow> { - return combine(threadId.filterNotNull().distinctUntilChanged(), refreshTrigger) { id, _ -> id } - .map { repository.getSharedMedia(it, SHARED_MEDIA_LIMIT) } +private fun MediaTable.MediaRecord.toRecentMedia(): RecentMedia { + return RecentMedia( + thumbnailUri = attachment?.displayUri, + availability = availability(), + thumbnailTimeUs = attachment?.transformProperties?.videoTrimStartTimeUs ?: 0 + ) +} + +/** Whether the attachment behind this record is actually here yet, and if not, whether we can go get it. */ +private fun MediaTable.MediaRecord.availability(): Availability { + val attachment = this.attachment + + return when { + attachment == null -> { + Availability.UNAVAILABLE + } + attachment.displayUri == null -> { + if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { + Availability.RESTORABLE + } else { + Availability.UNAVAILABLE + } + } + attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && + attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED -> { + Availability.UNAVAILABLE + } + else -> { + Availability.AVAILABLE + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt index 9dc42caa44..c668a9ad95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt @@ -5,42 +5,27 @@ package org.thoughtcrime.securesms.components.settings.conversation.shared -import android.view.View -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.bumptech.glide.Glide import org.signal.core.ui.compose.Dividers import org.signal.core.ui.compose.Rows import org.signal.core.ui.compose.Texts +import org.signal.uicomponents.recentmediarail.RecentMediaRail +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents +import org.signal.uicomponents.recentmediarail.RecentMediaRailState import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ThreadPhotoRailView -import org.thoughtcrime.securesms.database.MediaTable - -private val RAIL_HEIGHT = 80.dp /** * The shared media rail and its "see all" row, which every conversation type shows. * - * The rail is a fixed height and stays in the layout while [loaded] is false, so that media arriving later fills in - * space that was already there instead of shoving the rest of the screen down. It's only dropped once we know the chat - * has no media at all. + * Dropped entirely once we know the chat has no media at all -- until then the rail keeps its space, so media arriving + * later doesn't shove the rest of the screen down. */ fun LazyListScope.sharedMediaSection( - media: List, - loaded: Boolean, - onMediaClick: (MediaTable.MediaRecord, Boolean) -> Unit, - onMediaViewClicked: (View) -> Unit, - onSeeAllClick: () -> Unit + state: RecentMediaRailState, + onEvent: (RecentMediaRailEvents) -> Unit ) { - if (loaded && media.isEmpty()) { + if (!state.visible) { return } @@ -49,40 +34,16 @@ fun LazyListScope.sharedMediaSection( item { Texts.SectionHeader(text = stringResource(R.string.recipient_preference_activity__shared_media)) } item { - SharedMediaRail( - media = media, - onMediaClick = onMediaClick, - onMediaViewClicked = onMediaViewClicked + RecentMediaRail( + state = state, + onEvent = onEvent ) } item { Rows.TextRow( text = stringResource(R.string.ConversationSettingsFragment__see_all), - onClick = onSeeAllClick + onClick = { onEvent(RecentMediaRailEvents.SeeAllClicked) } ) } } - -@Composable -private fun SharedMediaRail( - media: List, - onMediaClick: (MediaTable.MediaRecord, Boolean) -> Unit, - onMediaViewClicked: (View) -> Unit, - modifier: Modifier = Modifier -) { - val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr - - AndroidView( - factory = { context -> ThreadPhotoRailView(context) }, - modifier = modifier - .fillMaxWidth() - .height(RAIL_HEIGHT) - ) { railView -> - railView.setListener { view, mediaRecord -> - onMediaViewClicked(view) - onMediaClick(mediaRecord, isLtr) - } - railView.setMediaRecords(Glide.with(railView), media) - } -} diff --git a/app/src/main/res/layout/recipient_preference_photo_rail.xml b/app/src/main/res/layout/recipient_preference_photo_rail.xml deleted file mode 100644 index f384f10702..0000000000 --- a/app/src/main/res/layout/recipient_preference_photo_rail.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/recipient_preference_photo_rail_item.xml b/app/src/main/res/layout/recipient_preference_photo_rail_item.xml deleted file mode 100644 index f1f176808e..0000000000 --- a/app/src/main/res/layout/recipient_preference_photo_rail_item.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt index f22fdb4392..bbc8e69af2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.group +import androidx.compose.ui.unit.IntRect import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -28,6 +29,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails @@ -601,17 +603,18 @@ class GroupSettingsViewModelTest { val viewModel = createViewModel() val actions = collectActions(viewModel) - viewModel.onEvent(GroupSettingsEvent.SeeAllSharedMediaClicked) + viewModel.onEvent(GroupSettingsEvent.MediaRailEvent(RecentMediaRailEvents.SeeAllClicked)) assertEquals(ConversationSettingsAction.ShowMediaOverview(THREAD_ID), actions.single()) } @Test fun `shared media click reports the media is not sent yet when there is no attachment`() = runTest(testDispatcher) { + coEvery { repository.getSharedMedia(any(), any()) } returns listOf(mediaRecord()) val viewModel = createViewModel() val actions = collectActions(viewModel) - viewModel.onEvent(GroupSettingsEvent.SharedMediaClicked(mediaRecord(), isLtr = true)) + viewModel.onEvent(GroupSettingsEvent.MediaRailEvent(RecentMediaRailEvents.ItemClicked(index = 0, bounds = IntRect.Zero, leftToRight = true))) assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt index bb7cf6c714..b2c185ce12 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.individual +import androidx.compose.ui.unit.IntRect import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -30,6 +31,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.signal.core.models.database.AttachmentId +import org.signal.uicomponents.recentmediarail.RecentMediaRailEvents import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -89,8 +91,11 @@ class IndividualSettingsViewModelTest { private fun createViewModel( callMessageIds: LongArray = longArrayOf(), - kind: ConversationSettingsKind = ConversationSettingsKind.INDIVIDUAL + kind: ConversationSettingsKind = ConversationSettingsKind.INDIVIDUAL, + sharedMedia: List = emptyList() ): IndividualSettingsViewModel { + coEvery { repository.getSharedMedia(any(), any()) } returns sharedMedia + return IndividualSettingsViewModel( recipientId = RECIPIENT_ID, kind = kind, @@ -178,7 +183,7 @@ class IndividualSettingsViewModelTest { val viewModel = createViewModel() assertEquals(THREAD_ID, viewModel.state.value.threadId) - assertTrue(viewModel.state.value.sharedMediaLoaded) + assertTrue(viewModel.state.value.mediaRail.loaded) assertTrue(viewModel.state.value.isLoaded) } @@ -379,7 +384,7 @@ class IndividualSettingsViewModelTest { val viewModel = createViewModel() val actions = collectActions(viewModel) - viewModel.onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) + viewModel.onEvent(IndividualSettingsEvent.MediaRailEvent(RecentMediaRailEvents.SeeAllClicked)) assertEquals(ConversationSettingsAction.ShowMediaOverview(THREAD_ID), actions.single()) } @@ -387,32 +392,31 @@ class IndividualSettingsViewModelTest { @Test fun `shared media click shows the preview for a downloaded attachment`() = runTest(testDispatcher) { val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_PROGRESS_DONE, hasUri = true)) - val viewModel = createViewModel() + val viewModel = createViewModel(sharedMedia = listOf(record)) val actions = collectActions(viewModel) - viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + viewModel.onEvent(railItemClicked()) - assertEquals(ConversationSettingsAction.ShowMediaPreview(record, true), actions.single()) + assertEquals(ConversationSettingsAction.ShowMediaPreview(record, true, RAIL_ITEM_BOUNDS), actions.single()) } @Test fun `shared media click downloads offloaded media that has no local file`() = runTest(testDispatcher) { val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED, hasUri = false)) - val viewModel = createViewModel() + val viewModel = createViewModel(sharedMedia = listOf(record)) val actions = collectActions(viewModel) - viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + viewModel.onEvent(railItemClicked()) assertEquals(ConversationSettingsAction.DownloadMedia(record), actions.single()) } @Test fun `shared media click reports the media is not sent yet when there is no attachment`() = runTest(testDispatcher) { - val record = mediaRecord(null) - val viewModel = createViewModel() + val viewModel = createViewModel(sharedMedia = listOf(mediaRecord(null))) val actions = collectActions(viewModel) - viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + viewModel.onEvent(railItemClicked()) assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) } @@ -420,12 +424,22 @@ class IndividualSettingsViewModelTest { @Test fun `shared media click reports the media is not sent yet when it is still in flight`() = runTest(testDispatcher) { val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_PROGRESS_STARTED, hasUri = true)) + val viewModel = createViewModel(sharedMedia = listOf(record)) + val actions = collectActions(viewModel) + + viewModel.onEvent(railItemClicked()) + + assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) + } + + @Test + fun `shared media click is ignored when the rail no longer has that item`() = runTest(testDispatcher) { val viewModel = createViewModel() val actions = collectActions(viewModel) - viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + viewModel.onEvent(railItemClicked()) - assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) + assertTrue(actions.isEmpty()) } @Test @@ -739,6 +753,7 @@ class IndividualSettingsViewModelTest { private companion object { val RECIPIENT_ID: RecipientId = RecipientId.from(1L) const val THREAD_ID = 5L + val RAIL_ITEM_BOUNDS = IntRect(left = 16, top = 100, right = 96, bottom = 180) fun individual( isBlocked: Boolean = false, @@ -775,6 +790,11 @@ class IndividualSettingsViewModelTest { } } + /** The first rail item being tapped, which is all these tests ever need. */ + fun railItemClicked(): IndividualSettingsEvent.MediaRailEvent { + return IndividualSettingsEvent.MediaRailEvent(RecentMediaRailEvents.ItemClicked(index = 0, bounds = RAIL_ITEM_BOUNDS, leftToRight = true)) + } + fun mediaRecord(attachment: DatabaseAttachment?): MediaTable.MediaRecord { return MediaTable.MediaRecord( attachment = attachment, diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/EventDrivenPresenter.kt b/core/ui/src/main/java/org/signal/core/ui/compose/EventDrivenPresenter.kt new file mode 100644 index 0000000000..1d10eafa86 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/EventDrivenPresenter.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log + +/** + * Base presenter that helps one implement an Elm-like architecture, where events are processed and + * new models are emitted. In particular, this base class exists to setup the core event channel + * to avoid gotcha's around threading and race conditions. + */ +abstract class EventDrivenPresenter( + private val tag: String, + coroutineScope: CoroutineScope +) { + + private val eventChannel = Channel(Channel.UNLIMITED) + + init { + coroutineScope.launch { + for (event in eventChannel) { + Log.d(tag, "[Event] $event") + processEvent(event) + } + } + } + + fun onEvent(event: E) { + // Unlimited buffer means this will always succeed + eventChannel.trySend(event) + } + + /** + * Handle the event how you wish. It's recommended that you use the event to emit a new state model + * to be observed by the view. + */ + protected abstract suspend fun processEvent(event: E) +} diff --git a/lib/ui-components/build.gradle.kts b/lib/ui-components/build.gradle.kts new file mode 100644 index 0000000000..9a443e4f41 --- /dev/null +++ b/lib/ui-components/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("signal-library") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "org.signal.uicomponents" + + buildFeatures { + compose = true + } +} + +dependencies { + lintChecks(project(":lintchecks")) + + api(project(":core:ui")) + + implementation(project(":lib:glide")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material3) +} diff --git a/lib/ui-components/src/main/AndroidManifest.xml b/lib/ui-components/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/lib/ui-components/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRail.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRail.kt new file mode 100644 index 0000000000..0467c61ca3 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRail.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.uicomponents.recentmediarail + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.glide.compose.GlideImage +import org.signal.glide.compose.GlideImageScaleType +import org.signal.glide.decryptableuri.DecryptableUri +import org.signal.core.ui.R as CoreUiR + +private val ITEM_SIZE = 80.dp +private val ITEM_SPACING = 8.dp +private val ITEM_CORNERS = RoundedCornerShape(12.dp) +private val ITEM_SCRIM = Color(0x14000000) + +/** + * A horizontally scrolling strip of media thumbnails, most recent first. + * + * Driven entirely by a [RecentMediaRailPresenter], which owns the state handed in here and decides what the events sent + * back out of here actually do. + */ +@Composable +fun RecentMediaRail( + state: RecentMediaRailState, + onEvent: (RecentMediaRailEvents) -> Unit, + modifier: Modifier = Modifier +) { + val leftToRight = LocalLayoutDirection.current == LayoutDirection.Ltr + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(ITEM_SPACING), + contentPadding = PaddingValues(horizontal = dimensionResource(CoreUiR.dimen.gutter)), + modifier = modifier + .fillMaxWidth() + .height(ITEM_SIZE) + ) { + itemsIndexed(state.media) { index, media -> + RecentMediaRailItem( + media = media, + onClick = { bounds -> onEvent(RecentMediaRailEvents.ItemClicked(index, bounds, leftToRight)) } + ) + } + } +} + +@Composable +private fun RecentMediaRailItem( + media: RecentMedia, + onClick: (IntRect) -> Unit +) { + var coordinates by remember { mutableStateOf(null) } + + Box( + modifier = Modifier + .size(ITEM_SIZE) + .clip(ITEM_CORNERS) + .background(MaterialTheme.colorScheme.surfaceVariant) + .onGloballyPositioned { coordinates = it } + .clickable { onClick(coordinates?.boundsInWindow()?.roundToIntRect() ?: IntRect.Zero) } + ) { + GlideImage( + model = remember(media) { media.thumbnailUri?.let { DecryptableUri(it, media.thumbnailTimeUs) } }, + imageSize = DpSize(ITEM_SIZE, ITEM_SIZE), + scaleType = GlideImageScaleType.CENTER_CROP, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(ITEM_SCRIM) + ) + } +} + +@DayNightPreviews +@Composable +private fun RecentMediaRailPreview() { + Previews.Preview { + RecentMediaRail( + state = RecentMediaRailState( + media = List(5) { RecentMedia(thumbnailUri = Uri.EMPTY, availability = RecentMedia.Availability.AVAILABLE) }, + loaded = true + ), + onEvent = {} + ) + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailAction.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailAction.kt new file mode 100644 index 0000000000..cddf5fa707 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailAction.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.uicomponents.recentmediarail + +import androidx.compose.ui.unit.IntRect + +/** + * Side effects that can be emitted by [RecentMediaRailPresenter] that need to be handled by the user of the component. + * + * Every [index] is a position in [RecentMediaRailState.media]. + */ +sealed interface RecentMediaRailAction { + + /** Show the media the user tapped, animating out of [bounds] (window coordinates). */ + data class OpenMedia(val index: Int, val bounds: IntRect, val leftToRight: Boolean) : RecentMediaRailAction + + /** Download the media the user tapped, since it isn't on the device yet. */ + data class DownloadMedia(val index: Int) : RecentMediaRailAction + + /** Tell the user the media they tapped isn't available. */ + data object ShowMediaUnavailable : RecentMediaRailAction + + /** Open the full list of media this rail is a preview of. */ + data object OpenAllMedia : RecentMediaRailAction +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailEvents.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailEvents.kt new file mode 100644 index 0000000000..e4562650fa --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailEvents.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.uicomponents.recentmediarail + +import androidx.compose.ui.unit.IntRect + +/** + * Everything a [RecentMediaRailPresenter] can be told, whether it came from the rail itself or from the screen hosting + * it. + * + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface RecentMediaRailEvents { + + /** The rail has something to load from. Each distinct [sourceId] kicks off a fresh load. */ + data class SourceChanged(val sourceId: Long) : RecentMediaRailEvents + + /** Reload the current source, e.g. after the user came back from the media viewer. */ + data object RefreshRequested : RecentMediaRailEvents + + /** + * The user tapped an item. + * + * @param index Position in [RecentMediaRailState.media]. + * @param bounds The item's bounds in window coordinates, for hosts that want to animate out of it. + * @param leftToRight Whether the rail was laid out left to right, which decides which way the media viewer pages. + */ + data class ItemClicked(val index: Int, val bounds: IntRect, val leftToRight: Boolean) : RecentMediaRailEvents + + /** The user asked to see everything the rail is a preview of. */ + data object SeeAllClicked : RecentMediaRailEvents +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailPresenter.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailPresenter.kt new file mode 100644 index 0000000000..494f12bce8 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailPresenter.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.uicomponents.recentmediarail + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import org.signal.core.ui.compose.EventDrivenPresenter +import org.signal.core.util.logging.Log +import org.signal.uicomponents.recentmediarail.RecentMedia.Availability + +/** + * All of the logic behind a [RecentMediaRail]: loading it, keeping it up to date, and deciding what a tap on it should + * do. + * + * Meant to be held by the view model of whichever screen shows the rail, which feeds it events, mirrors [state] into + * its own state, and carries out [actions]. + */ +class RecentMediaRailPresenter( + coroutineScope: CoroutineScope, + private val loader: Loader +) : EventDrivenPresenter(TAG, coroutineScope) { + + companion object { + private val TAG = Log.tag(RecentMediaRailPresenter::class) + } + + private val _state = MutableStateFlow(RecentMediaRailState()) + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + private var sourceId: Long? = null + + override suspend fun processEvent(event: RecentMediaRailEvents) { + when (event) { + is RecentMediaRailEvents.SourceChanged -> { + if (sourceId != event.sourceId) { + sourceId = event.sourceId + load() + } + } + RecentMediaRailEvents.RefreshRequested -> { + load() + } + is RecentMediaRailEvents.ItemClicked -> { + val media = _state.value.media.getOrNull(event.index) + + val action = when (media?.availability) { + Availability.AVAILABLE -> RecentMediaRailAction.OpenMedia(event.index, event.bounds, event.leftToRight) + Availability.RESTORABLE -> RecentMediaRailAction.DownloadMedia(event.index) + Availability.UNAVAILABLE -> RecentMediaRailAction.ShowMediaUnavailable + null -> null + } + + if (action != null) { + _actions.send(action) + } + } + RecentMediaRailEvents.SeeAllClicked -> { + _actions.send(RecentMediaRailAction.OpenAllMedia) + } + } + } + + private suspend fun load() { + val id = sourceId ?: return + _state.value = RecentMediaRailState(media = loader.load(id), loaded = true) + } + + /** Where the rail's contents come from. */ + fun interface Loader { + suspend fun load(sourceId: Long): List + } +} diff --git a/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailState.kt b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailState.kt new file mode 100644 index 0000000000..b465f479e0 --- /dev/null +++ b/lib/ui-components/src/main/java/org/signal/uicomponents/recentmediarail/RecentMediaRailState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.uicomponents.recentmediarail + +import android.net.Uri + +/** + * State of a [RecentMediaRail]. Owned by a [RecentMediaRailPresenter] and expected to be mirrored into the state of + * whatever screen the rail sits in. + */ +data class RecentMediaRailState( + val media: List = emptyList(), + val loaded: Boolean = false +) { + + /** + * Whether the rail belongs in the layout at all. It stays put while [loaded] is false so that media arriving later + * fills space that was already there instead of shoving the rest of the screen down, and is only dropped once we know + * there's nothing to show. + */ + val visible: Boolean = !loaded || media.isNotEmpty() +} + +/** A single thumbnail in a [RecentMediaRail]. */ +data class RecentMedia( + val thumbnailUri: Uri?, + val availability: Availability, + /** Which frame of a video the thumbnail should show, for media that was trimmed before it was sent. */ + val thumbnailTimeUs: Long = 0 +) { + + /** Whether tapping an item can show it, and if not, what to do instead. */ + enum class Availability { + /** On the device and ready to view. */ + AVAILABLE, + + /** Offloaded, and needs to be downloaded before it can be viewed. */ + RESTORABLE, + + /** Not here, and not something we can go get. */ + UNAVAILABLE + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 073fdab7a9..c3e568a2c8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -124,6 +124,7 @@ include(":lib:blurhash") include(":lib:apng") include(":lib:emoji") include(":lib:archive") +include(":lib:ui-components") // Feature modules include(":feature:registration")