Allow users to remove viewers directly from stories.

This commit is contained in:
Alex Hart 2022-07-28 13:44:20 -03:00 committed by Greyson Parrelli
parent 2674fd2df4
commit 8f12b2041a
9 changed files with 133 additions and 6 deletions

View file

@ -1,9 +1,13 @@
package org.thoughtcrime.securesms.stories.viewer.views package org.thoughtcrime.securesms.stories.viewer.views
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@ -21,7 +25,10 @@ object StoryViewItem {
} }
class Model( class Model(
val storyViewItemData: StoryViewItemData val storyViewItemData: StoryViewItemData,
val canRemoveMember: Boolean,
val goToChat: (Model) -> Unit,
val removeFromStory: (Model) -> Unit
) : PreferenceModel<Model>() { ) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean { override fun areItemsTheSame(newItem: Model): Boolean {
return storyViewItemData.recipient == newItem.storyViewItemData.recipient return storyViewItemData.recipient == newItem.storyViewItemData.recipient
@ -30,6 +37,7 @@ object StoryViewItem {
override fun areContentsTheSame(newItem: Model): Boolean { override fun areContentsTheSame(newItem: Model): Boolean {
return storyViewItemData == newItem.storyViewItemData && return storyViewItemData == newItem.storyViewItemData &&
storyViewItemData.recipient.hasSameContent(newItem.storyViewItemData.recipient) && storyViewItemData.recipient.hasSameContent(newItem.storyViewItemData.recipient) &&
canRemoveMember == newItem.canRemoveMember &&
super.areContentsTheSame(newItem) super.areContentsTheSame(newItem)
} }
} }
@ -44,10 +52,47 @@ object StoryViewItem {
avatarView.setAvatar(model.storyViewItemData.recipient) avatarView.setAvatar(model.storyViewItemData.recipient)
nameView.text = model.storyViewItemData.recipient.getDisplayName(context) nameView.text = model.storyViewItemData.recipient.getDisplayName(context)
viewedAtView.text = formatDate(model.storyViewItemData.timeViewedInMillis) viewedAtView.text = formatDate(model.storyViewItemData.timeViewedInMillis)
itemView.setOnClickListener {
showContextMenu(model)
}
} }
private fun formatDate(dateInMilliseconds: Long): String { private fun formatDate(dateInMilliseconds: Long): String {
return DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), dateInMilliseconds) return DateUtils.formatDateWithDayOfWeek(Locale.getDefault(), dateInMilliseconds)
} }
private fun showContextMenu(model: Model) {
itemView.isSelected = true
val actions = mutableListOf<ActionItem>()
actions.add(
ActionItem(
iconRes = R.drawable.ic_open_24_tinted,
title = context.getString(R.string.StoriesLandingItem__go_to_chat),
action = {
model.goToChat(model)
}
)
)
if (model.canRemoveMember) {
actions.add(
ActionItem(
iconRes = R.drawable.ic_minus_circle_20,
title = context.getString(R.string.StoryViewItem__remove_viewer),
action = {
model.removeFromStory(model)
}
)
)
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetY(DimensionUnit.DP.toPixels(16f).toInt())
.onDismiss { itemView.isSelected = false }
.show(actions)
}
} }
} }

View file

@ -4,12 +4,15 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild
import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent
import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.fragments.findListener
@ -66,12 +69,37 @@ class StoryViewsFragment :
private fun getConfiguration(state: StoryViewsState): DSLConfiguration { private fun getConfiguration(state: StoryViewsState): DSLConfiguration {
return configure { return configure {
state.views.forEach { state.views.forEach { storyViewItemData ->
customPref(StoryViewItem.Model(it)) customPref(
StoryViewItem.Model(
storyViewItemData = storyViewItemData,
canRemoveMember = state.storyRecipient?.isDistributionList ?: false,
goToChat = {
val chatIntent = ConversationIntents.createBuilder(requireContext(), it.storyViewItemData.recipient.id, -1L).build()
startActivity(chatIntent)
},
removeFromStory = {
if (state.storyRecipient?.isDistributionList == true) {
confirmRemoveFromStory(it.storyViewItemData.recipient, state.storyRecipient)
}
}
)
)
} }
} }
} }
private fun confirmRemoveFromStory(user: Recipient, story: Recipient) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.StoryViewsFragment__remove_viewer)
.setMessage(getString(R.string.StoryViewsFragment__s_will_still_be_able, user.getShortDisplayName(requireContext()), story.getDisplayName(requireContext())))
.setPositiveButton(R.string.StoryViewsFragment__remove) { _, _ ->
viewModel.removeUserFromStory(user, story)
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
companion object { companion object {
private const val ARG_STORY_ID = "arg.story.id" private const val ARG_STORY_ID = "arg.story.id"

View file

@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.stories.viewer.views package org.thoughtcrime.securesms.stories.viewer.views
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.DatabaseObserver import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.GroupReceiptDatabase import org.thoughtcrime.securesms.database.GroupReceiptDatabase
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
@ -11,8 +14,20 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
class StoryViewsRepository { class StoryViewsRepository {
companion object {
private val TAG = Log.tag(StoryViewsRepository::class.java)
}
fun isReadReceiptsEnabled(): Boolean = TextSecurePreferences.isReadReceiptsEnabled(ApplicationDependencies.getApplication()) fun isReadReceiptsEnabled(): Boolean = TextSecurePreferences.isReadReceiptsEnabled(ApplicationDependencies.getApplication())
fun getStoryRecipient(storyId: Long): Single<Recipient> {
return Single.fromCallable {
val record = SignalDatabase.mms.getMessageRecord(storyId)
record.recipient
}.subscribeOn(Schedulers.io())
}
fun getViews(storyId: Long): Observable<List<StoryViewItemData>> { fun getViews(storyId: Long): Observable<List<StoryViewItemData>> {
return Observable.create<List<StoryViewItemData>> { emitter -> return Observable.create<List<StoryViewItemData>> { emitter ->
fun refresh() { fun refresh() {
@ -38,4 +53,15 @@ class StoryViewsRepository {
refresh() refresh()
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
} }
fun removeUserFromStory(user: Recipient, story: Recipient): Completable {
return Completable.fromAction {
val distributionListRecord = SignalDatabase.distributionLists.getList(story.requireDistributionListId())!!
if (user.id in distributionListRecord.members) {
SignalDatabase.distributionLists.excludeFromStory(user.id, distributionListRecord)
} else {
Log.w(TAG, "User is no longer in the distribution list.")
}
}
}
} }

View file

@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.stories.viewer.views package org.thoughtcrime.securesms.stories.viewer.views
import org.thoughtcrime.securesms.recipients.Recipient
data class StoryViewsState( data class StoryViewsState(
val loadState: LoadState = LoadState.INIT, val loadState: LoadState = LoadState.INIT,
val storyRecipient: Recipient? = null,
val views: List<StoryViewItemData> = emptyList() val views: List<StoryViewItemData> = emptyList()
) { ) {
enum class LoadState { enum class LoadState {

View file

@ -5,17 +5,24 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.util.livedata.Store
class StoryViewsViewModel(private val storyId: Long, private val repository: StoryViewsRepository) : ViewModel() { class StoryViewsViewModel(private val storyId: Long, private val repository: StoryViewsRepository) : ViewModel() {
private val store = Store(StoryViewsState(StoryViewsState.LoadState.INIT)) private val store = Store(StoryViewsState())
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
val state: LiveData<StoryViewsState> = store.stateLiveData val state: LiveData<StoryViewsState> = store.stateLiveData
fun refresh() { fun refresh() {
if (repository.isReadReceiptsEnabled()) { if (repository.isReadReceiptsEnabled()) {
disposables += repository.getStoryRecipient(storyId).subscribe { storyRecipient ->
store.update {
it.copy(storyRecipient = storyRecipient)
}
}
disposables += repository.getViews(storyId).subscribe { data -> disposables += repository.getViews(storyId).subscribe { data ->
store.update { store.update {
it.copy( it.copy(
@ -37,6 +44,10 @@ class StoryViewsViewModel(private val storyId: Long, private val repository: Sto
disposables.clear() disposables.clear()
} }
fun removeUserFromStory(user: Recipient, story: Recipient) {
repository.removeUserFromStory(user, story).subscribe()
}
class Factory( class Factory(
private val storyId: Long, private val storyId: Long,
private val repository: StoryViewsRepository private val repository: StoryViewsRepository

View file

@ -4,8 +4,10 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/dsl_settings_gutter" android:layout_marginHorizontal="@dimen/selectable_list_item_margin"
android:minHeight="64dp"> android:background="@drawable/selectable_list_item_background"
android:minHeight="64dp"
android:paddingHorizontal="@dimen/selectable_list_item_padding">
<org.thoughtcrime.securesms.components.AvatarImageView <org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/avatar" android:id="@+id/avatar"

View file

@ -20,6 +20,8 @@
<dimen name="media_overview_toggle_gutter">10dp</dimen> <dimen name="media_overview_toggle_gutter">10dp</dimen>
<dimen name="wallpaper_selection_gutter">16dp</dimen> <dimen name="wallpaper_selection_gutter">16dp</dimen>
<dimen name="safety_number_recipient_row_item_gutter">12dp</dimen> <dimen name="safety_number_recipient_row_item_gutter">12dp</dimen>
<dimen name="selectable_list_item_margin">16dp</dimen>
<dimen name="selectable_list_item_padding">8dp</dimen>
<dimen name="chat_colors_preview_bubble_max_width">260dp</dimen> <dimen name="chat_colors_preview_bubble_max_width">260dp</dimen>

View file

@ -200,6 +200,8 @@
<dimen name="media_overview_toggle_gutter">2dp</dimen> <dimen name="media_overview_toggle_gutter">2dp</dimen>
<dimen name="wallpaper_selection_gutter">8dp</dimen> <dimen name="wallpaper_selection_gutter">8dp</dimen>
<dimen name="safety_number_recipient_row_item_gutter">4dp</dimen> <dimen name="safety_number_recipient_row_item_gutter">4dp</dimen>
<dimen name="selectable_list_item_margin">8dp</dimen>
<dimen name="selectable_list_item_padding">8dp</dimen>
<dimen name="chat_colors_preview_bubble_max_width">240dp</dimen> <dimen name="chat_colors_preview_bubble_max_width">240dp</dimen>

View file

@ -4628,6 +4628,14 @@
<string name="StoryViewsFragment__enable_read_receipts_to_see_whos_viewed_your_story">Enable read receipts to see who\'s viewed your stories.</string> <string name="StoryViewsFragment__enable_read_receipts_to_see_whos_viewed_your_story">Enable read receipts to see who\'s viewed your stories.</string>
<!-- Button label displayed when user has disabled receipts --> <!-- Button label displayed when user has disabled receipts -->
<string name="StoryViewsFragment__go_to_settings">Go to settings</string> <string name="StoryViewsFragment__go_to_settings">Go to settings</string>
<!-- Dialog action to remove viewer from a story -->
<string name="StoryViewsFragment__remove">Remove</string>
<!-- Dialog title when removing a viewer from a story -->
<string name="StoryViewsFragment__remove_viewer">Remove viewer?</string>
<!-- Dialog message when removing a viewer from a story -->
<string name="StoryViewsFragment__s_will_still_be_able">%1$s will still be able to view this post, but will not be able to view any future posts you share to %2$s.</string>
<!-- Story View context menu action to remove them from a story -->
<string name="StoryViewItem__remove_viewer">Remove viewer</string>
<!-- Displayed when a story has no replies yet --> <!-- Displayed when a story has no replies yet -->
<string name="StoryGroupReplyFragment__no_replies_yet">No replies yet</string> <string name="StoryGroupReplyFragment__no_replies_yet">No replies yet</string>
<!-- Displayed for each user that reacted to a story when viewing replies --> <!-- Displayed for each user that reacted to a story when viewing replies -->