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 00f8e32df6
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)
========================================

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) {
// 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.
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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -90,7 +89,6 @@ class TimelinePresenter @AssistedInject constructor(
mutableStateOf(null)
}
val lastReadReceiptIndex = rememberSaveable { mutableIntStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
@ -128,7 +126,6 @@ class TimelinePresenter @AssistedInject constructor(
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId,
readReceiptType = if (isSendPublicReadReceiptsEnabled) ReceiptType.READ else ReceiptType.READ_PRIVATE,
)
@ -228,16 +225,19 @@ class TimelinePresenter @AssistedInject constructor(
private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>,
readReceiptType: ReceiptType,
) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId = eventId, receiptType = readReceiptType)
// If we are at the bottom of timeline, we mark the room as read.
if (firstVisibleIndex == 0) {
room.markAsRead(receiptType = readReceiptType)
} else {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
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.ViewConfiguration
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.TextOverflow
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.avatar.Avatar
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.PreviewsDayNight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
@ -648,18 +650,53 @@ private fun ReplyToContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = metadata?.text.orEmpty(),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.secondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
ReplyToContentText(metadata)
}
}
}
@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
@Composable
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(
@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails,
) = ElementPreview {
TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails)
}
@Composable
internal fun TimelineItemEventRowWithReplyContentToPreview(inReplyToDetails: InReplyToDetails) {
Column {
sequenceOf(false, true).forEach {
ATimelineItemEventRow(
@ -83,7 +88,7 @@ internal fun TimelineItemEventRowWithReplyPreview(
}
}
class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
open class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
aMessageContent(
@ -156,7 +161,7 @@ class InReplyToDetailsProvider : PreviewParameterProvider<InReplyToDetails> {
type = type,
)
private fun aInReplyToDetails(
protected fun aInReplyToDetails(
eventContent: EventContent,
) = InReplyToDetails(
eventId = EventId("\$event"),

View file

@ -21,12 +21,20 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
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.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.ImageMessageType
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.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.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.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
@ -35,17 +43,20 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Immutable
internal sealed interface InReplyToMetadata {
val text: String?
data class Thumbnail(
val attachmentThumbnailInfo: AttachmentThumbnailInfo
) : InReplyToMetadata {
override val text: String? = attachmentThumbnailInfo.textContent
val text: String = attachmentThumbnailInfo.textContent.orEmpty()
}
data class Text(
override val text: String
val text: String
) : 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(
thumbnailSource = MediaSource(eventContent.url),
textContent = eventContent.body,
type = AttachmentThumbnailType.Image
type = AttachmentThumbnailType.Image,
blurHash = eventContent.info.blurhash,
)
)
is PollContent -> InReplyToMetadata.Thumbnail(
@ -112,5 +124,13 @@ internal fun InReplyToDetails.metadata(): InReplyToMetadata? = when (eventConten
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
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.aMessagesState
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -24,16 +25,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun MessagesViewWithTypingPreview() = ElementPreview {
internal fun MessagesViewWithTypingPreview(
@PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState
) = ElementPreview {
MessagesView(
state = aMessagesState().copy(
typingNotificationState = aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
),
state = aMessagesState().copy(typingNotificationState = typingState),
onBackPressed = {},
onRoomDetailsClicked = {},
onEventClicked = { false },

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
@ -46,6 +47,7 @@ class TypingNotificationPresenter @Inject constructor(
override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(renderTypingNotifications) {
if (renderTypingNotifications) {
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(
renderTypingNotifications = renderTypingNotifications,
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 kotlinx.collections.immutable.ImmutableList
/**
* State for the typing notification view.
*/
data class TypingNotificationState(
/** Whether to render the typing notifications based on the user's preferences. */
val renderTypingNotifications: Boolean,
/** The room members currently typing. */
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"),
),
),
aTypingNotificationState(
typingMembers = emptyList(),
reserveSpace = true,
),
)
}
internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(),
reserveSpace: Boolean = false,
) = TypingNotificationState(
renderTypingNotifications = true,
typingMembers = typingMembers.toImmutableList(),
reserveSpace = reserveSpace,
)
internal fun aTypingRoomMember(

View file

@ -16,12 +16,27 @@
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.padding
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -43,54 +58,89 @@ fun TypingNotificationView(
state: TypingNotificationState,
modifier: Modifier = Modifier,
) {
if (state.typingMembers.isEmpty() || !state.renderTypingNotifications) return
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Text(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp),
text = typingNotificationText,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
@Suppress("ModifierNaming")
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
Text(
modifier = textModifier,
text = text,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
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
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
val names = when (typingMembers.size) {
0 -> "" // Cannot happen
1 -> typingMembers[0].disambiguatedDisplayName
2 -> stringResource(
id = R.string.screen_room_typing_two_members,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
)
else -> pluralStringResource(
id = R.plurals.screen_room_typing_many_members,
count = typingMembers.size - 2,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
typingMembers.size - 2,
)
}
// Get the translated string with a fake pattern
val tmpString = pluralStringResource(
id = R.plurals.screen_room_typing_notification,
count = typingMembers.size,
"<>",
)
// Split the string in 3 parts
val parts = tmpString.split("<>")
// And rebuild the string with the names
return buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
// Remember the last value to avoid empty typing messages while animating
var result by remember { mutableStateOf(AnnotatedString("")) }
if (typingMembers.isNotEmpty()) {
val names = when (typingMembers.size) {
0 -> "" // Cannot happen
1 -> typingMembers[0].disambiguatedDisplayName
2 -> stringResource(
id = R.string.screen_room_typing_two_members,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
)
else -> pluralStringResource(
id = R.plurals.screen_room_typing_many_members,
count = typingMembers.size - 2,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
typingMembers.size - 2,
)
}
// Get the translated string with a fake pattern
val tmpString = pluralStringResource(
id = R.plurals.screen_room_typing_notification,
count = typingMembers.size,
"<>",
)
// Split the string in 3 parts
val parts = tmpString.split("<>")
// And rebuild the string with the names
result = buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
}
append(parts[1])
}
append(parts[1])
}
return result
}
@PreviewsDayNight
@ -99,6 +149,7 @@ internal fun TypingNotificationViewPreview(
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
) = ElementPreview {
TypingNotificationView(
modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier,
state = state,
)
}

View file

@ -134,7 +134,7 @@ class MessagesPresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@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()
assertThat(room.markAsReadCalls).isEmpty()
val presenter = createMessagesPresenter(matrixRoom = room)
@ -142,7 +142,7 @@ class MessagesPresenterTest {
presenter.present()
}.test {
runCurrent()
assertThat(room.markAsReadCalls).isEqualTo(listOf(null))
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false))
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.virtual.VirtualTimelineItem
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.encryption.FakeEncryptionService
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.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -69,6 +73,7 @@ import java.util.Date
import kotlin.time.Duration.Companion.seconds
private const val FAKE_UNIQUE_ID = "FAKE_UNIQUE_ID"
private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2"
class TimelinePresenterTest {
@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
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline(
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)
@ -140,7 +179,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ)
@ -149,10 +188,17 @@ class TimelinePresenterTest {
}
@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(
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)
@ -168,6 +214,7 @@ class TimelinePresenterTest {
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isNotEmpty()
assertThat(timeline.sentReadReceipts.first().second).isEqualTo(ReceiptType.READ_PRIVATE)
@ -176,10 +223,17 @@ class TimelinePresenterTest {
}
@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(
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)
@ -191,8 +245,9 @@ class TimelinePresenterTest {
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isEmpty()
assertThat(timeline.sentReadReceipts).hasSize(1)
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 {
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker),
MatrixTimelineItem.Virtual(FAKE_UNIQUE_ID, VirtualTimelineItem.ReadMarker)
)
)
@ -212,7 +268,7 @@ class TimelinePresenterTest {
val initialState = awaitFirstItem()
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sentReadReceipts).isEmpty()
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.media.AudioInfo
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.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.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.ImageMessageType
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.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.VoiceMessageType
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.timeline.aMessageContent
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.AttachmentThumbnailType
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Duration.Companion.minutes
@RunWith(AndroidJUnit4::class)
class InReplyToMetadataKtTest {
@ -72,7 +84,7 @@ class InReplyToMetadataKtTest {
messageType = ImageMessageType(
body = "body",
source = aMediaSource(),
info = null,
info = anImageInfo(),
)
)
).metadata()
@ -84,7 +96,33 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(),
textContent = "body",
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(
body = "body",
source = aMediaSource(),
info = VideoInfo(
duration = null,
height = null,
width = null,
mimetype = null,
size = null,
thumbnailInfo = null,
thumbnailSource = aMediaSource(),
blurhash = null
),
info = aVideoInfo(),
)
)
).metadata()
@ -121,7 +150,7 @@ class InReplyToMetadataKtTest {
thumbnailSource = aMediaSource(),
textContent = "body",
type = AttachmentThumbnailType.Video,
blurHash = null,
blurHash = A_BLUR_HASH,
)
)
)
@ -277,11 +306,115 @@ class InReplyToMetadataKtTest {
}
@Test
fun `any other content`() = runTest {
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetails(
eventContent = RedactedContent
).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 {
awaitItem().let {
assertThat(it).isNull()
@ -306,6 +439,31 @@ fun anInReplyToDetails(
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
private fun testEnv(content: @Composable () -> Any?): Any? {
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.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore
@ -53,6 +54,7 @@ class TypingNotificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.renderTypingNotifications).isTrue()
assertThat(initialState.typingMembers).isEmpty()
assertThat(initialState.reserveSpace).isFalse()
}
}
@ -85,7 +87,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// Preferences changes again
sessionPreferencesStore.setRenderTypingNotifications(false)
skipItems(1)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.renderTypingNotifications).isFalse()
assertThat(finalState.typingMembers).isEmpty()
@ -108,6 +110,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
@ -140,6 +143,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
@ -166,11 +170,38 @@ class TypingNotificationPresenterTest {
listOf(aKnownRoomMember).toImmutableList()
)
)
skipItems(1)
val finalState = awaitItem()
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(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
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.roomdetails.impl.members.details.RoomMemberDetailsPresenter
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.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -98,7 +99,9 @@ class RoomDetailsPresenter @Inject constructor(
val dmMember by room.getDirectRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(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 topic = roomTopic
@ -174,11 +177,6 @@ class RoomDetailsPresenter @Inject constructor(
}
}
@Composable
private fun isFavorite() = remember {
room.notableTagsFlow.map { it.isFavorite }
}.collectAsState(initial = false)
@Composable
private fun getCanInvite(membersState: MatrixRoomMembersState) = produceState(false, membersState) {
value = room.canInvite().getOrElse { false }

View file

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

View file

@ -135,19 +135,18 @@ private fun RoomListModalBottomSheetContent(
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.FavouriteOff,
CompoundIcons.Favourite(),
contentDescription = stringResource(id = CommonStrings.common_favourite),
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite.dataOrNull().orFalse(),
enabled = contextMenu.isFavorite is AsyncData.Success,
checked = contextMenu.isFavorite,
onChange = { isFavorite ->
onFavoriteChanged(isFavorite)
},
),
onClick = {
onFavoriteChanged(!contextMenu.isFavorite.dataOrNull().orFalse())
onFavoriteChanged(!contextMenu.isFavorite)
},
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.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -74,7 +71,6 @@ class RoomListPresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService,
private val setRoomIsFavorite: SetRoomIsFavoriteAction,
private val migrationScreenPresenter: MigrationScreenPresenter,
private val sessionPreferencesStore: SessionPreferencesStore,
) : Presenter<RoomListState> {
@ -146,17 +142,26 @@ class RoomListPresenter @Inject constructor(
}
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event)
is RoomListEvents.MarkAsRead -> coroutineScope.launch {
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch {
client.getRoom(event.roomId)?.use { room ->
room.setIsFavorite(event.isFavorite)
}
}
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 {
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,
roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm,
isFavorite = AsyncData.Loading(),
isFavorite = event.roomListRoomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent
)
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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -529,19 +529,18 @@ class RoomListPresenterTests {
}.test {
val initialState = awaitItem()
assertThat(room.markAsReadCalls).isEmpty()
assertThat(room.markAsUnreadReadCallCount).isEqualTo(0)
assertThat(room.setUnreadFlagCalls).isEmpty()
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
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))
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
sessionPreferencesStore.setSendPublicReadReceipts(false)
initialState.eventSink.invoke(RoomListEvents.MarkAsRead(A_ROOM_ID))
assertThat(room.markAsReadCalls).isEqualTo(listOf(ReceiptType.READ, ReceiptType.READ_PRIVATE))
assertThat(room.markAsUnreadReadCallCount).isEqualTo(1)
assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false, true, false))
cancelAndIgnoreRemainingEvents()
scope.cancel()
}

View file

@ -80,6 +80,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@ -95,4 +96,5 @@ internal fun createRoomListRoomSummary(
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = 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" }
molecule-runtime = "app.cash.molecule:molecule-runtime:1.3.2"
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_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" }
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"
# 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"
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.poll.PollKind
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.ReceiptType
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -60,11 +59,6 @@ interface MatrixRoom : Closeable {
val roomInfoFlow: Flow<MatrixRoomInfo>
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.
* 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>
/**
* Reverts a previously set unread flag, and eventually send a Read Receipt.
* @param receiptType The type of receipt to send. If null, no Read Receipt will be sent.
* 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.
*/
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.

View file

@ -30,6 +30,7 @@ data class MatrixRoomInfo(
val isPublic: Boolean,
val isSpace: Boolean,
val isTombstoned: Boolean,
val isFavorite: Boolean,
val canonicalAlias: String?,
val alternativeAliases: ImmutableList<String>,
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 hasRoomCall: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
) {
val lastMessageTimestamp = lastMessage?.originServerTs
}

View file

@ -40,6 +40,7 @@ class MatrixRoomInfoMapper(
isPublic = it.isPublic,
isSpace = it.isSpace,
isTombstoned = it.isTombstoned,
isFavorite = it.isFavourite,
canonicalAlias = it.canonicalAlias,
alternativeAliases = it.alternativeAliases.toImmutableList(),
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.location.AssetType
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.ReceiptType
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.room.location.toInner
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.toRustReceiptType
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.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.RoomNotableTagsListener
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.WidgetCapabilities
@ -86,7 +83,6 @@ import timber.log.Timber
import java.io.File
import org.matrix.rustcomponents.sdk.Room as InnerRoom
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
import uniffi.matrix_sdk_base.RoomNotableTags as RustRoomNotableTags
@OptIn(ExperimentalCoroutinesApi::class)
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 {
launch {
val initial = emptyList<UserId>()
@ -457,23 +444,19 @@ class RustMatrixRoom(
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
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 {
if (receiptType != null) {
innerRoom.markAsReadAndSendReadReceipt(receiptType.toRustReceiptType())
} else {
innerRoom.markAsRead()
}
innerRoom.markAsRead(receiptType.toRustReceiptType())
}
}
override suspend fun markAsUnread(): Result<Unit> = withContext(roomDispatcher) {
override suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit> = withContext(roomDispatcher) {
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),
hasRoomCall = roomInfo.hasRoomCall,
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.StateEventType
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.ReceiptType
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)
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)
override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow
@ -383,33 +379,26 @@ class FakeMatrixRoom(
return reportContentResult
}
val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
return setIsFavoriteResult.also { result ->
if (result.isSuccess) {
val lowPriority = if (isFavorite) {
false
} else {
_notableTagsFlow.value.isLowPriority
}
val notableTags = RoomNotableTags(isFavorite, lowPriority)
_notableTagsFlow.emit(notableTags)
}
return setIsFavoriteResult.also {
setIsFavoriteCalls.add(isFavorite)
}
}
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)
return Result.success(Unit)
}
var markAsUnreadReadCallCount = 0
var setUnreadFlagCalls = mutableListOf<Boolean>()
private set
override suspend fun markAsUnread(): Result<Unit> {
markAsUnreadReadCallCount++
override suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit> {
setUnreadFlagCalls.add(isUnread)
return Result.success(Unit)
}
@ -657,6 +646,7 @@ fun aRoomInfo(
isPublic: Boolean = true,
isSpace: Boolean = false,
isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: String? = null,
alternativeAliases: List<String> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
@ -679,6 +669,7 @@ fun aRoomInfo(
isPublic = isPublic,
isSpace = isSpace,
isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership,
@ -693,9 +684,3 @@ fun aRoomInfo(
hasRoomCall = hasRoomCall,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
)
fun aRoomNotableTags(
isFavorite: Boolean = false,
) = RoomNotableTags(
isFavorite = isFavorite,
)

View file

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

View file

@ -117,6 +117,7 @@ fun aRoomSummaryDetails(
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@ -132,4 +133,5 @@ fun aRoomSummaryDetails(
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
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.
// When creating a hotfix, you should decrease the value, since the current value
// is the value for the next regular release.
private const val versionPatch = 3
private const val versionPatch = 5
object Versions {
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