diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt index 9ba6ad2fc5..6df9886a66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryViewerArgs.kt @@ -20,7 +20,8 @@ data class StoryViewerArgs( val recipientIds: List = emptyList(), val isFromNotification: Boolean = false, val groupReplyStartPosition: Int = -1, - val isUnviewedOnly: Boolean = false + val isUnviewedOnly: Boolean = false, + val isFromInfoContextMenuAction: Boolean = false ) : Parcelable { class Builder(private val recipientId: RecipientId, private val isInHiddenStoryMode: Boolean) { @@ -33,6 +34,7 @@ data class StoryViewerArgs( private var isFromNotification: Boolean = false private var groupReplyStartPosition: Int = -1 private var isUnviewedOnly: Boolean = false + private var isFromInfoContextMenuAction: Boolean = false fun withStoryId(storyId: Long): Builder { this.storyId = storyId @@ -85,7 +87,8 @@ data class StoryViewerArgs( recipientIds = recipientIds, isFromNotification = isFromNotification, groupReplyStartPosition = groupReplyStartPosition, - isUnviewedOnly = isUnviewedOnly + isUnviewedOnly = isUnviewedOnly, + isFromInfoContextMenuAction = isFromInfoContextMenuAction ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt index 6eae421fd7..a0b356ac82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.stories.landing.StoriesLandingItem -import org.thoughtcrime.securesms.stories.my.MyStoriesItem import org.thoughtcrime.securesms.stories.viewer.page.StoryPost import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageState import org.thoughtcrime.securesms.util.DeleteDialog @@ -80,6 +79,7 @@ object StoryContextMenu { fun show( context: Context, anchorView: View, + previewView: View, model: StoriesLandingItem.Model, onDismiss: () -> Unit ) { @@ -99,6 +99,7 @@ object StoryContextMenu { override fun onDismissed() = onDismiss() override fun onDelete() = model.onDeleteStory(model) override fun onSave() = model.onSave(model) + override fun onInfo() = model.onInfo(model, previewView) } ) } @@ -113,6 +114,7 @@ object StoryContextMenu { onGoToChat: (StoryPost) -> Unit, onSave: (StoryPost) -> Unit, onDelete: (StoryPost) -> Unit, + onInfo: (StoryPost) -> Unit, onDismiss: () -> Unit ) { val selectedStory: StoryPost = storyViewerPageState.posts[storyViewerPageState.selectedPostIndex] @@ -132,32 +134,7 @@ object StoryContextMenu { override fun onDismissed() = onDismiss() override fun onSave() = onSave(selectedStory) override fun onDelete() = onDelete(selectedStory) - } - ) - } - - fun show( - context: Context, - anchorView: View, - myStoriesItemModel: MyStoriesItem.Model, - onDismiss: () -> Unit - ) { - show( - context = context, - anchorView = anchorView, - isFromSelf = true, - isToGroup = false, - isFromReleaseChannel = false, - canHide = false, - callbacks = object : Callbacks { - override fun onHide() = throw NotImplementedError() - override fun onUnhide() = throw NotImplementedError() - override fun onForward() = myStoriesItemModel.onForwardClick(myStoriesItemModel) - override fun onShare() = myStoriesItemModel.onShareClick(myStoriesItemModel) - override fun onGoToChat() = throw NotImplementedError() - override fun onDismissed() = onDismiss() - override fun onSave() = myStoriesItemModel.onSaveClick(myStoriesItemModel) - override fun onDelete() = myStoriesItemModel.onDeleteClick(myStoriesItemModel) + override fun onInfo() = onInfo(selectedStory) } ) } @@ -219,6 +196,12 @@ object StoryContextMenu { } ) } + + add( + ActionItem(R.drawable.ic_info_outline_message_details_24, context.getString(R.string.StoriesLandingItem__info)) { + callbacks.onInfo() + } + ) } SignalContextMenu.Builder(anchorView, rootView) @@ -240,5 +223,6 @@ object StoryContextMenu { fun onDismissed() fun onSave() fun onDelete() + fun onInfo() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index a42a87ae17..d724bd3b55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -207,46 +207,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l return StoriesLandingItem.Model( data = data, onRowClick = { model, preview -> - if (model.data.storyRecipient.isMyStory) { - startActivityIfAble(Intent(requireContext(), MyStoriesActivity::class.java)) - } else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) { - if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) { - SafetyNumberBottomSheet - .forMessageRecord(requireContext(), model.data.primaryStory.messageRecord) - .show(childFragmentManager) - } else { - StoryDialogs.resendStory(requireContext()) { - lifecycleDisposable += viewModel.resend(model.data.primaryStory.messageRecord).subscribe() - } - } - } else { - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") - - val record = model.data.primaryStory.messageRecord as MmsMessageRecord - val blur = record.slideDeck.thumbnailSlide?.placeholderBlur - val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { - StoryTextPostModel.parseFrom(record) to null - } else { - null to record.slideDeck.thumbnailSlide?.uri - } - - startActivityIfAble( - StoryViewerActivity.createIntent( - context = requireContext(), - storyViewerArgs = StoryViewerArgs( - recipientId = model.data.storyRecipient.id, - storyId = -1L, - isInHiddenStoryMode = model.data.isHidden, - storyThumbTextModel = text, - storyThumbUri = image, - storyThumbBlur = blur, - recipientIds = viewModel.getRecipientIds(model.data.isHidden, model.data.storyViewState == StoryViewState.UNVIEWED), - isUnviewedOnly = model.data.storyViewState == StoryViewState.UNVIEWED - ) - ), - options.toBundle() - ) - } + openStoryViewer(model, preview, false) }, onForwardStory = { MultiselectForwardFragmentArgs.create(requireContext(), it.data.primaryStory.multiselectCollection.toSet()) { args -> @@ -271,10 +232,57 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l }, onDeleteStory = { handleDeleteStory(it) + }, + onInfo = { model, preview -> + openStoryViewer(model, preview, true) } ) } + private fun openStoryViewer(model: StoriesLandingItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) { + if (model.data.storyRecipient.isMyStory) { + startActivityIfAble(Intent(requireContext(), MyStoriesActivity::class.java)) + } else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) { + if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) { + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), model.data.primaryStory.messageRecord) + .show(childFragmentManager) + } else { + StoryDialogs.resendStory(requireContext()) { + lifecycleDisposable += viewModel.resend(model.data.primaryStory.messageRecord).subscribe() + } + } + } else { + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") + + val record = model.data.primaryStory.messageRecord as MmsMessageRecord + val blur = record.slideDeck.thumbnailSlide?.placeholderBlur + val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { + StoryTextPostModel.parseFrom(record) to null + } else { + null to record.slideDeck.thumbnailSlide?.uri + } + + startActivityIfAble( + StoryViewerActivity.createIntent( + context = requireContext(), + storyViewerArgs = StoryViewerArgs( + recipientId = model.data.storyRecipient.id, + storyId = -1L, + isInHiddenStoryMode = model.data.isHidden, + storyThumbTextModel = text, + storyThumbUri = image, + storyThumbBlur = blur, + recipientIds = viewModel.getRecipientIds(model.data.isHidden, model.data.storyViewState == StoryViewState.UNVIEWED), + isUnviewedOnly = model.data.storyViewState == StoryViewState.UNVIEWED, + isFromInfoContextMenuAction = isFromInfoContextMenuAction + ) + ), + options.toBundle() + ) + } + } + private fun handleDeleteStory(model: StoriesLandingItem.Model) { lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(model.data.primaryStory.messageRecord)).subscribe() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index 71821fe412..cec8f2e3ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -49,7 +49,8 @@ object StoriesLandingItem { val onShareStory: (Model) -> Unit, val onGoToChat: (Model) -> Unit, val onSave: (Model) -> Unit, - val onDeleteStory: (Model) -> Unit + val onDeleteStory: (Model) -> Unit, + val onInfo: (Model, View) -> Unit ) : MappingModel { override fun areItemsTheSame(newItem: Model): Boolean { return data.storyRecipient.id == newItem.data.storyRecipient.id @@ -267,7 +268,7 @@ object StoriesLandingItem { private fun displayContext(model: Model) { itemView.isSelected = true - StoryContextMenu.show(context, itemView, model) { itemView.isSelected = false } + StoryContextMenu.show(context, itemView, storyPreview, model) { itemView.isSelected = false } } private fun clearGlide() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index 26eeb0c55f..952ce5a7cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -78,47 +78,7 @@ class MyStoriesFragment : DSLSettingsFragment( MyStoriesItem.Model( distributionStory = conversationMessage, onClick = { it, preview -> - if (it.distributionStory.messageRecord.isOutgoing && it.distributionStory.messageRecord.isFailed) { - if (it.distributionStory.messageRecord.isIdentityMismatchFailure) { - SafetyNumberBottomSheet - .forMessageRecord(requireContext(), it.distributionStory.messageRecord) - .show(childFragmentManager) - } else { - StoryDialogs.resendStory(requireContext()) { - lifecycleDisposable += viewModel.resend(it.distributionStory.messageRecord).subscribe() - } - } - } else { - val recipient = if (it.distributionStory.messageRecord.recipient.isGroup) { - it.distributionStory.messageRecord.recipient - } else { - Recipient.self() - } - - val record = it.distributionStory.messageRecord as MmsMessageRecord - val blur = record.slideDeck.thumbnailSlide?.placeholderBlur - val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { - StoryTextPostModel.parseFrom(record) to null - } else { - null to record.slideDeck.thumbnailSlide?.uri - } - - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") - startActivity( - StoryViewerActivity.createIntent( - context = requireContext(), - storyViewerArgs = StoryViewerArgs( - recipientId = recipient.id, - storyId = conversationMessage.messageRecord.id, - isInHiddenStoryMode = recipient.shouldHideStory(), - storyThumbTextModel = text, - storyThumbUri = image, - storyThumbBlur = blur - ) - ), - options.toBundle() - ) - } + openStoryViewer(it, preview, false) }, onLongClick = { Util.copyToClipboard(requireContext(), it.distributionStory.messageRecord.timestamp.toString()) @@ -139,6 +99,9 @@ class MyStoriesFragment : DSLSettingsFragment( }, onShareClick = { StoryContextMenu.share(this@MyStoriesFragment, it.distributionStory.messageRecord as MediaMmsMessageRecord) + }, + onInfoClick = { model, preview -> + openStoryViewer(model, preview, true) } ) ) @@ -151,6 +114,51 @@ class MyStoriesFragment : DSLSettingsFragment( } } + private fun openStoryViewer(it: MyStoriesItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) { + if (it.distributionStory.messageRecord.isOutgoing && it.distributionStory.messageRecord.isFailed) { + if (it.distributionStory.messageRecord.isIdentityMismatchFailure) { + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), it.distributionStory.messageRecord) + .show(childFragmentManager) + } else { + StoryDialogs.resendStory(requireContext()) { + lifecycleDisposable += viewModel.resend(it.distributionStory.messageRecord).subscribe() + } + } + } else { + val recipient = if (it.distributionStory.messageRecord.recipient.isGroup) { + it.distributionStory.messageRecord.recipient + } else { + Recipient.self() + } + + val record = it.distributionStory.messageRecord as MmsMessageRecord + val blur = record.slideDeck.thumbnailSlide?.placeholderBlur + val (text: StoryTextPostModel?, image: Uri?) = if (record.storyType.isTextStory) { + StoryTextPostModel.parseFrom(record) to null + } else { + null to record.slideDeck.thumbnailSlide?.uri + } + + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), preview, ViewCompat.getTransitionName(preview) ?: "") + startActivity( + StoryViewerActivity.createIntent( + context = requireContext(), + storyViewerArgs = StoryViewerArgs( + recipientId = recipient.id, + storyId = it.distributionStory.messageRecord.id, + isInHiddenStoryMode = recipient.shouldHideStory(), + storyThumbTextModel = text, + storyThumbUri = image, + storyThumbBlur = blur, + isFromInfoContextMenuAction = isFromInfoContextMenuAction + ) + ), + options.toBundle() + ) + } + } + private fun handleDeleteClick(model: MyStoriesItem.Model) { lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(model.distributionStory.messageRecord)).subscribe() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt index e087eeb273..18c9a23b13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesItem.kt @@ -42,7 +42,8 @@ object MyStoriesItem { val onSaveClick: (Model) -> Unit, val onDeleteClick: (Model) -> Unit, val onForwardClick: (Model) -> Unit, - val onShareClick: (Model) -> Unit + val onShareClick: (Model) -> Unit, + val onInfoClick: (Model, View) -> Unit ) : PreferenceModel() { override fun areItemsTheSame(newItem: Model): Boolean { return distributionStory.messageRecord.id == newItem.distributionStory.messageRecord.id @@ -173,7 +174,8 @@ object MyStoriesItem { ActionItem(R.drawable.ic_delete_24_tinted, context.getString(R.string.delete)) { model.onDeleteClick(model) }, ActionItem(R.drawable.ic_download_24_tinted, context.getString(R.string.save)) { model.onSaveClick(model) }, ActionItem(R.drawable.ic_forward_24_tinted, context.getString(R.string.MyStories_forward)) { model.onForwardClick(model) }, - ActionItem(R.drawable.ic_share_24_tinted, context.getString(R.string.StoriesLandingItem__share)) { model.onShareClick(model) } + ActionItem(R.drawable.ic_share_24_tinted, context.getString(R.string.StoriesLandingItem__share)) { model.onShareClick(model) }, + ActionItem(R.drawable.ic_info_outline_message_details_24, context.getString(R.string.StoriesLandingItem__info)) { model.onInfoClick(model, storyPreview) } ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt index 92cba4a056..aead349bc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -50,7 +50,8 @@ class StoryViewerFragment : storyViewerArgs.storyId, storyViewerArgs.isFromNotification, storyViewerArgs.groupReplyStartPosition, - storyViewerArgs.isUnviewedOnly + storyViewerArgs.isUnviewedOnly, + storyViewerArgs.isFromInfoContextMenuAction ) storyPager.adapter = adapter diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt index 8549ca8569..ef07d33d16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerPagerAdapter.kt @@ -12,7 +12,8 @@ class StoryViewerPagerAdapter( private val initialStoryId: Long, private val isFromNotification: Boolean, private val groupReplyStartPosition: Int, - private val isUnviewedOnly: Boolean + private val isUnviewedOnly: Boolean, + private val isFromInfoContextMenuAction: Boolean ) : FragmentStateAdapter(fragment) { private var pages: List = emptyList() @@ -33,7 +34,7 @@ class StoryViewerPagerAdapter( override fun getItemCount(): Int = pages.size override fun createFragment(position: Int): Fragment { - return StoryViewerPageFragment.create(pages[position], initialStoryId, isFromNotification, groupReplyStartPosition, isUnviewedOnly) + return StoryViewerPageFragment.create(pages[position], initialStoryId, isFromNotification, groupReplyStartPosition, isUnviewedOnly, isFromInfoContextMenuAction) } private class Callback( diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..59c5de075b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoBottomSheetDialogFragment.kt @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +import android.content.DialogInterface +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter +import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.fragments.findListener + +/** + * Bottom sheet which displays receipt information to the user for a given story. + */ +class StoryInfoBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() { + + override val peekHeightPercentage: Float = 0.5f + + companion object { + private const val STORY_ID = "args.story.id" + + fun create(storyId: Long): StoryInfoBottomSheetDialogFragment { + return StoryInfoBottomSheetDialogFragment().apply { + arguments = bundleOf(STORY_ID to storyId) + } + } + } + + private val storyId: Long get() = requireArguments().getLong(STORY_ID) + + private val viewModel: StoryInfoViewModel by viewModels(factoryProducer = { + StoryInfoViewModel.Factory(storyId) + }) + + private val lifecycleDisposable = LifecycleDisposable() + + override fun bindAdapter(adapter: DSLSettingsAdapter) { + StoryInfoHeader.register(adapter) + StoryInfoRecipientRow.register(adapter) + + lifecycleDisposable.bindTo(viewLifecycleOwner) + lifecycleDisposable += viewModel.state.subscribe { state -> + if (state.isLoaded) { + adapter.submitList(getConfiguration(state).toMappingModelList()) + } + } + } + + private fun getConfiguration(state: StoryInfoState): DSLConfiguration { + return configure { + customPref( + StoryInfoHeader.Model( + sentMillis = state.sentMillis, + receivedMillis = state.receivedMillis, + size = state.size + ) + ) + + sectionHeaderPref( + title = if (state.isOutgoing) { + R.string.StoryInfoBottomSheetDialogFragment__sent_to + } else { + R.string.StoryInfoBottomSheetDialogFragment__sent_from + } + ) + + state.recipients.forEach { + customPref(it) + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + findListener()?.onInfoSheetDismissed() + } + + interface OnInfoSheetDismissedListener { + fun onInfoSheetDismissed() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoHeader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoHeader.kt new file mode 100644 index 0000000000..0996e4f47c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoHeader.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.visible +import java.util.Locale + +/** + * Holds information around the sent time, received time, and file size of a given story. + */ +object StoryInfoHeader { + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.story_info_header)) + } + + class Model(val sentMillis: Long, val receivedMillis: Long, val size: Long) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean = true + + override fun areContentsTheSame(newItem: Model): Boolean { + return newItem.sentMillis == sentMillis && newItem.receivedMillis == receivedMillis && newItem.size == size + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val sentView: TextView = itemView.findViewById(R.id.story_info_view_sent_label) + private val recvView: TextView = itemView.findViewById(R.id.story_info_view_received_label) + private val sizeView: TextView = itemView.findViewById(R.id.story_info_view_file_size_label) + + override fun bind(model: Model) { + if (model.sentMillis > 0L) { + sentView.visible = true + sentView.text = DateUtils.getTimeString(context, Locale.getDefault(), model.sentMillis) + } else { + sentView.visible = false + } + + if (model.receivedMillis > 0L) { + recvView.visible = true + recvView.text = DateUtils.getTimeString(context, Locale.getDefault(), model.receivedMillis) + } else { + recvView.visible = false + } + + if (model.size > 0L) { + sizeView.visible = true + sizeView.text = Util.getPrettyFileSize(model.size) + } else { + sizeView.visible = false + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRecipientRow.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRecipientRow.kt new file mode 100644 index 0000000000..19c5435a79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRecipientRow.kt @@ -0,0 +1,49 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +import android.view.View +import android.widget.TextView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import java.util.Locale + +/** + * Holds information needed to render a single recipient row in the info sheet. + */ +object StoryInfoRecipientRow { + fun register(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.story_info_recipient_row)) + } + + class Model( + val recipient: Recipient, + val date: Long, + val status: Int + ) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean { + return recipient.id == newItem.recipient.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return recipient.hasSameContent(newItem.recipient) && date == newItem.date + } + } + + private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatarView: AvatarImageView = itemView.findViewById(R.id.story_info_avatar) + private val nameView: TextView = itemView.findViewById(R.id.story_info_display_name) + private val timestampView: TextView = itemView.findViewById(R.id.story_info_timestamp) + + override fun bind(model: Model) { + avatarView.setRecipient(model.recipient) + nameView.text = model.recipient.getDisplayName(context) + timestampView.text = DateUtils.getTimeString(context, Locale.getDefault(), model.date) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRepository.kt new file mode 100644 index 0000000000..2ad567eac0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoRepository.kt @@ -0,0 +1,73 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.GroupReceiptDatabase +import org.thoughtcrime.securesms.database.NoSuchMessageException +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies + +/** + * Gathers necessary message record and receipt data for a given story id. + */ +class StoryInfoRepository { + + companion object { + private val TAG = Log.tag(StoryInfoRepository::class.java) + } + + /** + * Retrieves the StoryInfo for a given ID and emits a new item whenever the underlying + * message record changes. + */ + fun getStoryInfo(storyId: Long): Observable { + return observeMessageRecord(storyId) + .switchMap { record -> + getReceiptInfo(storyId).map { receiptInfo -> + StoryInfo(record, receiptInfo) + }.toObservable() + } + .subscribeOn(Schedulers.io()) + } + + private fun observeMessageRecord(storyId: Long): Observable { + return Observable.create { emitter -> + fun refresh() { + try { + emitter.onNext(SignalDatabase.mms.getMessageRecord(storyId)) + } catch (e: NoSuchMessageException) { + Log.w(TAG, "The story message disappeared. Terminating emission.") + emitter.onComplete() + } + } + + val observer = DatabaseObserver.MessageObserver { + if (it.mms && it.id == storyId) { + refresh() + } + } + + ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(observer) + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) + } + + refresh() + } + } + + private fun getReceiptInfo(storyId: Long): Single> { + return Single.fromCallable { + SignalDatabase.groupReceipts.getGroupReceiptInfo(storyId) + } + } + + /** + * The message record and receipt info for a given story id. + */ + data class StoryInfo(val messageRecord: MessageRecord, val receiptInfo: List) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoState.kt new file mode 100644 index 0000000000..ebfbc731dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoState.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +/** + * Contains the needed information to render the story info sheet. + */ +data class StoryInfoState( + val sentMillis: Long = -1L, + val receivedMillis: Long = -1L, + val size: Long = -1L, + val isOutgoing: Boolean = false, + val recipients: List = emptyList(), + val isLoaded: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoViewModel.kt new file mode 100644 index 0000000000..039f39e33e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/info/StoryInfoViewModel.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.stories.viewer.info + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.rx.RxStore + +/** + * Gathers and stores the StoryInfoState which is used to render the story info sheet. + */ +class StoryInfoViewModel(storyId: Long, repository: StoryInfoRepository = StoryInfoRepository()) : ViewModel() { + + private val store = RxStore(StoryInfoState()) + private val disposables = CompositeDisposable() + + val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + + init { + disposables += store.update(repository.getStoryInfo(storyId).toFlowable(BackpressureStrategy.LATEST)) { storyInfo, storyInfoState -> + storyInfoState.copy( + isLoaded = true, + sentMillis = storyInfo.messageRecord.dateSent, + receivedMillis = storyInfo.messageRecord.dateReceived, + size = (storyInfo.messageRecord as? MmsMessageRecord)?.let { it.slideDeck.firstSlide?.fileSize } ?: -1L, + isOutgoing = storyInfo.messageRecord.isOutgoing, + recipients = buildRecipients(storyInfo) + ) + } + } + + private fun buildRecipients(storyInfo: StoryInfoRepository.StoryInfo): List { + return if (storyInfo.messageRecord.isOutgoing) { + storyInfo.receiptInfo.map { + StoryInfoRecipientRow.Model( + recipient = Recipient.resolved(it.recipientId), + date = it.timestamp, + status = it.status + ) + } + } else { + listOf( + StoryInfoRecipientRow.Model( + recipient = storyInfo.messageRecord.individualRecipient, + date = storyInfo.messageRecord.dateSent, + status = -1 + ) + ) + } + } + + override fun onCleared() { + disposables.clear() + } + + class Factory(private val storyId: Long) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(StoryInfoViewModel(storyId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt index db0091e609..9f4c4772ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerDialog.kt @@ -21,6 +21,7 @@ sealed class StoryViewerDialog(val type: Type) { FORWARD, DELETE, CONTEXT_MENU, - VIEWS_AND_REPLIES + VIEWS_AND_REPLIES, + INFO } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index ac700a5c2a..95868c4509 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -21,6 +21,7 @@ import android.widget.TextView import androidx.cardview.widget.CardView import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.os.bundleOf import androidx.core.view.GestureDetectorCompat import androidx.core.view.animation.PathInterpolatorCompat import androidx.core.view.doOnNextLayout @@ -58,6 +59,7 @@ import org.thoughtcrime.securesms.stories.StoryVolumeOverlayView import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu import org.thoughtcrime.securesms.stories.viewer.StoryViewerViewModel import org.thoughtcrime.securesms.stories.viewer.StoryVolumeViewModel +import org.thoughtcrime.securesms.stories.viewer.info.StoryInfoBottomSheetDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.direct.StoryDirectReplyDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.group.StoryGroupReplyBottomSheetDialogFragment import org.thoughtcrime.securesms.stories.viewer.reply.reaction.OnReactionSentView @@ -86,7 +88,8 @@ class StoryViewerPageFragment : MultiselectForwardBottomSheet.Callback, StorySlateView.Callback, StoryTextPostPreviewFragment.Callback, - StoryFirstTimeNavigationView.Callback { + StoryFirstTimeNavigationView.Callback, + StoryInfoBottomSheetDialogFragment.OnInfoSheetDismissedListener { private val storyVolumeViewModel: StoryVolumeViewModel by viewModels(ownerProducer = { requireActivity() }) @@ -147,6 +150,9 @@ class StoryViewerPageFragment : private val isUnviewedOnly: Boolean get() = requireArguments().getBoolean(ARG_IS_UNVIEWED_ONLY, false) + private val isFromInfoContextMenuAction: Boolean + get() = requireArguments().getBoolean(ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION, false) + @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { callback = requireListener() @@ -380,6 +386,8 @@ class StoryViewerPageFragment : sharedViewModel.consumeInitialState() if (isFromNotification) { startReply(isFromNotification = true, groupReplyStartPosition = groupReplyStartPosition) + } else if (isFromInfoContextMenuAction && state.selectedPostIndex in state.posts.indices) { + showInfo(state.posts[state.selectedPostIndex]) } } } @@ -626,6 +634,11 @@ class StoryViewerPageFragment : replyFragment.showNow(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } + private fun showInfo(storyPost: StoryPost) { + viewModel.setIsDisplayingInfoDialog(true) + StoryInfoBottomSheetDialogFragment.create(storyPost.id).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + private fun markViewedIfAble() { val post = if (viewModel.hasPost()) viewModel.getPost() else null if (post?.content?.transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { @@ -936,6 +949,9 @@ class StoryViewerPageFragment : viewModel.setIsDisplayingDeleteDialog(false) viewModel.refresh() } + }, + onInfo = { + showInfo(it) } ) } @@ -955,16 +971,25 @@ class StoryViewerPageFragment : private const val ARG_IS_FROM_NOTIFICATION = "is_from_notification" private const val ARG_GROUP_REPLY_START_POSITION = "group_reply_start_position" private const val ARG_IS_UNVIEWED_ONLY = "is_unviewed_only" + private const val ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION = "is_from_info_context_menu_action" - fun create(recipientId: RecipientId, initialStoryId: Long, isFromNotification: Boolean, groupReplyStartPosition: Int, isUnviewedOnly: Boolean): Fragment { + fun create( + recipientId: RecipientId, + initialStoryId: Long, + isFromNotification: Boolean, + groupReplyStartPosition: Int, + isUnviewedOnly: Boolean, + isFromInfoContextMenuAction: Boolean + ): Fragment { return StoryViewerPageFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_STORY_RECIPIENT_ID, recipientId) - putLong(ARG_STORY_ID, initialStoryId) - putBoolean(ARG_IS_FROM_NOTIFICATION, isFromNotification) - putInt(ARG_GROUP_REPLY_START_POSITION, groupReplyStartPosition) - putBoolean(ARG_IS_UNVIEWED_ONLY, isUnviewedOnly) - } + arguments = bundleOf( + ARG_STORY_RECIPIENT_ID to recipientId, + ARG_STORY_ID to initialStoryId, + ARG_IS_FROM_NOTIFICATION to isFromNotification, + ARG_GROUP_REPLY_START_POSITION to groupReplyStartPosition, + ARG_IS_UNVIEWED_ONLY to isUnviewedOnly, + ARG_IS_FROM_INFO_CONTEXT_MENU_ACTION to isFromInfoContextMenuAction + ) } } } @@ -1147,4 +1172,8 @@ class StoryViewerPageFragment : SignalStore.storyValues().userHasSeenFirstNavView = true viewModel.setIsDisplayingFirstTimeNavigation(false) } + + override fun onInfoSheetDismissed() { + viewModel.setIsDisplayingInfoDialog(false) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt index 841a0d8e43..5294015a8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageViewModel.kt @@ -61,10 +61,9 @@ class StoryViewerPageViewModel( disposables.clear() disposables += repository.getStoryPostsFor(recipientId, isUnviewedOnly).subscribe { posts -> store.update { state -> - var isDisplayingInitialState = false + val isDisplayingInitialState = state.posts.isEmpty() && posts.isNotEmpty() val startIndex = if (state.posts.isEmpty() && initialStoryId > 0) { val initialIndex = posts.indexOfFirst { it.id == initialStoryId } - isDisplayingInitialState = true initialIndex.takeIf { it > -1 } ?: state.selectedPostIndex } else if (state.posts.isEmpty()) { val initialPost = getNextUnreadPost(posts) @@ -244,6 +243,10 @@ class StoryViewerPageViewModel( storyViewerPlaybackStore.update { it.copy(isDisplayingFirstTimeNavigation = isDisplayingFirstTimeNavigation) } } + fun setIsDisplayingInfoDialog(isDisplayingInfoDialog: Boolean) { + storyViewerPlaybackStore.update { it.copy(isDisplayingInfoDialog = isDisplayingInfoDialog) } + } + private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int): StoryViewerPageState.ReplyState { if (index !in state.posts.indices) { return StoryViewerPageState.ReplyState.NONE diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt index 1c6c47e39f..861570773f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPlaybackState.kt @@ -16,7 +16,8 @@ data class StoryViewerPlaybackState( val isDisplayingLinkPreviewTooltip: Boolean = false, val isDisplayingReactionAnimation: Boolean = false, val isRunningSharedElementAnimation: Boolean = false, - val isDisplayingFirstTimeNavigation: Boolean = false + val isDisplayingFirstTimeNavigation: Boolean = false, + val isDisplayingInfoDialog: Boolean = false ) { val hideChromeImmediate: Boolean = isRunningSharedElementAnimation @@ -38,5 +39,6 @@ data class StoryViewerPlaybackState( isDisplayingLinkPreviewTooltip || isDisplayingReactionAnimation || isRunningSharedElementAnimation || - isDisplayingFirstTimeNavigation + isDisplayingFirstTimeNavigation || + isDisplayingInfoDialog } diff --git a/app/src/main/res/layout/story_info_header.xml b/app/src/main/res/layout/story_info_header.xml new file mode 100644 index 0000000000..84a39efa42 --- /dev/null +++ b/app/src/main/res/layout/story_info_header.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/story_info_recipient_row.xml b/app/src/main/res/layout/story_info_recipient_row.xml new file mode 100644 index 0000000000..2cb87ee8b6 --- /dev/null +++ b/app/src/main/res/layout/story_info_recipient_row.xml @@ -0,0 +1,44 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ca04b36c4..418667be7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4553,6 +4553,8 @@ Share… Go to chat + + Info Sending… @@ -4994,6 +4996,19 @@ Only share with… + + Sent + + Received + + File size + + Sent to + + Sent from + + Info +