Merge pull request #2349 from element-hq/feature/bma/disableTyping

"Share presence" setting
This commit is contained in:
Benoit Marty 2024-02-07 10:29:43 +01:00 committed by GitHub
commit 3c36868f85
35 changed files with 197 additions and 58 deletions

2
changelog.d/2241.feature Normal file
View file

@ -0,0 +1,2 @@
Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings.
When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline.

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -37,6 +38,7 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -86,6 +88,7 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val sessionPreferencesStore: SessionPreferencesStore,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@ -147,6 +150,8 @@ class MessageComposerPresenter @Inject constructor(
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
var showTextFormatting: Boolean by remember { mutableStateOf(false) } var showTextFormatting: Boolean by remember { mutableStateOf(false) }
val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(messageComposerContext.composerMode) { LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> is MessageComposerMode.Edit ->
@ -212,7 +217,9 @@ class MessageComposerPresenter @Inject constructor(
// Declare that the user is not typing anymore when the composer is disposed // Declare that the user is not typing anymore when the composer is disposed
onDispose { onDispose {
appCoroutineScope.launch { appCoroutineScope.launch {
room.typingNotice(false) if (sendTypingNotifications) {
room.typingNotice(false)
}
} }
} }
} }
@ -310,8 +317,10 @@ class MessageComposerPresenter @Inject constructor(
analyticsService.trackError(event.error) analyticsService.trackError(event.error)
} }
is MessageComposerEvents.TypingNotice -> { is MessageComposerEvents.TypingNotice -> {
localCoroutineScope.launch { if (sendTypingNotifications) {
room.typingNotice(event.isTyping) localCoroutineScope.launch {
room.typingNotice(event.isTyping)
}
} }
} }
is MessageComposerEvents.SuggestionReceived -> { is MessageComposerEvents.SuggestionReceived -> {

View file

@ -106,6 +106,7 @@ class TimelinePresenter @AssistedInject constructor(
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState() val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val sessionState by remember { val sessionState by remember {
derivedStateOf { derivedStateOf {
@ -183,6 +184,7 @@ class TimelinePresenter @AssistedInject constructor(
highlightedEventId = highlightedEventId.value, highlightedEventId = highlightedEventId.value,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value, newEventState = newItemState.value,
sessionState = sessionState, sessionState = sessionState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -28,6 +28,7 @@ import kotlinx.collections.immutable.ImmutableList
data class TimelineState( data class TimelineState(
val timelineItems: ImmutableList<TimelineItem>, val timelineItems: ImmutableList<TimelineItem>,
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
val renderReadReceipts: Boolean,
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState, val newEventState: NewEventState,

View file

@ -48,6 +48,7 @@ import kotlin.random.Random
fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState( fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf()) = TimelineState(
timelineItems = timelineItems, timelineItems = timelineItems,
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts = false,
paginationState = MatrixTimeline.PaginationState( paginationState = MatrixTimeline.PaginationState(
isBackPaginating = false, isBackPaginating = false,
hasMoreToLoadBackwards = true, hasMoreToLoadBackwards = true,

View file

@ -120,6 +120,7 @@ fun TimelineView(
TimelineItemRow( TimelineItemRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo, timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(), state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value, highlightedItem = state.highlightedEventId?.value,

View file

@ -26,11 +26,13 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
internal fun ATimelineItemEventRow( internal fun ATimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false, isLastOutgoingMessage: Boolean = false,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
) = TimelineItemEventRow( ) = TimelineItemEventRow(
event = event, event = event,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted, isHighlighted = isHighlighted,
onClick = {}, onClick = {},

View file

@ -114,6 +114,7 @@ import kotlin.math.roundToInt
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -223,6 +224,7 @@ fun TimelineItemEventRow(
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts, receipts = event.readReceiptState.receipts,
), ),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClicked = { onReadReceiptClick(event) }, onReadReceiptsClicked = { onReadReceiptClick(event) },
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
) )

View file

@ -47,6 +47,7 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0), timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts), readReceiptState = TimelineItemReadReceipts(state.receipts),
), ),
renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
) )
// A message from current user // A message from current user
@ -60,6 +61,7 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0), timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts), readReceiptState = TimelineItemReadReceipts(state.receipts),
), ),
renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
) )
// Another message from current user // Another message from current user
@ -73,6 +75,7 @@ internal fun TimelineItemEventRowWithRRPreview(
timelineItemReactions = aTimelineItemReactions(count = 0), timelineItemReactions = aTimelineItemReactions(count = 0),
readReceiptState = TimelineItemReadReceipts(state.receipts), readReceiptState = TimelineItemReadReceipts(state.receipts),
), ),
renderReadReceipts = true,
isLastOutgoingMessage = true, isLastOutgoingMessage = true,
) )
} }

View file

@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.UserId
fun TimelineItemGroupedEventsRow( fun TimelineItemGroupedEventsRow(
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState, sessionState: SessionState,
@ -70,6 +71,7 @@ fun TimelineItemGroupedEventsRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
sessionState = sessionState, sessionState = sessionState,
onClick = onClick, onClick = onClick,
@ -93,6 +95,7 @@ private fun TimelineItemGroupedEventsRowContent(
timelineItem: TimelineItem.GroupedEvents, timelineItem: TimelineItem.GroupedEvents,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
highlightedItem: String?, highlightedItem: String?,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
sessionState: SessionState, sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
@ -124,6 +127,7 @@ private fun TimelineItemGroupedEventsRowContent(
TimelineItemRow( TimelineItemRow(
timelineItem = subGroupEvent, timelineItem = subGroupEvent,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState, sessionState = sessionState,
@ -141,13 +145,14 @@ private fun TimelineItemGroupedEventsRowContent(
) )
} }
} }
} else { } else if (renderReadReceipts) {
TimelineItemReadReceiptView( TimelineItemReadReceiptView(
state = ReadReceiptViewState( state = ReadReceiptViewState(
sendState = null, sendState = null,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
receipts = timelineItem.aggregatedReadReceipts, receipts = timelineItem.aggregatedReadReceipts,
), ),
renderReadReceipts = true,
onReadReceiptsClicked = onExpandGroupClick onReadReceiptsClicked = onExpandGroupClick
) )
} }
@ -163,6 +168,7 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
timelineItem = aGroupedEvents(withReadReceipts = true), timelineItem = aGroupedEvents(withReadReceipts = true),
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(), sessionState = aSessionState(),
onClick = {}, onClick = {},
@ -187,6 +193,7 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
timelineItem = aGroupedEvents(withReadReceipts = true), timelineItem = aGroupedEvents(withReadReceipts = true),
timelineRoomInfo = aTimelineRoomInfo(), timelineRoomInfo = aTimelineRoomInfo(),
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(), sessionState = aSessionState(),
onClick = {}, onClick = {},

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.core.UserId
internal fun TimelineItemRow( internal fun TimelineItemRow(
timelineItem: TimelineItem, timelineItem: TimelineItem,
timelineRoomInfo: TimelineRoomInfo, timelineRoomInfo: TimelineRoomInfo,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState, sessionState: SessionState,
@ -58,6 +59,7 @@ internal fun TimelineItemRow(
if (timelineItem.content is TimelineItemStateContent) { if (timelineItem.content is TimelineItemStateContent) {
TimelineItemStateEventRow( TimelineItemStateEventRow(
event = timelineItem, event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
@ -70,6 +72,7 @@ internal fun TimelineItemRow(
TimelineItemEventRow( TimelineItemEventRow(
event = timelineItem, event = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = highlightedItem == timelineItem.identifier(), isHighlighted = highlightedItem == timelineItem.identifier(),
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
@ -91,6 +94,7 @@ internal fun TimelineItemRow(
TimelineItemGroupedEventsRow( TimelineItemGroupedEventsRow(
timelineItem = timelineItem, timelineItem = timelineItem,
timelineRoomInfo = timelineRoomInfo, timelineRoomInfo = timelineRoomInfo,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState, sessionState = sessionState,

View file

@ -47,6 +47,7 @@ import kotlinx.collections.immutable.toPersistentList
@Composable @Composable
fun TimelineItemStateEventRow( fun TimelineItemStateEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -90,6 +91,7 @@ fun TimelineItemStateEventRow(
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
receipts = event.readReceiptState.receipts, receipts = event.readReceiptState.receipts,
), ),
renderReadReceipts = renderReadReceipts,
onReadReceiptsClicked = { onReadReceiptsClick(event) }, onReadReceiptsClicked = { onReadReceiptsClick(event) },
) )
} }
@ -107,6 +109,7 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
receipts = listOf(aReadReceiptData(0)).toPersistentList(), receipts = listOf(aReadReceiptData(0)).toPersistentList(),
) )
), ),
renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
isHighlighted = false, isHighlighted = false,
onClick = {}, onClick = {},

View file

@ -58,20 +58,23 @@ import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun TimelineItemReadReceiptView( fun TimelineItemReadReceiptView(
state: ReadReceiptViewState, state: ReadReceiptViewState,
renderReadReceipts: Boolean,
onReadReceiptsClicked: () -> Unit, onReadReceiptsClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.receipts.isNotEmpty()) { if (state.receipts.isNotEmpty()) {
ReadReceiptsRow(modifier = modifier) { if (renderReadReceipts) {
ReadReceiptsAvatars( ReadReceiptsRow(modifier = modifier) {
receipts = state.receipts, ReadReceiptsAvatars(
modifier = Modifier receipts = state.receipts,
.clip(RoundedCornerShape(4.dp)) modifier = Modifier
.clickable { .clip(RoundedCornerShape(4.dp))
onReadReceiptsClicked() .clickable {
} onReadReceiptsClicked()
.padding(2.dp) }
) .padding(2.dp)
)
}
} }
} else { } else {
when (state.sendState) { when (state.sendState) {
@ -206,6 +209,7 @@ internal fun TimelineItemReactionsViewPreview(
) = ElementPreview { ) = ElementPreview {
TimelineItemReadReceiptView( TimelineItemReadReceiptView(
state = state, state = state,
renderReadReceipts = true,
onReadReceiptsClicked = {}, onReadReceiptsClicked = {},
) )
} }

View file

@ -675,6 +675,7 @@ class MessagesPresenterTest {
room = matrixRoom, room = matrixRoom,
mediaPickerProvider = FakePickerProvider(), mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)), featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
sessionPreferencesStore = InMemorySessionPreferencesStore(),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl), localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = mediaSender, mediaSender = mediaSender,
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),

View file

@ -32,11 +32,13 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesStore
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
@ -888,6 +890,24 @@ class MessageComposerPresenterTest {
} }
} }
@Test
fun `present - handle typing notice event when sending typing notice is disabled`() = runTest {
val room = FakeMatrixRoom()
val store = InMemorySessionPreferencesStore(
isSendTypingNotificationsEnabled = false
)
val presenter = createPresenter(room = room, sessionPreferencesStore = store, coroutineScope = this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(room.typingRecord).isEmpty()
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(true))
initialState.eventSink.invoke(MessageComposerEvents.TypingNotice(false))
assertThat(room.typingRecord).isEmpty()
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState { private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount) skipItems(skipCount)
@ -901,6 +921,7 @@ class MessageComposerPresenterTest {
room: MatrixRoom = FakeMatrixRoom(), room: MatrixRoom = FakeMatrixRoom(),
pickerProvider: PickerProvider = this.pickerProvider, pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService, featureFlagService: FeatureFlagService = this.featureFlagService,
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(),
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
@ -909,6 +930,7 @@ class MessageComposerPresenterTest {
room, room,
pickerProvider, pickerProvider,
featureFlagService, featureFlagService,
sessionPreferencesStore,
localMediaFactory, localMediaFactory,
MediaSender(mediaPreProcessor, room), MediaSender(mediaPreProcessor, room),
snackbarDispatcher, snackbarDispatcher,

View file

@ -21,7 +21,7 @@ import io.element.android.compound.theme.Theme
sealed interface AdvancedSettingsEvents { sealed interface AdvancedSettingsEvents {
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetSendPublicReadReceiptsEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data object ChangeTheme : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents
data object CancelChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents
data class SetTheme(val theme: Theme) : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents

View file

@ -44,8 +44,8 @@ class AdvancedSettingsPresenter @Inject constructor(
val isDeveloperModeEnabled by appPreferencesStore val isDeveloperModeEnabled by appPreferencesStore
.isDeveloperModeEnabledFlow() .isDeveloperModeEnabledFlow()
.collectAsState(initial = false) .collectAsState(initial = false)
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore val isSharePresenceEnabled by sessionPreferencesStore
.isSendPublicReadReceiptsEnabled() .isSharePresenceEnabled()
.collectAsState(initial = true) .collectAsState(initial = true)
val theme by remember { val theme by remember {
appPreferencesStore.getThemeFlow().mapToTheme() appPreferencesStore.getThemeFlow().mapToTheme()
@ -60,8 +60,8 @@ class AdvancedSettingsPresenter @Inject constructor(
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
appPreferencesStore.setDeveloperModeEnabled(event.enabled) appPreferencesStore.setDeveloperModeEnabled(event.enabled)
} }
is AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetSharePresenceEnabled -> localCoroutineScope.launch {
sessionPreferencesStore.setSendPublicReadReceipts(event.enabled) sessionPreferencesStore.setSharePresence(event.enabled)
} }
AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false AdvancedSettingsEvents.CancelChangeTheme -> showChangeThemeDialog = false
AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true AdvancedSettingsEvents.ChangeTheme -> showChangeThemeDialog = true
@ -75,7 +75,7 @@ class AdvancedSettingsPresenter @Inject constructor(
return AdvancedSettingsState( return AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled, isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled, isSharePresenceEnabled = isSharePresenceEnabled,
theme = theme, theme = theme,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -21,7 +21,7 @@ import io.element.android.compound.theme.Theme
data class AdvancedSettingsState( data class AdvancedSettingsState(
val isRichTextEditorEnabled: Boolean, val isRichTextEditorEnabled: Boolean,
val isDeveloperModeEnabled: Boolean, val isDeveloperModeEnabled: Boolean,
val isSendPublicReadReceiptsEnabled: Boolean, val isSharePresenceEnabled: Boolean,
val theme: Theme, val theme: Theme,
val showChangeThemeDialog: Boolean, val showChangeThemeDialog: Boolean,
val eventSink: (AdvancedSettingsEvents) -> Unit val eventSink: (AdvancedSettingsEvents) -> Unit

View file

@ -38,7 +38,7 @@ fun aAdvancedSettingsState(
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled, isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
theme = Theme.System, theme = Theme.System,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
eventSink = {} eventSink = {}

View file

@ -83,15 +83,15 @@ fun AdvancedSettingsView(
) )
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_send_read_receipts)) Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence))
}, },
supportingContent = { supportingContent = {
Text(text = stringResource(id = R.string.screen_advanced_settings_send_read_receipts_description)) Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence_description))
}, },
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(
checked = state.isSendPublicReadReceiptsEnabled, checked = state.isSharePresenceEnabled,
), ),
onClick = { state.eventSink(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(!state.isSendPublicReadReceiptsEnabled)) } onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) }
) )
} }

View file

@ -8,6 +8,8 @@
<string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Disable the rich text editor to type Markdown manually."</string>
<string name="screen_advanced_settings_send_read_receipts">"Read receipts"</string> <string name="screen_advanced_settings_send_read_receipts">"Read receipts"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."</string> <string name="screen_advanced_settings_send_read_receipts_description">"If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users."</string>
<string name="screen_advanced_settings_share_presence">"Share presence"</string>
<string name="screen_advanced_settings_share_presence_description">"If turned off, you wont be able to send or receive read receipts or typing notifications"</string>
<string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string> <string name="screen_advanced_settings_view_source_description">"Enable option to view message source in the timeline."</string>
<string name="screen_edit_profile_display_name">"Display name"</string> <string name="screen_edit_profile_display_name">"Display name"</string>
<string name="screen_edit_profile_display_name_placeholder">"Your display name"</string> <string name="screen_edit_profile_display_name_placeholder">"Your display name"</string>

View file

@ -43,7 +43,7 @@ class AdvancedSettingsPresenterTest {
assertThat(initialState.isDeveloperModeEnabled).isFalse() assertThat(initialState.isDeveloperModeEnabled).isFalse()
assertThat(initialState.isRichTextEditorEnabled).isFalse() assertThat(initialState.isRichTextEditorEnabled).isFalse()
assertThat(initialState.showChangeThemeDialog).isFalse() assertThat(initialState.showChangeThemeDialog).isFalse()
assertThat(initialState.isSendPublicReadReceiptsEnabled).isTrue() assertThat(initialState.isSharePresenceEnabled).isTrue()
assertThat(initialState.theme).isEqualTo(Theme.System) assertThat(initialState.theme).isEqualTo(Theme.System)
} }
} }
@ -79,17 +79,17 @@ class AdvancedSettingsPresenterTest {
} }
@Test @Test
fun `present - send public read receipts off on`() = runTest { fun `present - share presence off on`() = runTest {
val presenter = createAdvancedSettingsPresenter() val presenter = createAdvancedSettingsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitLastSequentialItem() val initialState = awaitLastSequentialItem()
assertThat(initialState.isSendPublicReadReceiptsEnabled).isTrue() assertThat(initialState.isSharePresenceEnabled).isTrue()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(false)) initialState.eventSink.invoke(AdvancedSettingsEvents.SetSharePresenceEnabled(false))
assertThat(awaitItem().isSendPublicReadReceiptsEnabled).isFalse() assertThat(awaitItem().isSharePresenceEnabled).isFalse()
initialState.eventSink.invoke(AdvancedSettingsEvents.SetSendPublicReadReceiptsEnabled(true)) initialState.eventSink.invoke(AdvancedSettingsEvents.SetSharePresenceEnabled(true))
assertThat(awaitItem().isSendPublicReadReceiptsEnabled).isTrue() assertThat(awaitItem().isSharePresenceEnabled).isTrue()
} }
} }

View file

@ -19,8 +19,20 @@ package io.element.android.features.preferences.api.store
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface SessionPreferencesStore { interface SessionPreferencesStore {
suspend fun setSharePresence(enabled: Boolean)
fun isSharePresenceEnabled(): Flow<Boolean>
suspend fun setSendPublicReadReceipts(enabled: Boolean) suspend fun setSendPublicReadReceipts(enabled: Boolean)
fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> fun isSendPublicReadReceiptsEnabled(): Flow<Boolean>
suspend fun setRenderReadReceipts(enabled: Boolean)
fun isRenderReadReceiptsEnabled(): Flow<Boolean>
suspend fun setSendTypingNotifications(enabled: Boolean)
fun isSendTypingNotificationsEnabled(): Flow<Boolean>
suspend fun setRenderTypingNotifications(enabled: Boolean)
fun isRenderTypingNotificationsEnabled(): Flow<Boolean>
suspend fun clear() suspend fun clear()
} }

View file

@ -29,7 +29,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import java.io.File import java.io.File
class DefaultSessionPreferencesStore( class DefaultSessionPreferencesStore(
@ -43,13 +45,41 @@ class DefaultSessionPreferencesStore(
return context.preferencesDataStoreFile("session_${hashedUserId}_preferences") return context.preferencesDataStoreFile("session_${hashedUserId}_preferences")
} }
} }
private val sharePresenceKey = booleanPreferencesKey("sharePresence")
private val sendPublicReadReceiptsKey = booleanPreferencesKey("sendPublicReadReceipts") private val sendPublicReadReceiptsKey = booleanPreferencesKey("sendPublicReadReceipts")
private val renderReadReceiptsKey = booleanPreferencesKey("renderReadReceipts")
private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications")
private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications")
private val dataStoreFile = storeFile(context, sessionId) private val dataStoreFile = storeFile(context, sessionId)
private val store = PreferenceDataStoreFactory.create(scope = sessionCoroutineScope) { dataStoreFile } private val store = PreferenceDataStoreFactory.create(scope = sessionCoroutineScope) { dataStoreFile }
override suspend fun setSharePresence(enabled: Boolean) {
update(sharePresenceKey, enabled)
// Also update all the other settings
setSendPublicReadReceipts(enabled)
setRenderReadReceipts(enabled)
setSendTypingNotifications(enabled)
setRenderTypingNotifications(enabled)
}
override fun isSharePresenceEnabled(): Flow<Boolean> {
// Migration, if sendPublicReadReceiptsKey was false, consider that sharing presence is false.
return get(sharePresenceKey) { runBlocking { isSendPublicReadReceiptsEnabled().first() } }
}
override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled) override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled)
override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> = get(sendPublicReadReceiptsKey, true) override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> = get(sendPublicReadReceiptsKey) { true }
override suspend fun setRenderReadReceipts(enabled: Boolean) = update(renderReadReceiptsKey, enabled)
override fun isRenderReadReceiptsEnabled(): Flow<Boolean> = get(renderReadReceiptsKey) { true }
override suspend fun setSendTypingNotifications(enabled: Boolean) = update(sendTypingNotificationsKey, enabled)
override fun isSendTypingNotificationsEnabled(): Flow<Boolean> = get(sendTypingNotificationsKey) { true }
override suspend fun setRenderTypingNotifications(enabled: Boolean) = update(renderTypingNotificationsKey, enabled)
override fun isRenderTypingNotificationsEnabled(): Flow<Boolean> = get(renderTypingNotificationsKey) { true }
override suspend fun clear() { override suspend fun clear() {
dataStoreFile.safeDelete() dataStoreFile.safeDelete()
@ -59,7 +89,7 @@ class DefaultSessionPreferencesStore(
store.edit { prefs -> prefs[key] = value } store.edit { prefs -> prefs[key] = value }
} }
private fun <T> get(key: Preferences.Key<T>, default: T): Flow<T> { private fun <T> get(key: Preferences.Key<T>, default: () -> T): Flow<T> {
return store.data.map { prefs -> prefs[key] ?: default } return store.data.map { prefs -> prefs[key] ?: default() }
} }
} }

View file

@ -21,19 +21,50 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class InMemorySessionPreferencesStore( class InMemorySessionPreferencesStore(
isSharePresenceEnabled: Boolean = true,
isSendPublicReadReceiptsEnabled: Boolean = true, isSendPublicReadReceiptsEnabled: Boolean = true,
isRenderReadReceiptsEnabled: Boolean = true,
isSendTypingNotificationsEnabled: Boolean = true,
isRenderTypingNotificationsEnabled: Boolean = true,
) : SessionPreferencesStore { ) : SessionPreferencesStore {
private val isSharePresenceEnabled = MutableStateFlow(isSharePresenceEnabled)
private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled) private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled)
private val isRenderReadReceiptsEnabled = MutableStateFlow(isRenderReadReceiptsEnabled)
private val isSendTypingNotificationsEnabled = MutableStateFlow(isSendTypingNotificationsEnabled)
private val isRenderTypingNotificationsEnabled = MutableStateFlow(isRenderTypingNotificationsEnabled)
var clearCallCount = 0 var clearCallCount = 0
private set private set
override suspend fun setSharePresence(enabled: Boolean) {
isSharePresenceEnabled.tryEmit(enabled)
}
override fun isSharePresenceEnabled(): Flow<Boolean> = isSharePresenceEnabled
override suspend fun setSendPublicReadReceipts(enabled: Boolean) { override suspend fun setSendPublicReadReceipts(enabled: Boolean) {
isSendPublicReadReceiptsEnabled.tryEmit(enabled) isSendPublicReadReceiptsEnabled.tryEmit(enabled)
} }
override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> {
return isSendPublicReadReceiptsEnabled override fun isSendPublicReadReceiptsEnabled(): Flow<Boolean> = isSendPublicReadReceiptsEnabled
override suspend fun setRenderReadReceipts(enabled: Boolean) {
isRenderReadReceiptsEnabled.tryEmit(enabled)
} }
override fun isRenderReadReceiptsEnabled(): Flow<Boolean> = isRenderReadReceiptsEnabled
override suspend fun setSendTypingNotifications(enabled: Boolean) {
isSendTypingNotificationsEnabled.tryEmit(enabled)
}
override fun isSendTypingNotificationsEnabled(): Flow<Boolean> = isSendTypingNotificationsEnabled
override suspend fun setRenderTypingNotifications(enabled: Boolean) {
isRenderTypingNotificationsEnabled.tryEmit(enabled)
}
override fun isRenderTypingNotificationsEnabled(): Flow<Boolean> = isRenderTypingNotificationsEnabled
override suspend fun clear() { override suspend fun clear() {
clearCallCount++ clearCallCount++
isSendPublicReadReceiptsEnabled.tryEmit(true) isSendPublicReadReceiptsEnabled.tryEmit(true)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:da3f8614862ddacfd9b54afcd3bf091bec335e1489d7cbffc826059a89ae5478 oid sha256:41d892f8a97bb9d89d6a6128c7acae4d5c509dadee0e1758c7fec9d76bc4216d
size 58453 size 56386

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1fd599f2e93432d5d5555f086b60059e84452a0da3c2a69d932042b7ee4d3bac oid sha256:478e8c2d3dd29baf88f2641813760c623631d53446f31bca2f1372c05d4ca914
size 57922 size 55856

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9e9641228e0482c8e5500bb999b180e79cbfcb6a13e121f3be68aae5c58a6839 oid sha256:6e174f5f0b15343f5f02cc716cb371aa89d87e298104ac8e5df83bdab2db573d
size 57953 size 55887

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1c1c5b00098e5860d4c69ef9a652483ea1192d9fa10f5449fb817769ab054183 oid sha256:d044628791909fdb8f621b3693cb63726e6a1dbc1fd6e12e1b3a4e3c9be1e868
size 36128 size 36288

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4fe6625641cf06c45a817e9d10bc0d7296aa59160fc5a3e9dc28304e5b794125 oid sha256:62762b55b56ee307e15a2aa488e8426714771d6e9d76d68ee82185b3420a348f
size 57943 size 55831

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e85b252d0d6c166f987974ebcf9a6b58b924316733b868ad0ed5a61413bda381 oid sha256:923619c9af298ce1a250c5094429b6b820b560c62a55ec8baa40144478e42358
size 54577 size 53102

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4f246ce9f60b25d9739d73e2aed98132dfb3a0a694acbf320ff796f35f805fdc oid sha256:1125869fdf539f15103bef1a380e46af9b56c44262a1986009099e2700206038
size 54273 size 52799

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d0403a4bb8da3d36922cb5362977af9b3052624525def05e5236f4f922384c31 oid sha256:ecee6cd60257a931f717c8ae23f256ded8dd6ac8fa7af7430082c6a3f837b274
size 54297 size 52821

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:824774a1e19974090de4cb32e06a7f984b5db238c3dc8d04a35d3457e647ac84 oid sha256:8449c20314175d6e829df65b2b1ba3cf36944a679b2e360ee5d2ed4d8333ad07
size 31999 size 32149

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c0820b462a0dc103b66aef78528ae5ce13a55e2614a7a197e06713366c422893 oid sha256:5c38a6f66d48174c6e2b3ac8525906cd371030f15ac3ca8727a8ca83f528916e
size 54313 size 52823