Migrate recent media rail to compose.

This commit is contained in:
Greyson Parrelli
2026-08-17 14:36:14 -04:00
committed by Cody Henthorne
parent a20c7f70cb
commit cb8bcba86b
31 changed files with 666 additions and 455 deletions
+22 -19
View File
@@ -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)
@@ -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<MediaTable.MediaRecord> mediaRecords) {
this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), requestManager, mediaRecords, this.listener));
}
private static class ThreadPhotoRailAdapter extends RecyclerView.Adapter<ThreadPhotoRailAdapter.ThreadPhotoViewHolder> {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ThreadPhotoRailAdapter.class);
@NonNull private final RequestManager requestManager;
@Nullable private OnItemClickedListener clickedListener;
private final List<MediaTable.MediaRecord> mediaRecords = new ArrayList<>();
private ThreadPhotoRailAdapter(@NonNull Context context,
@NonNull RequestManager requestManager,
@NonNull List<MediaTable.MediaRecord> 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);
}
}
@@ -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)"
}
@@ -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<NicknameActivity.Args>
@@ -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) }
@@ -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<MediaTable.MediaRecord>) : 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<CallEntry>) : 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
}
@@ -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 = {}
)
}
}
@@ -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<MediaTable.MediaRecord> = emptyList(),
val sharedMediaLoaded: Boolean = false,
val mediaRail: RecentMediaRailState = RecentMediaRailState(),
val calls: List<CallEntry> = emptyList(),
val callBar: CallBarState = CallBarState(),
val title: String = "",
@@ -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<ConversationSettingsAction> = _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) }
}
}
}
@@ -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<MediaTable.MediaRecord>) : 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<CallEntry>) : 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
}
@@ -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 = {}
)
}
}
@@ -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<Recipient> = emptyList(),
val selfHasGroups: Boolean = false,
val groupsInCommonExpanded: Boolean = false,
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
val sharedMediaLoaded: Boolean = false,
val mediaRail: RecentMediaRailState = RecentMediaRailState(),
val calls: List<CallEntry> = emptyList(),
val callBar: CallBarState = CallBarState(),
val dialog: Dialog = Dialog.None
@@ -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<ConversationSettingsAction> = _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) }
}
}
}
@@ -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 = {}
)
}
}
@@ -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 = {}
)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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<Unit>(replay = 1).apply { tryEmit(Unit) }
private val threadId = MutableStateFlow<Long?>(null)
@Volatile
private var records: List<MediaTable.MediaRecord> = emptyList()
fun onThreadIdLoaded(threadId: Long) {
this.threadId.value = threadId
override suspend fun load(sourceId: Long): List<RecentMedia> {
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<List<MediaTable.MediaRecord>> {
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
}
}
}
@@ -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<MediaTable.MediaRecord>,
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<MediaTable.MediaRecord>,
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)
}
}
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/photo_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter"
android:scrollbars="none"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/recipient_preference_photo_rail_item"
tools:orientation="horizontal" />
</merge>
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.SquareFrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
app:square_height="true">
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
app:thumbnail_radius="12dp"
app:transparent_overlay_color="@color/transparent_black_08" />
</org.thoughtcrime.securesms.components.SquareFrameLayout>
@@ -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())
}
@@ -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<MediaTable.MediaRecord> = 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,
@@ -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<E : Any>(
private val tag: String,
coroutineScope: CoroutineScope
) {
private val eventChannel = Channel<E>(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)
}
+23
View File
@@ -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)
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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<LayoutCoordinates?>(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 = {}
)
}
}
@@ -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
}
@@ -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
}
@@ -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<RecentMediaRailEvents>(TAG, coroutineScope) {
companion object {
private val TAG = Log.tag(RecentMediaRailPresenter::class)
}
private val _state = MutableStateFlow(RecentMediaRailState())
private val _actions = Channel<RecentMediaRailAction>(Channel.BUFFERED)
val state: StateFlow<RecentMediaRailState> = _state.asStateFlow()
val actions: Flow<RecentMediaRailAction> = _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<RecentMedia>
}
}
@@ -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<RecentMedia> = 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
}
}
+1
View File
@@ -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")