Merge branch 'develop' into feature/fga/mark_room_as_favorite
This commit is contained in:
commit
e00311a739
282 changed files with 718 additions and 300 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue