Merge branch 'develop' into feature/fga/mark_room_as_favorite
This commit is contained in:
commit
00f8e32df6
282 changed files with 718 additions and 300 deletions
31
CHANGES.md
31
CHANGES.md
|
|
@ -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)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Fix message forwarding after SDK API change related to Timeline intitialization.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK.
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
Remove session preferences on user log out.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Rendering typing notification
|
||||
|
|
@ -1 +0,0 @@
|
|||
Manually mark a room as unread
|
||||
|
|
@ -1 +0,0 @@
|
|||
Fix crash after unregistering UnifiedPush distributor
|
||||
|
|
@ -1 +0,0 @@
|
|||
Move migration screen to within the room list
|
||||
|
|
@ -1 +0,0 @@
|
|||
Add missing device id to settings screen.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Open the keyboard (and keep it opened) when creating a poll.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Add empty state to the room list.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Allow joining unencrypted video calls in non encrypted rooms.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Adjusted the login flow buttons so the continue button is always at the same height
|
||||
10
fastlane/metadata/android/en-US/changelogs/40004030.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/40004030.txt
Normal 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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -48,6 +48,7 @@ data class RoomSummaryDetails(
|
|||
val userDefinedNotificationMode: RoomNotificationMode?,
|
||||
val hasRoomCall: Boolean,
|
||||
val isDm: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
) {
|
||||
val lastMessageTimestamp = lastMessage?.originServerTs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue