mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-13 12:07:57 +01:00
Migrate recent media rail to compose.
This commit is contained in:
committed by
Cody Henthorne
parent
a20c7f70cb
commit
cb8bcba86b
+22
-19
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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)"
|
||||
}
|
||||
|
||||
|
||||
+34
-28
@@ -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) }
|
||||
|
||||
+14
-17
@@ -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
|
||||
}
|
||||
|
||||
+3
-9
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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 = "",
|
||||
|
||||
+25
-17
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-17
@@ -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
|
||||
}
|
||||
|
||||
+3
-9
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+27
-17
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-9
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-13
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-22
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-51
@@ -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
-2
@@ -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())
|
||||
}
|
||||
|
||||
+33
-13
@@ -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)
|
||||
}
|
||||
@@ -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" />
|
||||
+124
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+83
@@ -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>
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user