Merge branch 'develop' into feature/fga/mark_room_as_favorite

This commit is contained in:
ganfra 2024-02-15 10:16:43 +01:00
commit e00311a739
282 changed files with 718 additions and 300 deletions

View file

@ -1,3 +1,34 @@
Changes in Element X v0.4.3 (2024-02-14)
========================================
Features ✨
----------
- 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. ([#2241](https://github.com/element-hq/element-x-android/issues/2241))
- Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242))
- Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261))
- Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330))
- Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333))
Bugfixes 🐛
----------
- Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304))
- Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316))
- Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329))
- Fix message forwarding after SDK API change related to Timeline intitialization.
Other changes
-------------
- Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825))
- Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310))
- Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318))
- Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed.
- Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK.
- Remove session preferences on user log out.
Breaking changes 🚨
-------------------
- Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions.
Changes in Element X v0.4.2 (2024-01-31) Changes in Element X v0.4.2 (2024-01-31)
======================================== ========================================

View file

@ -1 +0,0 @@
Fix message forwarding after SDK API change related to Timeline intitialization.

View file

@ -1 +0,0 @@
Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed.

View file

@ -1,2 +0,0 @@
Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK.

View file

@ -1 +0,0 @@
Remove session preferences on user log out.

View file

@ -1 +0,0 @@
Update Compound icons in the project. **This is a breaking change** since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions.

View file

@ -1,2 +0,0 @@
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

@ -1 +0,0 @@
Rendering typing notification

View file

@ -1 +0,0 @@
Manually mark a room as unread

View file

@ -1 +0,0 @@
Fix crash after unregistering UnifiedPush distributor

View file

@ -1 +0,0 @@
Move migration screen to within the room list

View file

@ -1 +0,0 @@
Add missing device id to settings screen.

View file

@ -1 +0,0 @@
Open the keyboard (and keep it opened) when creating a poll.

View file

@ -1 +0,0 @@
Add empty state to the room list.

View file

@ -1 +0,0 @@
Allow joining unencrypted video calls in non encrypted rooms.

View file

@ -1 +0,0 @@
Adjusted the login flow buttons so the continue button is always at the same height

View file

@ -0,0 +1,10 @@
Main changes in this version:
- Added share presence toggle.
- Render typing notifications.
- Manually mark a room as unread.
- Add an empty state to the room list.
- Allow joining unencrypted video calls in non encrypted rooms.
And several other bugfixes.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -159,10 +159,10 @@ class MessagesPresenter @AssistedInject constructor(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Mark the room as read on entering but don't send read receipts // Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline. // as those will be handled by the timeline.
withContext(dispatchers.io) { withContext(dispatchers.io) {
room.markAsRead(null) room.setUnreadFlag(isUnread = false)
} }
} }

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -90,7 +89,6 @@ class TimelinePresenter @AssistedInject constructor(
mutableStateOf(null) mutableStateOf(null)
} }
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineItems by timelineItemsFactory.collectItemsAsState()
@ -128,7 +126,6 @@ class TimelinePresenter @AssistedInject constructor(
appScope.sendReadReceiptIfNeeded( appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex, firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems, timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId, lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE, readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
) )
@ -228,16 +225,19 @@ class TimelinePresenter @AssistedInject constructor(
private fun CoroutineScope.sendReadReceiptIfNeeded( private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int, firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>, timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>, lastReadReceiptId: MutableState<EventId?>,
readReceiptType: ReceiptType, readReceiptType: ReceiptType,
) = launch(dispatchers.computation) { ) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item // If we are at the bottom of timeline, we mark the room as read.
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems) if (firstVisibleIndex == 0) {
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) { room.markAsRead(receiptType = readReceiptType)
lastReadReceiptIndex.value = firstVisibleIndex } else {
lastReadReceiptId.value = eventId // Get last valid EventId seen by the user, as the first index might refer to a Virtual item
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType) val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && eventId != lastReadReceiptId.value) {
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
}
} }
} }

View file

@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -92,6 +93,7 @@ import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
@ -648,18 +650,53 @@ private fun ReplyToContent(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( ReplyToContentText(metadata)
text = metadata?.text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
@Composable
private fun ReplyToContentText(metadata: InReplyToMetadata?) {
val text = when (metadata) {
InReplyToMetadata.Redacted -> stringResource(id = CommonStrings.common_message_removed)
InReplyToMetadata.UnableToDecrypt -> stringResource(id = CommonStrings.common_waiting_for_decryption_key)
is InReplyToMetadata.Text -> metadata.text
is InReplyToMetadata.Thumbnail -> metadata.text
null -> ""
}
val iconResourceId = when (metadata) {
InReplyToMetadata.Redacted -> CompoundDrawables.ic_compound_delete
InReplyToMetadata.UnableToDecrypt -> CompoundDrawables.ic_compound_time
else -> null
}
val fontStyle = when (metadata) {
is InReplyToMetadata.Informative -> FontStyle.Italic
else -> FontStyle.Normal
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (iconResourceId != null) {
Icon(
resourceId = iconResourceId,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = text,
style = ElementTheme.typography.fontBodyMdRegular,
fontStyle = fontStyle,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemEventRowPreview() = ElementPreview { internal fun TimelineItemEventRowPreview() = ElementPreview {

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
@PreviewsDayNight
@Composable
internal fun TimelineItemEventRowWithReplyInformativePreview(
@PreviewParameter(InReplyToDetailsInformativeProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
RedactedContent,
UnableToDecryptContent(UnableToDecryptContent.Data.Unknown),
).map {
aInReplyToDetails(
eventContent = it,
)
}
}

View file

@ -54,6 +54,11 @@ import kotlinx.collections.immutable.persistentMapOf
internal fun TimelineItemEventRowWithReplyPreview( internal fun TimelineItemEventRowWithReplyPreview(
@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails, @PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview { ) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
@Composable
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
Column { Column {
sequenceOf(false, true).forEach { sequenceOf(false, true).forEach {
ATimelineItemEventRow( ATimelineItemEventRow(
@ -83,7 +88,7 @@ internal fun TimelineItemEventRowWithReplyPreview(
} }
} }
class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> { open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
override val values: Sequence<InReplyToDetails> override val values: Sequence<InReplyToDetails>
get() = sequenceOf( get() = sequenceOf(
aMessageContent( aMessageContent(
@ -156,7 +161,7 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
type = type, type = type,
) )
private fun aInReplyToDetails( protected fun aInReplyToDetails(
eventContent: EventContent, eventContent: EventContent,
) = InReplyToDetails( ) = InReplyToDetails(
eventId = EventId("\$event"), eventId = EventId("\$event"),

View file

@ -21,12 +21,20 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -35,17 +43,20 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Immutable @Immutable
internal sealed interface InReplyToMetadata { internal sealed interface InReplyToMetadata {
val text: String?
data class Thumbnail( data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata { ) : InReplyToMetadata {
override val text: String? = attachmentThumbnailInfo.textContent val text: String = attachmentThumbnailInfo.textContent.orEmpty()
} }
data class Text( data class Text(
override val text: String val text: String
) : InReplyToMetadata ) : InReplyToMetadata
sealed interface Informative : InReplyToMetadata
data object Redacted : Informative
data object UnableToDecrypt : Informative
} }
/** /**
@ -103,7 +114,8 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
AttachmentThumbnailInfo( AttachmentThumbnailInfo(
thumbnailSource = MediaSource(eventContent.url), thumbnailSource = MediaSource(eventContent.url),
textContent = eventContent.body, textContent = eventContent.body,
type = AttachmentThumbnailType.Image type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash,
) )
) )
is PollContent -> InReplyToMetadata.Thumbnail( is PollContent -> InReplyToMetadata.Thumbnail(
@ -112,5 +124,13 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
type = AttachmentThumbnailType.Poll, type = AttachmentThumbnailType.Poll,
) )
) )
else -> null is RedactedContent -> InReplyToMetadata.Redacted
is UnableToDecryptContent -> InReplyToMetadata.UnableToDecrypt
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
is ProfileChangeContent,
is RoomMembershipContent,
is StateContent,
UnknownContent,
null -> null
} }

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.typing package io.element.android.features.messages.impl.typing
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.MessagesView import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.aMessagesState import io.element.android.features.messages.impl.aMessagesState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -24,16 +25,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun MessagesViewWithTypingPreview() = ElementPreview { internal fun MessagesViewWithTypingPreview(
@PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState
) = ElementPreview {
MessagesView( MessagesView(
state = aMessagesState().copy( state = aMessagesState().copy(typingNotificationState = typingState),
typingNotificationState = aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
),
onBackPressed = {}, onBackPressed = {},
onRoomDetailsClicked = {}, onRoomDetailsClicked = {},
onEventClicked = { false }, onEventClicked = { false },

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.preferences.api.store.SessionPreferencesStore 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.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -46,6 +47,7 @@ class TypingNotificationPresenter @Inject constructor(
override fun present(): TypingNotificationState { override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) } val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true) val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(renderTypingNotifications) { LaunchedEffect(renderTypingNotifications) {
if (renderTypingNotifications) { if (renderTypingNotifications) {
observeRoomTypingMembers(typingMembersState) observeRoomTypingMembers(typingMembersState)
@ -54,9 +56,18 @@ class TypingNotificationPresenter @Inject constructor(
} }
} }
// This will keep the space reserved for the typing notifications after the first one is displayed
var reserveSpace by remember { mutableStateOf(false) }
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
reserveSpace = true
}
}
return TypingNotificationState( return TypingNotificationState(
renderTypingNotifications = renderTypingNotifications, renderTypingNotifications = renderTypingNotifications,
typingMembers = typingMembersState.value.toImmutableList(), typingMembers = typingMembersState.value.toImmutableList(),
reserveSpace = reserveSpace,
) )
} }

View file

@ -19,7 +19,14 @@ package io.element.android.features.messages.impl.typing
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
/**
* State for the typing notification view.
*/
data class TypingNotificationState( data class TypingNotificationState(
/** Whether to render the typing notifications based on the user's preferences. */
val renderTypingNotifications: Boolean, val renderTypingNotifications: Boolean,
/** The room members currently typing. */
val typingMembers: ImmutableList<RoomMember>, val typingMembers: ImmutableList<RoomMember>,
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
val reserveSpace: Boolean,
) )

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class TypingNotificationStateForMessagesProvider : PreviewParameterProvider<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(aTypingRoomMember()),
reserveSpace = true
),
aTypingNotificationState(reserveSpace = true),
)
}

View file

@ -68,14 +68,20 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"), aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
), ),
), ),
aTypingNotificationState(
typingMembers = emptyList(),
reserveSpace = true,
),
) )
} }
internal fun aTypingNotificationState( internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(), typingMembers: List<RoomMember> = emptyList(),
reserveSpace: Boolean = false,
) = TypingNotificationState( ) = TypingNotificationState(
renderTypingNotifications = true, renderTypingNotifications = true,
typingMembers = typingMembers.toImmutableList(), typingMembers = typingMembers.toImmutableList(),
reserveSpace = reserveSpace,
) )
internal fun aTypingRoomMember( internal fun aTypingRoomMember(

View file

@ -16,12 +16,27 @@
package io.element.android.features.messages.impl.typing package io.element.android.features.messages.impl.typing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
@ -43,54 +58,89 @@ fun TypingNotificationView(
state: TypingNotificationState, state: TypingNotificationState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.typingMembers.isEmpty() || !state.renderTypingNotifications) return val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Text( @Suppress("ModifierNaming")
modifier = modifier @Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
.fillMaxWidth() Text(
.padding(horizontal = 24.dp, vertical = 2.dp), modifier = textModifier,
text = typingNotificationText, text = text,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular, style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
) )
}
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
AnimatedVisibility(
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
visible = displayNotifications || state.reserveSpace,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Box(contentAlignment = Alignment.BottomStart) {
// Reserve the space for the typing notification by adding an invisible text
TypingText(
text = typingNotificationText,
textModifier = Modifier
.alpha(0f)
// Remove the semantics of the text to avoid screen readers to read it
.clearAndSetSemantics { }
)
// Display the actual notification
AnimatedVisibility(
visible = displayNotifications,
enter = fadeIn(),
exit = fadeOut(),
) {
TypingText(text = typingNotificationText, textModifier = Modifier.padding(horizontal = 24.dp))
}
}
}
} }
@Composable @Composable
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString { private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
val names = when (typingMembers.size) { // Remember the last value to avoid empty typing messages while animating
0 -> "" // Cannot happen var result by remember { mutableStateOf(AnnotatedString("")) }
1 -> typingMembers[0].disambiguatedDisplayName if (typingMembers.isNotEmpty()) {
2 -> stringResource( val names = when (typingMembers.size) {
id = R.string.screen_room_typing_two_members, 0 -> "" // Cannot happen
typingMembers[0].disambiguatedDisplayName, 1 -> typingMembers[0].disambiguatedDisplayName
typingMembers[1].disambiguatedDisplayName, 2 -> stringResource(
) id = R.string.screen_room_typing_two_members,
else -> pluralStringResource( typingMembers[0].disambiguatedDisplayName,
id = R.plurals.screen_room_typing_many_members, typingMembers[1].disambiguatedDisplayName,
count = typingMembers.size - 2, )
typingMembers[0].disambiguatedDisplayName, else -> pluralStringResource(
typingMembers[1].disambiguatedDisplayName, id = R.plurals.screen_room_typing_many_members,
typingMembers.size - 2, count = typingMembers.size - 2,
) typingMembers[0].disambiguatedDisplayName,
} typingMembers[1].disambiguatedDisplayName,
// Get the translated string with a fake pattern typingMembers.size - 2,
val tmpString = pluralStringResource( )
id = R.plurals.screen_room_typing_notification, }
count = typingMembers.size, // Get the translated string with a fake pattern
"<>", val tmpString = pluralStringResource(
) id = R.plurals.screen_room_typing_notification,
// Split the string in 3 parts count = typingMembers.size,
val parts = tmpString.split("<>") "<>",
// And rebuild the string with the names )
return buildAnnotatedString { // Split the string in 3 parts
append(parts[0]) val parts = tmpString.split("<>")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { // And rebuild the string with the names
append(names) result = buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
}
append(parts[1])
} }
append(parts[1])
} }
return result
} }
@PreviewsDayNight @PreviewsDayNight
@ -99,6 +149,7 @@ internal fun TypingNotificationViewPreview(
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState, @PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
) = ElementPreview { ) = ElementPreview {
TypingNotificationView( TypingNotificationView(
modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier,
state = state, state = state,
) )
} }

View file

@ -134,7 +134,7 @@ class MessagesPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - check that the room is marked as read`() = runTest { fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
assertThat(room.markAsReadCalls).isEmpty() assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room) val presenter = createMessagesPresenter(matrixRoom = room)
@ -142,7 +142,7 @@ class MessagesPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
runCurrent() runCurrent()
assertThat(room.markAsReadCalls).isEqualTo(listOf(null)) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View file

@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSende
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -60,8 +61,11 @@ import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -69,6 +73,7 @@ import java.util.Date
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID" private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
class TimelinePresenterTest { class TimelinePresenterTest {
@get:Rule @get:Rule
@ -125,11 +130,45 @@ class TimelinePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - on scroll finished mark a room as read if the first visible index is 0`() = runTest(StandardTestDispatcher()) {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem())
)
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = createTimelinePresenter(
timeline = timeline,
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sentReadReceipts).isEmpty()
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
assertThat(room.markAsReadCalls).isNotEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline( val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf( initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()) MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
) )
) )
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
@ -140,7 +179,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
} }
assertThat(timeline.sentReadReceipts).isNotEmpty() assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ) assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
@ -149,10 +188,17 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - on scroll finished send a private read receipt if an event is before the index and public read receipts are disabled`() = runTest { fun `present - on scroll finished send a private read receipt if an event is at an index other than 0 and public read receipts are disabled`() = runTest {
val timeline = FakeMatrixTimeline( val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf( initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()) MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
) )
) )
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false)
@ -168,6 +214,7 @@ class TimelinePresenterTest {
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
} }
assertThat(timeline.sentReadReceipts).isNotEmpty() assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE) assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
@ -176,10 +223,17 @@ class TimelinePresenterTest {
} }
@Test @Test
fun `present - on scroll finished will not send read receipt if no event is before the index`() = runTest { fun `present - on scroll finished will not send read receipt the first visible event is the same as before`() = runTest {
val timeline = FakeMatrixTimeline( val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf( initialTimelineItems = listOf(
MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()) MatrixTimelineItem.Event(FAKE_UNIQUE_ID, anEventTimelineItem()),
MatrixTimelineItem.Event(
uniqueId = FAKE_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = aMessageContent("Test message")
)
)
) )
) )
val presenter = createTimelinePresenter(timeline) val presenter = createTimelinePresenter(timeline)
@ -191,8 +245,9 @@ class TimelinePresenterTest {
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
} }
assertThat(timeline.sentReadReceipts).isEmpty() assertThat(timeline.sentReadReceipts).hasSize(1)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -201,6 +256,7 @@ class TimelinePresenterTest {
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline( val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf( initialTimelineItems = listOf(
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker) MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
) )
) )
@ -212,7 +268,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem() val initialState = awaitFirstItem()
awaitWithLatch { latch -> awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
} }
assertThat(timeline.sentReadReceipts).isEmpty() assertThat(timeline.sentReadReceipts).isEmpty()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()

View file

@ -31,13 +31,23 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -45,11 +55,13 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest { class InReplyToMetadataKtTest {
@ -72,7 +84,7 @@ class InReplyToMetadataKtTest {
messageType = ImageMessageType( messageType = ImageMessageType(
body = "body", body = "body",
source = aMediaSource(), source = aMediaSource(),
info = null, info = anImageInfo(),
) )
) )
).metadata() ).metadata()
@ -84,7 +96,33 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(), thumbnailSource = aMediaSource(),
textContent = "body", textContent = "body",
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = null, blurHash = A_BLUR_HASH,
)
)
)
}
}
}
@Test
fun `a sticker message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = StickerContent(
body = "body",
info = anImageInfo(),
url = "url"
)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(
InReplyToMetadata.Thumbnail(
attachmentThumbnailInfo = AttachmentThumbnailInfo(
thumbnailSource = aMediaSource(url = "url"),
textContent = "body",
type = AttachmentThumbnailType.Image,
blurHash = A_BLUR_HASH,
) )
) )
) )
@ -100,16 +138,7 @@ class InReplyToMetadataKtTest {
messageType = VideoMessageType( messageType = VideoMessageType(
body = "body", body = "body",
source = aMediaSource(), source = aMediaSource(),
info = VideoInfo( info = aVideoInfo(),
duration = null,
height = null,
width = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = null
),
) )
) )
).metadata() ).metadata()
@ -121,7 +150,7 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(), thumbnailSource = aMediaSource(),
textContent = "body", textContent = "body",
type = AttachmentThumbnailType.Video, type = AttachmentThumbnailType.Video,
blurHash = null, blurHash = A_BLUR_HASH,
) )
) )
) )
@ -277,11 +306,115 @@ class InReplyToMetadataKtTest {
} }
@Test @Test
fun `any other content`() = runTest { fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails( anInReplyToDetails(
eventContent = RedactedContent eventContent = RedactedContent
).metadata() ).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.Redacted)
}
}
}
@Test
fun `unable to decrypt content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isEqualTo(InReplyToMetadata.UnableToDecrypt)
}
}
}
@Test
fun `failed to parse message content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = FailedToParseMessageLikeContent("", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `failed to parse state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = FailedToParseStateContent("", "", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `profile change content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = ProfileChangeContent("", "", "", "")
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `room membership content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RoomMembershipContent(A_USER_ID, null)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `state content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = StateContent("", OtherState.RoomJoinRules)
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `unknown content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = UnknownContent
).metadata()
}.test {
awaitItem().let {
assertThat(it).isNull()
}
}
}
@Test
fun `null content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = null
).metadata()
}.test { }.test {
awaitItem().let { awaitItem().let {
assertThat(it).isNull() assertThat(it).isNull()
@ -306,6 +439,31 @@ fun anInReplyToDetails(
textContent = textContent, textContent = textContent,
) )
fun aVideoInfo(): VideoInfo {
return VideoInfo(
duration = 1.minutes,
height = 100,
width = 100,
mimetype = "video/mp4",
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
fun anImageInfo(): ImageInfo {
return ImageInfo(
height = 100,
width = 100,
mimetype = "image/jpeg",
size = 1000,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = A_BLUR_HASH,
)
}
@Composable @Composable
private fun testEnv(content: @Composable () -> Any?): Any? { private fun testEnv(content: @Composable () -> Any?): Any? {
var result: Any? = null var result: Any? = null

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.typing
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore
@ -53,6 +54,7 @@ class TypingNotificationPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.renderTypingNotifications).isTrue() assertThat(initialState.renderTypingNotifications).isTrue()
assertThat(initialState.typingMembers).isEmpty() assertThat(initialState.typingMembers).isEmpty()
assertThat(initialState.reserveSpace).isFalse()
} }
} }
@ -85,7 +87,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember) assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// Preferences changes again // Preferences changes again
sessionPreferencesStore.setRenderTypingNotifications(false) sessionPreferencesStore.setRenderTypingNotifications(false)
skipItems(1) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.renderTypingNotifications).isFalse() assertThat(finalState.renderTypingNotifications).isFalse()
assertThat(finalState.typingMembers).isEmpty() assertThat(finalState.typingMembers).isEmpty()
@ -108,6 +110,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember) assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// User stops typing // User stops typing
room.givenRoomTypingMembers(emptyList()) room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty() assertThat(finalState.typingMembers).isEmpty()
} }
@ -140,6 +143,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember) assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
// User stops typing // User stops typing
room.givenRoomTypingMembers(emptyList()) room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty() assertThat(finalState.typingMembers).isEmpty()
} }
@ -166,11 +170,38 @@ class TypingNotificationPresenterTest {
listOf(aKnownRoomMember).toImmutableList() listOf(aKnownRoomMember).toImmutableList()
) )
) )
skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember) assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
} }
} }
@Test
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
skipItems(1)
val updatedTypingState = awaitItem()
assertThat(updatedTypingState.reserveSpace).isTrue()
// User stops typing
room.givenRoomTypingMembers(emptyList())
// Is still true for all future events
val futureEvents = cancelAndConsumeRemainingEvents()
for (event in futureEvents) {
if (event is Event.Item) {
assertThat(event.value.reserveSpace).isTrue()
}
}
}
}
private fun createPresenter( private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply { matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(id = roomId.value, name = "")) givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))

View file

@ -32,6 +32,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction import io.element.android.features.roomactions.api.SetRoomIsFavoriteAction
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -98,7 +99,9 @@ class RoomDetailsPresenter @Inject constructor(
val dmMember by room.getDirectRoomMember(membersState) val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember) val roomType by getRoomType(dmMember)
val isFavorite by isFavorite() val isFavorite by remember {
derivedStateOf { roomInfo?.isFavorite.orFalse() }
}
val topicState = remember(canEditTopic, roomTopic, roomType) { val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic val topic = roomTopic
@ -174,11 +177,6 @@ class RoomDetailsPresenter @Inject constructor(
} }
} }
@Composable
private fun isFavorite() = remember {
room.notableTagsFlow.map { it.isFavorite }
}.collectAsState(initial = false)
@Composable @Composable
private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) { private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) {
value = room.canInvite().getOrElse { false } value = room.canInvite().getOrElse { false }

View file

@ -372,7 +372,7 @@ private fun FavoriteSection(
) { ) {
PreferenceCategory(modifier = modifier) { PreferenceCategory(modifier = modifier) {
PreferenceSwitch( PreferenceSwitch(
icon = CompoundIcons.FavouriteOff, icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite), title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite, isChecked = isFavorite,
onCheckedChange = onFavoriteChanges onCheckedChange = onFavoriteChanges

View file

@ -135,19 +135,18 @@ private fun RoomListModalBottomSheetContent(
}, },
leadingContent = ListItemContent.Icon( leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector( iconSource = IconSource.Vector(
CompoundIcons.FavouriteOff, CompoundIcons.Favourite(),
contentDescription = stringResource(id = CommonStrings.common_favourite), contentDescription = stringResource(id = CommonStrings.common_favourite),
) )
), ),
trailingContent = ListItemContent.Switch( trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite.dataOrNull().orFalse(), checked = contextMenu.isFavorite,
enabled = contextMenu.isFavorite is AsyncData.Success,
onChange = { isFavorite -> onChange = { isFavorite ->
onFavoriteChanged(isFavorite) onFavoriteChanged(isFavorite)
}, },
), ),
onClick = { onClick = {
onFavoriteChanged(!contextMenu.isFavorite.dataOrNull().orFalse()) onFavoriteChanged(!contextMenu.isFavorite)
}, },
style = ListItemStyle.Primary, style = ListItemStyle.Primary,
) )

View file

@ -54,10 +54,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -74,7 +71,6 @@ class RoomListPresenter @Inject constructor(
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService, private val indicatorService: IndicatorService,
private val setRoomIsFavorite: SetRoomIsFavoriteAction,
private val migrationScreenPresenter: MigrationScreenPresenter, private val migrationScreenPresenter: MigrationScreenPresenter,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
@ -146,17 +142,26 @@ class RoomListPresenter @Inject constructor(
} }
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event) is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch {
is RoomListEvents.MarkAsRead -> coroutineScope.launch { client.getRoom(event.roomId)?.use { room ->
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { room.setIsFavorite(event.isFavorite)
ReceiptType.READ }
} else { }
ReceiptType.READ_PRIVATE is RoomListEvents.MarkAsRead -> coroutineScope.launch {
client.getRoom(event.roomId)?.use { room ->
room.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
room.markAsRead(receiptType)
} }
client.getRoom(event.roomId)?.markAsRead(receiptType)
} }
is RoomListEvents.MarkAsUnread -> coroutineScope.launch { is RoomListEvents.MarkAsUnread -> coroutineScope.launch {
client.getRoom(event.roomId)?.markAsUnread() client.getRoom(event.roomId)?.use { room ->
room.setUnreadFlag(isUnread = true)
}
} }
} }
} }
@ -191,28 +196,11 @@ class RoomListPresenter @Inject constructor(
roomId = event.roomListRoomSummary.roomId, roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name, roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm, isDm = event.roomListRoomSummary.isDm,
isFavorite = AsyncData.Loading(), isFavorite = event.roomListRoomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread), markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent hasNewContent = event.roomListRoomSummary.hasNewContent
) )
contextMenuState.value = initialState contextMenuState.value = initialState
client.getRoom(event.roomListRoomSummary.roomId).use { room ->
if (room != null) {
room.notableTagsFlow
.distinctUntilChanged()
.onEach { tags ->
val newState = initialState.copy(isFavorite = AsyncData.Success(tags.isFavorite))
contextMenuState.value = newState
}
.collect()
} else {
contextMenuState.value = initialState.copy(isFavorite = AsyncData.Failure(IllegalStateException("Room not found")))
}
}
}
private fun CoroutineScope.setRoomIsFavorite(event: RoomListEvents.SetRoomIsFavorite) = launch {
setRoomIsFavorite(event.roomId, event.isFavorite)
} }
private fun updateVisibleRange(range: IntRange) { private fun updateVisibleRange(range: IntRange) {

View file

@ -49,7 +49,7 @@ data class RoomListState(
val roomId: RoomId, val roomId: RoomId,
val roomName: String, val roomName: String,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: AsyncData<Boolean>, val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean, val markAsUnreadFeatureFlagEnabled: Boolean,
val hasNewContent: Boolean, val hasNewContent: Boolean,
) : ContextMenu ) : ContextMenu

View file

@ -44,7 +44,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true), aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = AsyncData.Success(true))), aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState().copy(displayRecoveryKeyPrompt = true), aRoomListState().copy(displayRecoveryKeyPrompt = true),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
@ -103,7 +103,7 @@ internal fun aContextMenuShown(
roomName: String = "aRoom", roomName: String = "aRoom",
isDm: Boolean = false, isDm: Boolean = false,
hasNewContent: Boolean = false, hasNewContent: Boolean = false,
isFavorite: AsyncData<Boolean> = AsyncData.Success(false), isFavorite: Boolean = false,
) = RoomListState.ContextMenu.Shown( ) = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"), roomId = RoomId("!aRoom:aDomain"),
roomName = roomName, roomName = roomName,

View file

@ -49,6 +49,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
userDefinedNotificationMode = null, userDefinedNotificationMode = null,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDm = false,
isFavorite = false,
) )
} }
@ -84,6 +85,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode, userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall, hasRoomCall = roomSummary.details.hasRoomCall,
isDm = roomSummary.details.isDm, isDm = roomSummary.details.isDm,
isFavorite = roomSummary.details.isFavorite,
) )
} }
} }

View file

@ -16,12 +16,11 @@
package io.element.android.features.roomlist.impl.migration package io.element.android.features.roomlist.impl.migration
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
@ -33,14 +32,14 @@ fun MigrationScreenView(
isMigrating: Boolean, isMigrating: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val displayMigrationStatusFadeProgress by animateFloatAsState( AnimatedVisibility(
targetValue = if (isMigrating) 1f else 0f, visible = isMigrating,
animationSpec = tween(durationMillis = 200), enter = fadeIn(),
label = "Migration view fade" exit = fadeOut(),
) label = "Migration view fade",
if (displayMigrationStatusFadeProgress > 0f) { ) {
SunsetPage( SunsetPage(
modifier = modifier.alpha(displayMigrationStatusFadeProgress), modifier = modifier,
isLoading = true, isLoading = true,
title = stringResource(id = R.string.screen_migration_title), title = stringResource(id = R.string.screen_migration_title),
subtitle = stringResource(id = R.string.screen_migration_message), subtitle = stringResource(id = R.string.screen_migration_message),

View file

@ -37,6 +37,7 @@ data class RoomListRoomSummary(
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||

View file

@ -97,6 +97,7 @@ internal fun aRoomListRoomSummary(
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDm: Boolean = false, isDm: Boolean = false,
isFavorite: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@ -112,4 +113,5 @@ internal fun aRoomListRoomSummary(
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite,
) )

View file

@ -529,19 +529,18 @@ class RoomListPresenterTests {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(room.markAsReadCalls).isEmpty() assertThat(room.markAsReadCalls).isEmpty()
assertThat(room.markAsUnreadReadCallCount).isEqualTo(0) assertThat(room.setUnreadFlagCalls).isEmpty()
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ)) assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ))
assertThat(room.markAsUnreadReadCallCount).isEqualTo(0) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID)) initialState.eventSink.invoke(RoomListEvents.MarkAsUnread(A_ROOM_ID))
assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ)) assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ))
assertThat(room.markAsUnreadReadCallCount).isEqualTo(1) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true))
// Test again with private read receipts // Test again with private read receipts
sessionPreferencesStore.setSendPublicReadReceipts(false) sessionPreferencesStore.setSendPublicReadReceipts(false)
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID)) initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE)) assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE))
assertThat(room.markAsUnreadReadCallCount).isEqualTo(1) assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true, false))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
scope.cancel() scope.cancel()
} }

View file

@ -80,6 +80,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications: Int = 0, numberOfUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null, userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -95,4 +96,5 @@ internal fun createRoomListRoomSummary(
userDefinedNotificationMode = userDefinedNotificationMode, userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDm = false,
isFavorite = isFavorite,
) )

View file

@ -152,7 +152,7 @@ jsoup = "org.jsoup:jsoup:1.17.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:1.3.2" molecule-runtime = "app.cash.molecule:molecule-runtime:1.3.2"
timber = "com.jakewharton.timber:timber:5.0.1" timber = "com.jakewharton.timber:timber:5.0.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.0" matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.1"
matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" }
matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
@ -172,7 +172,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0"
kotlinpoet = "com.squareup:kotlinpoet:1.16.0" kotlinpoet = "com.squareup:kotlinpoet:1.16.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.1.6" posthog = "com.posthog:posthog-android:3.1.7"
sentry = "io.sentry:sentry-android:7.3.0" sentry = "io.sentry:sentry-android:7.3.0"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:aa14cbcdf81af2746d20a71779ec751f971e1d7f"

View file

@ -29,7 +29,6 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -60,11 +59,6 @@ interface MatrixRoom : Closeable {
val roomInfoFlow: Flow<MatrixRoomInfo> val roomInfoFlow: Flow<MatrixRoomInfo>
val roomTypingMembersFlow: Flow<List<UserId>> val roomTypingMembersFlow: Flow<List<UserId>>
/**
* The current notable tags as a Flow.
*/
val notableTagsFlow: Flow<RoomNotableTags>
/** /**
* A one-to-one is a room with exactly 2 members. * A one-to-one is a room with exactly 2 members.
* See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules). * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/#default-underride-rules).
@ -161,15 +155,17 @@ interface MatrixRoom : Closeable {
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
/** /**
* Reverts a previously set unread flag, and eventually send a Read Receipt. * Mark the room as read by trying to attach an unthreaded read receipt to the latest room event.
* @param receiptType The type of receipt to send. If null, no Read Receipt will be sent. * @param receiptType The type of receipt to send.
*/ */
suspend fun markAsRead(receiptType: ReceiptType?): Result<Unit> suspend fun markAsRead(receiptType: ReceiptType): Result<Unit>
/** /**
* Sets a flag on the room to indicate that the user has explicitly marked it as unread. * Sets a flag on the room to indicate that the user has explicitly marked it as unread, or reverts the flag.
* @param isUnread true to mark the room as unread, false to remove the flag.
*
*/ */
suspend fun markAsUnread(): Result<Unit> suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit>
/** /**
* Share a location message in the room. * Share a location message in the room.

View file

@ -30,6 +30,7 @@ data class MatrixRoomInfo(
val isPublic: Boolean, val isPublic: Boolean,
val isSpace: Boolean, val isSpace: Boolean,
val isTombstoned: Boolean, val isTombstoned: Boolean,
val isFavorite: Boolean,
val canonicalAlias: String?, val canonicalAlias: String?,
val alternativeAliases: ImmutableList<String>, val alternativeAliases: ImmutableList<String>,
val currentUserMembership: CurrentUserMembership, val currentUserMembership: CurrentUserMembership,

View file

@ -1,27 +0,0 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room.tags
/**
* Represents the notable tags of a room.
* @param isFavorite true if the room is marked as favorite.
* @param isLowPriority true if the room is marked as low priority.
*/
data class RoomNotableTags(
val isFavorite: Boolean = false,
val isLowPriority: Boolean = false,
)

View file

@ -48,6 +48,7 @@ data class RoomSummaryDetails(
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean,
) { ) {
val lastMessageTimestamp = lastMessage?.originServerTs val lastMessageTimestamp = lastMessage?.originServerTs
} }

View file

@ -40,6 +40,7 @@ class MatrixRoomInfoMapper(
isPublic = it.isPublic, isPublic = it.isPublic,
isSpace = it.isSpace, isSpace = it.isSpace,
isTombstoned = it.isTombstoned, isTombstoned = it.isTombstoned,
isFavorite = it.isFavourite,
canonicalAlias = it.canonicalAlias, canonicalAlias = it.canonicalAlias,
alternativeAliases = it.alternativeAliases.toImmutableList(), alternativeAliases = it.alternativeAliases.toImmutableList(),
currentUserMembership = it.membership.map(), currentUserMembership = it.membership.map(),

View file

@ -39,7 +39,6 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -52,7 +51,6 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.tags.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
@ -74,7 +72,6 @@ import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.RoomNotableTagsListener
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.TypingNotificationsListener import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.WidgetCapabilities import org.matrix.rustcomponents.sdk.WidgetCapabilities
@ -86,7 +83,6 @@ import timber.log.Timber
import java.io.File import java.io.File
import org.matrix.rustcomponents.sdk.Room as InnerRoom import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
import uniffi.matrix_sdk_base.RoomNotableTags as RustRoomNotableTags
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom( class RustMatrixRoom(
@ -118,15 +114,6 @@ class RustMatrixRoom(
}) })
} }
override val notableTagsFlow: Flow<RoomNotableTags> = mxCallbackFlow {
innerRoom.subscribeToNotableTags(object : RoomNotableTagsListener {
override fun call(notableTags: RustRoomNotableTags) {
channel.trySend(notableTags.map())
}
})
}
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow { override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
launch { launch {
val initial = emptyList<UserId>() val initial = emptyList<UserId>()
@ -457,23 +444,19 @@ class RustMatrixRoom(
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) { override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.setIsFavorite(isFavorite, null) innerRoom.setIsFavourite(isFavorite, null)
} }
} }
override suspend fun markAsRead(receiptType: ReceiptType?): Result<Unit> = withContext(roomDispatcher) { override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
if (receiptType != null) { innerRoom.markAsRead(receiptType.toRustReceiptType())
innerRoom.markAsReadAndSendReadReceipt(receiptType.toRustReceiptType())
} else {
innerRoom.markAsRead()
}
} }
} }
override suspend fun markAsUnread(): Result<Unit> = withContext(roomDispatcher) { override suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.markAsUnread() innerRoom.setUnreadFlag(isUnread)
} }
} }

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room.tags
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import uniffi.matrix_sdk_base.RoomNotableTags as RustRoomNotableTags
fun RustRoomNotableTags.map() = RoomNotableTags(
isFavorite = isFavorite,
isLowPriority = isLowPriority
)
fun RoomNotableTags.map() = RustRoomNotableTags(
isFavorite = isFavorite,
isLowPriority = isLowPriority
)

View file

@ -44,6 +44,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode), userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L, isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
isFavorite = roomInfo.isFavourite,
) )
} }
} }

View file

@ -40,7 +40,6 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.tags.RoomNotableTags
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
@ -172,9 +171,6 @@ class FakeMatrixRoom(
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1) private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
private val _notableTagsFlow: MutableStateFlow<RoomNotableTags> = MutableStateFlow(aRoomNotableTags())
override val notableTagsFlow: Flow<RoomNotableTags> = _notableTagsFlow
private val _roomTypingMembersFlow: MutableSharedFlow<List<UserId>> = MutableSharedFlow(replay = 1) private val _roomTypingMembersFlow: MutableSharedFlow<List<UserId>> = MutableSharedFlow(replay = 1)
override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow
@ -383,33 +379,26 @@ class FakeMatrixRoom(
return reportContentResult return reportContentResult
} }
val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> { override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
return setIsFavoriteResult.also { result -> return setIsFavoriteResult.also {
if (result.isSuccess) { setIsFavoriteCalls.add(isFavorite)
val lowPriority = if (isFavorite) {
false
} else {
_notableTagsFlow.value.isLowPriority
}
val notableTags = RoomNotableTags(isFavorite, lowPriority)
_notableTagsFlow.emit(notableTags)
}
} }
} }
val markAsReadCalls = mutableListOf<ReceiptType>()
val markAsReadCalls = mutableListOf<ReceiptType?>() override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> {
override suspend fun markAsRead(receiptType: ReceiptType?): Result<Unit> {
markAsReadCalls.add(receiptType) markAsReadCalls.add(receiptType)
return Result.success(Unit) return Result.success(Unit)
} }
var markAsUnreadReadCallCount = 0 var setUnreadFlagCalls = mutableListOf<Boolean>()
private set private set
override suspend fun markAsUnread(): Result<Unit> { override suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit> {
markAsUnreadReadCallCount++ setUnreadFlagCalls.add(isUnread)
return Result.success(Unit) return Result.success(Unit)
} }
@ -657,6 +646,7 @@ fun aRoomInfo(
isPublic: Boolean = true, isPublic: Boolean = true,
isSpace: Boolean = false, isSpace: Boolean = false,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: String? = null, canonicalAlias: String? = null,
alternativeAliases: List<String> = emptyList(), alternativeAliases: List<String> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
@ -679,6 +669,7 @@ fun aRoomInfo(
isPublic = isPublic, isPublic = isPublic,
isSpace = isSpace, isSpace = isSpace,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(), alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership, currentUserMembership = currentUserMembership,
@ -693,9 +684,3 @@ fun aRoomInfo(
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(), activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
) )
fun aRoomNotableTags(
isFavorite: Boolean = false,
) = RoomNotableTags(
isFavorite = isFavorite,
)

View file

@ -68,6 +68,7 @@ fun aRoomSummaryDetails(
canonicalAlias: String? = null, canonicalAlias: String? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
isDm: Boolean = false, isDm: Boolean = false,
isFavorite: Boolean = false,
) = RoomSummaryDetails( ) = RoomSummaryDetails(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -83,6 +84,7 @@ fun aRoomSummaryDetails(
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite,
) )
fun aRoomMessage( fun aRoomMessage(

View file

@ -117,6 +117,7 @@ fun aRoomSummaryDetails(
numUnreadMessages: Int = 0, numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0, numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
) = RoomSummaryDetails( ) = RoomSummaryDetails(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -132,4 +133,5 @@ fun aRoomSummaryDetails(
numUnreadMessages = numUnreadMessages, numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications, numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
) )

View file

@ -56,7 +56,7 @@ private const val versionMinor = 4
// Note: even values are reserved for regular release, odd values for hotfix release. // Note: even values are reserved for regular release, odd values for hotfix release.
// When creating a hotfix, you should decrease the value, since the current value // When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release. // is the value for the next regular release.
private const val versionPatch = 3 private const val versionPatch = 5
object Versions { object Versions {
val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch val versionCode = 4_000_000 + versionMajor * 1_00_00 + versionMinor * 1_00 + versionPatch

Some files were not shown because too many files have changed in this diff Show more