Add info sheet for stories.

fork-5.53.8
Alex Hart 2022-07-14 17:00:15 -03:00 zatwierdzone przez Cody Henthorne
rodzic caab91cdc3
commit 2e8ebe8b74
21 zmienionych plików z 660 dodań i 131 usunięć

Wyświetl plik

@ -20,7 +20,8 @@ data class StoryViewerArgs(
val recipientIds: List<RecipientId> = 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
)
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -50,7 +50,8 @@ class StoryViewerFragment :
storyViewerArgs.storyId,
storyViewerArgs.isFromNotification,
storyViewerArgs.groupReplyStartPosition,
storyViewerArgs.isUnviewedOnly
storyViewerArgs.isUnviewedOnly,
storyViewerArgs.isFromInfoContextMenuAction
)
storyPager.adapter = adapter

Wyświetl plik

@ -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<RecipientId> = 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(

Wyświetl plik

@ -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<OnInfoSheetDismissedListener>()?.onInfoSheetDismissed()
}
interface OnInfoSheetDismissedListener {
fun onInfoSheetDismissed()
}
}

Wyświetl plik

@ -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<Model> {
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<Model>(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
}
}
}
}

Wyświetl plik

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

Wyświetl plik

@ -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<StoryInfo> {
return observeMessageRecord(storyId)
.switchMap { record ->
getReceiptInfo(storyId).map { receiptInfo ->
StoryInfo(record, receiptInfo)
}.toObservable()
}
.subscribeOn(Schedulers.io())
}
private fun observeMessageRecord(storyId: Long): Observable<MessageRecord> {
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<List<GroupReceiptDatabase.GroupReceiptInfo>> {
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<GroupReceiptDatabase.GroupReceiptInfo>)
}

Wyświetl plik

@ -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<StoryInfoRecipientRow.Model> = emptyList(),
val isLoaded: Boolean = false
)

Wyświetl plik

@ -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<StoryInfoState> = 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<StoryInfoRecipientRow.Model> {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(StoryInfoViewModel(storyId)) as T
}
}
}

Wyświetl plik

@ -21,6 +21,7 @@ sealed class StoryViewerDialog(val type: Type) {
FORWARD,
DELETE,
CONTEXT_MENU,
VIEWS_AND_REPLIES
VIEWS_AND_REPLIES,
INFO
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="@dimen/dsl_settings_gutter"
android:paddingTop="16dp"
android:paddingBottom="16dp">
<TextView
android:id="@+id/story_info_view_sent_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/StoryInfoHeader__sent"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/story_info_view_sent_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="@id/story_info_view_sent_heading"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/story_info_view_sent_heading"
app:layout_constraintTop_toTopOf="@id/story_info_view_sent_heading"
tools:text="Today 12:30 PM" />
<TextView
android:id="@+id/story_info_view_received_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/StoryInfoHeader__received"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/story_info_view_sent_heading" />
<TextView
android:id="@+id/story_info_view_received_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="@id/story_info_view_received_heading"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/story_info_view_received_heading"
app:layout_constraintTop_toTopOf="@id/story_info_view_received_heading"
tools:text="Today 12:30 PM" />
<TextView
android:id="@+id/story_info_view_file_size_heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/StoryInfoHeader__file_size"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/story_info_view_received_heading" />
<TextView
android:id="@+id/story_info_view_file_size_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="@id/story_info_view_file_size_heading"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/story_info_view_file_size_heading"
app:layout_constraintTop_toTopOf="@id/story_info_view_file_size_heading"
tools:text="100 KB" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="64dp"
android:paddingHorizontal="@dimen/dsl_settings_gutter">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/story_info_avatar"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/story_info_display_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:lines="1"
android:textAppearance="@style/Signal.Text.BodyLarge"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/story_info_timestamp"
app:layout_constraintStart_toEndOf="@id/story_info_avatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/story_info_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_colorOnSurfaceVariant"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Today 12:30 PM" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -4553,6 +4553,8 @@
<string name="StoriesLandingItem__share">Share…</string>
<!-- Context menu option to go to story chat -->
<string name="StoriesLandingItem__go_to_chat">Go to chat</string>
<!-- Context menu option to go to story info -->
<string name="StoriesLandingItem__info">Info</string>
<!-- Label when a story is pending sending -->
<string name="StoriesLandingItem__sending">Sending…</string>
<!-- Label when multiple stories are pending sending -->
@ -4994,6 +4996,19 @@
<!-- Only with selected connections option for initial My Story settings configuration shown when sending to My Story for the first time -->
<string name="ChooseInitialMyStoryMembershipFragment__only_share_with">Only share with…</string>
<!-- Story info header sent heading -->
<string name="StoryInfoHeader__sent">Sent</string>
<!-- Story info header received heading -->
<string name="StoryInfoHeader__received">Received</string>
<!-- Story info header file size heading -->
<string name="StoryInfoHeader__file_size">File size</string>
<!-- Story info "Sent to" header -->
<string name="StoryInfoBottomSheetDialogFragment__sent_to">Sent to</string>
<!-- Story info "Sent from" header -->
<string name="StoryInfoBottomSheetDialogFragment__sent_from">Sent from</string>
<!-- Story Info context menu label -->
<string name="StoryInfoBottomSheetDialogFragment__info">Info</string>
<!-- EOF -->
</resources>