Merge branch 'develop' of https://github.com/vector-im/element-x-android into langleyd/live_waveform
This commit is contained in:
commit
37f8195956
352 changed files with 2007 additions and 254 deletions
|
|
@ -33,8 +33,6 @@ import androidx.compose.material.icons.filled.MyLocation
|
|||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
|
|
@ -47,19 +45,21 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
|
|||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.internal.centerBottomEdge
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.features.location.impl.R
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
||||
|
|
|
|||
|
|
@ -20,12 +20,8 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.material3.BottomSheetScaffold
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
|
|
@ -42,6 +38,10 @@ import androidx.compose.ui.unit.Constraints
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
|
||||
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +58,7 @@ import kotlin.math.roundToInt
|
|||
* @param modifier The modifier for the layout.
|
||||
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ExpandableBottomSheetScaffold(
|
||||
content: @Composable (padding: PaddingValues) -> Unit,
|
||||
|
|
@ -139,7 +140,7 @@ internal fun ExpandableBottomSheetScaffold(
|
|||
modifier = Modifier.fillMaxHeight(),
|
||||
measurePolicy = { measurables, constraints ->
|
||||
val constraintHeight = constraints.maxHeight
|
||||
val offset = scaffoldState.bottomSheetState.getOffset() ?: 0
|
||||
val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0
|
||||
val height = Integer.max(0, constraintHeight - offset)
|
||||
val top = measurables[0].measure(
|
||||
constraints.copy(
|
||||
|
|
@ -163,7 +164,7 @@ internal fun ExpandableBottomSheetScaffold(
|
|||
})
|
||||
}
|
||||
|
||||
private fun SheetState.getOffset(): Int? = try {
|
||||
private fun CustomSheetState.getIntOffset(): Int? = try {
|
||||
requireOffset().roundToInt()
|
||||
} catch (e: IllegalStateException) {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -331,11 +331,14 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
textContent = targetEvent.content.body,
|
||||
type = AttachmentThumbnailType.Audio,
|
||||
)
|
||||
is TimelineItemVoiceContent -> AttachmentThumbnailInfo(
|
||||
textContent = targetEvent.content.body,
|
||||
type = AttachmentThumbnailType.Voice,
|
||||
)
|
||||
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
|
||||
type = AttachmentThumbnailType.Location,
|
||||
)
|
||||
is TimelineItemPollContent, // TODO Polls: handle reply to
|
||||
is TimelineItemVoiceContent, // TODO Voice messages: handle reply to
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemRedactedContent,
|
||||
is TimelineItemStateContent,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ import androidx.compose.runtime.DisposableEffect
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
|
|
@ -55,6 +59,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
|||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsPickerView
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
|
||||
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
|
|
@ -338,7 +343,11 @@ private fun MessagesViewContent(
|
|||
@Composable {}
|
||||
},
|
||||
sheetSwipeEnabled = state.composerState.showTextFormatting,
|
||||
sheetShape = if (state.composerState.showTextFormatting) MaterialTheme.shapes.large else RectangleShape,
|
||||
sheetShape = if (state.composerState.showTextFormatting || state.composerState.memberSuggestions.isNotEmpty()) {
|
||||
MaterialTheme.shapes.large
|
||||
} else {
|
||||
RectangleShape
|
||||
},
|
||||
content = { paddingValues ->
|
||||
TimelineView(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
|
|
@ -354,27 +363,56 @@ private fun MessagesViewContent(
|
|||
)
|
||||
},
|
||||
sheetContent = { subcomposing: Boolean ->
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
CantSendMessageBanner()
|
||||
}
|
||||
MessagesViewComposerBottomSheetContents(
|
||||
subcomposing = subcomposing,
|
||||
state = state,
|
||||
)
|
||||
},
|
||||
sheetContentKey = state.composerState.richTextEditorState.lineCount,
|
||||
sheetContentKey = state.composerState.richTextEditorState.lineCount + state.composerState.memberSuggestions.size,
|
||||
sheetTonalElevation = 0.dp,
|
||||
sheetShadowElevation = 0.dp,
|
||||
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagesViewComposerBottomSheetContents(
|
||||
subcomposing: Boolean,
|
||||
state: MessagesState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.userHasPermissionToSendMessage) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
MentionSuggestionsPickerView(
|
||||
modifier = Modifier.heightIn(max = 230.dp)
|
||||
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
return available
|
||||
}
|
||||
}),
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatarData = state.roomAvatar.dataOrNull(),
|
||||
memberSuggestions = state.composerState.memberSuggestions,
|
||||
onSuggestionSelected = {
|
||||
// TODO pass the selected suggestion to the RTE so it can be inserted as a pill
|
||||
}
|
||||
)
|
||||
MessageComposerView(
|
||||
state = state.composerState,
|
||||
voiceMessageState = state.voiceMessageComposerState,
|
||||
subcomposing = subcomposing,
|
||||
enableTextFormatting = state.enableTextFormatting,
|
||||
enableVoiceMessages = state.enableVoiceMessages,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CantSendMessageBanner(modifier = modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun MessagesViewTopBar(
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
|||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -67,6 +69,22 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemAudioContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemVoiceContent()).copy(
|
||||
reactionsState = reactionsState
|
||||
),
|
||||
actions = aTimelineItemActionList(),
|
||||
)
|
||||
),
|
||||
anActionListState().copy(
|
||||
target = ActionListState.Target.Success(
|
||||
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
|
||||
|
|
|
|||
|
|
@ -238,7 +238,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
|||
|
||||
when (event.content) {
|
||||
is TimelineItemPollContent, // TODO Polls: handle summary
|
||||
is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary
|
||||
is TimelineItemTextBasedContent,
|
||||
is TimelineItemStateContent,
|
||||
is TimelineItemEncryptedContent,
|
||||
|
|
@ -309,6 +308,18 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
|||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
is TimelineItemVoiceContent -> {
|
||||
icon = {
|
||||
AttachmentThumbnail(
|
||||
modifier = imageModifier,
|
||||
info = AttachmentThumbnailInfo(
|
||||
textContent = textContent,
|
||||
type = AttachmentThumbnailType.Voice,
|
||||
)
|
||||
)
|
||||
}
|
||||
content = { ContentForBody(event.content.body) }
|
||||
}
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
icon()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* 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.mentions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun MentionSuggestionsPickerView(
|
||||
roomId: RoomId,
|
||||
roomName: String?,
|
||||
roomAvatarData: AvatarData?,
|
||||
memberSuggestions: ImmutableList<RoomMemberSuggestion>,
|
||||
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
items(
|
||||
memberSuggestions,
|
||||
key = { suggestion ->
|
||||
when (suggestion) {
|
||||
is RoomMemberSuggestion.Room -> "@room"
|
||||
is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillParentMaxWidth()) {
|
||||
RoomMemberSuggestionItemView(
|
||||
memberSuggestion = it,
|
||||
roomId = roomId.value,
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatarData,
|
||||
onSuggestionSelected = onSuggestionSelected,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomMemberSuggestionItemView(
|
||||
memberSuggestion: RoomMemberSuggestion,
|
||||
roomId: String,
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val avatarSize = AvatarSize.TimelineRoom
|
||||
val avatarData = when (memberSuggestion) {
|
||||
is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
|
||||
is RoomMemberSuggestion.Member -> AvatarData(
|
||||
memberSuggestion.roomMember.userId.value,
|
||||
memberSuggestion.roomMember.displayName,
|
||||
memberSuggestion.roomMember.avatarUrl,
|
||||
avatarSize,
|
||||
)
|
||||
}
|
||||
val title = when (memberSuggestion) {
|
||||
is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
|
||||
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName
|
||||
}
|
||||
|
||||
val subtitle = when (memberSuggestion) {
|
||||
is RoomMemberSuggestion.Room -> "@room"
|
||||
is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value
|
||||
}
|
||||
|
||||
Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MentionSuggestionsPickerView_Preview() {
|
||||
ElementPreview {
|
||||
val roomMember = RoomMember(
|
||||
userId = UserId("@alice:server.org"),
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0L,
|
||||
normalizedPowerLevel = 0L,
|
||||
isIgnored = false,
|
||||
)
|
||||
MentionSuggestionsPickerView(
|
||||
roomId = RoomId("!room:matrix.org"),
|
||||
roomName = "Room",
|
||||
roomAvatarData = null,
|
||||
memberSuggestions = persistentListOf(
|
||||
RoomMemberSuggestion.Room,
|
||||
RoomMemberSuggestion.Member(roomMember),
|
||||
RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
|
||||
),
|
||||
onSuggestionSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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.mentions
|
||||
|
||||
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
||||
/**
|
||||
* This class is responsible for processing mention suggestions when `@`, `/` or `#` are type in the composer.
|
||||
*/
|
||||
object MentionSuggestionsProcessor {
|
||||
|
||||
// We don't want to retrieve thousands of members
|
||||
private const val MAX_BATCH_ITEMS = 100
|
||||
|
||||
/**
|
||||
* Process the mention suggestions.
|
||||
* @param suggestion The current suggestion input
|
||||
* @param roomMembersState The room members state, it contains the current users in the room
|
||||
* @param currentUserId The current user id
|
||||
* @param canSendRoomMention Should return true if the current user can send room mentions
|
||||
* @return The list of mentions to display
|
||||
*/
|
||||
suspend fun process(
|
||||
suggestion: Suggestion?,
|
||||
roomMembersState: MatrixRoomMembersState,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: suspend () -> Boolean,
|
||||
): List<RoomMemberSuggestion> {
|
||||
val members = roomMembersState.roomMembers()
|
||||
// Take the first MAX_BATCH_ITEMS only
|
||||
?.take(MAX_BATCH_ITEMS)
|
||||
return when {
|
||||
members.isNullOrEmpty() || suggestion == null -> {
|
||||
// Clear suggestions
|
||||
emptyList()
|
||||
}
|
||||
else -> {
|
||||
when (suggestion.type) {
|
||||
SuggestionType.Mention -> {
|
||||
// Replace suggestions
|
||||
val matchingMembers = getMemberSuggestions(
|
||||
query = suggestion.text,
|
||||
roomMembers = roomMembersState.roomMembers(),
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = canSendRoomMention()
|
||||
)
|
||||
matchingMembers
|
||||
}
|
||||
else -> {
|
||||
// Clear suggestions
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMemberSuggestions(
|
||||
query: String,
|
||||
roomMembers: List<RoomMember>?,
|
||||
currentUserId: UserId,
|
||||
canSendRoomMention: Boolean,
|
||||
): List<RoomMemberSuggestion> {
|
||||
return if (roomMembers.isNullOrEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
fun isJoinedMemberAndNotSelf(member: RoomMember): Boolean {
|
||||
return member.membership == RoomMembershipState.JOIN && currentUserId != member.userId
|
||||
}
|
||||
|
||||
fun memberMatchesQuery(member: RoomMember, query: String): Boolean {
|
||||
return member.userId.value.contains(query, ignoreCase = true)
|
||||
|| member.displayName?.contains(query, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
val matchingMembers = roomMembers
|
||||
// Search only in joined members, exclude the current user
|
||||
.filter { member ->
|
||||
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
|
||||
}
|
||||
.map(RoomMemberSuggestion::Member)
|
||||
|
||||
if ("room".contains(query) && canSendRoomMention) {
|
||||
listOf(RoomMemberSuggestion.Room) + matchingMembers
|
||||
} else {
|
||||
matchingMembers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
|
||||
@Immutable
|
||||
sealed interface MessageComposerEvents {
|
||||
|
|
@ -39,4 +40,5 @@ sealed interface MessageComposerEvents {
|
|||
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
|
||||
data object CancelSendAttachment : MessageComposerEvents
|
||||
data class Error(val error: Throwable) : MessageComposerEvents
|
||||
data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
|
|
@ -34,6 +35,7 @@ import im.vector.app.features.analytics.plan.Composer
|
|||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
|
||||
import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
|
|
@ -43,22 +45,32 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
|
|
@ -73,17 +85,26 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val messageComposerContext: MessageComposerContextImpl,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory
|
||||
) : Presenter<MessageComposerState> {
|
||||
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
private var pendingEvent: MessageComposerEvents? = null
|
||||
|
||||
private val suggestionSearchTrigger = MutableStateFlow<Suggestion?>(null)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
var isMentionsEnabled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
|
||||
}
|
||||
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val attachmentsState = remember {
|
||||
mutableStateOf<AttachmentsState>(AttachmentsState.None)
|
||||
|
|
@ -151,6 +172,34 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val memberSuggestions = remember { mutableStateListOf<RoomMemberSuggestion>() }
|
||||
LaunchedEffect(isMentionsEnabled) {
|
||||
if (!isMentionsEnabled) return@LaunchedEffect
|
||||
val currentUserId = currentSessionIdHolder.current
|
||||
|
||||
suspend fun canSendRoomMention(): Boolean {
|
||||
val roomIsDm = room.isDirect && room.isOneToOne
|
||||
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
|
||||
return !roomIsDm && userCanSendAtRoom
|
||||
}
|
||||
|
||||
suggestionSearchTrigger
|
||||
.debounce(0.5.seconds)
|
||||
.combine(room.membersStateFlow) { suggestion, roomMembersState ->
|
||||
memberSuggestions.clear()
|
||||
val result = MentionSuggestionsProcessor.process(
|
||||
suggestion = suggestion,
|
||||
roomMembersState = roomMembersState,
|
||||
currentUserId = currentUserId,
|
||||
canSendRoomMention = ::canSendRoomMention,
|
||||
)
|
||||
if (result.isNotEmpty()) {
|
||||
memberSuggestions.addAll(result)
|
||||
}
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
|
||||
fun handleEvents(event: MessageComposerEvents) {
|
||||
when (event) {
|
||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||
|
|
@ -231,6 +280,9 @@ class MessageComposerPresenter @Inject constructor(
|
|||
is MessageComposerEvents.Error -> {
|
||||
analyticsService.trackError(event.error)
|
||||
}
|
||||
is MessageComposerEvents.SuggestionReceived -> {
|
||||
suggestionSearchTrigger.value = event.suggestion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -243,6 +295,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
canShareLocation = canShareLocation.value,
|
||||
canCreatePoll = canCreatePoll.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
memberSuggestions = memberSuggestions.toPersistentList(),
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
@ -355,3 +408,8 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface RoomMemberSuggestion {
|
||||
data object Room : RoomMemberSuggestion
|
||||
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ data class MessageComposerState(
|
|||
val canShareLocation: Boolean,
|
||||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val memberSuggestions: ImmutableList<RoomMemberSuggestion>,
|
||||
val eventSink: (MessageComposerEvents) -> Unit,
|
||||
) {
|
||||
val hasFocus: Boolean = richTextEditorState.hasFocus
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
|
||||
override val values: Sequence<MessageComposerState>
|
||||
|
|
@ -36,6 +38,7 @@ fun aMessageComposerState(
|
|||
canShareLocation: Boolean = true,
|
||||
canCreatePoll: Boolean = true,
|
||||
attachmentsState: AttachmentsState = AttachmentsState.None,
|
||||
memberSuggestions: ImmutableList<RoomMemberSuggestion> = persistentListOf(),
|
||||
) = MessageComposerState(
|
||||
richTextEditorState = composerState,
|
||||
isFullScreen = isFullScreen,
|
||||
|
|
@ -45,5 +48,6 @@ fun aMessageComposerState(
|
|||
canShareLocation = canShareLocation,
|
||||
canCreatePoll = canCreatePoll,
|
||||
attachmentsState = attachmentsState,
|
||||
memberSuggestions = memberSuggestions,
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,11 +28,12 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
|
|||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -61,6 +62,10 @@ internal fun MessageComposerView(
|
|||
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
|
||||
}
|
||||
|
||||
fun onSuggestionReceived(suggestion: Suggestion?) {
|
||||
state.eventSink(MessageComposerEvents.SuggestionReceived(suggestion))
|
||||
}
|
||||
|
||||
fun onError(error: Throwable) {
|
||||
state.eventSink(MessageComposerEvents.Error(error))
|
||||
}
|
||||
|
|
@ -106,6 +111,7 @@ internal fun MessageComposerView(
|
|||
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||
onSendVoiceMessage = onSendVoiceMessage,
|
||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
onSuggestionReceived = ::onSuggestionReceived,
|
||||
onError = ::onError,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -612,10 +612,18 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
|
|||
textContent = messageContent.body,
|
||||
type = AttachmentThumbnailType.Location,
|
||||
)
|
||||
is AudioMessageType -> AttachmentThumbnailInfo(
|
||||
textContent = messageContent.body,
|
||||
type = AttachmentThumbnailType.Audio,
|
||||
)
|
||||
is AudioMessageType -> {
|
||||
when (type.isVoiceMessage) {
|
||||
true -> AttachmentThumbnailInfo(
|
||||
textContent = messageContent.body,
|
||||
type = AttachmentThumbnailType.Voice,
|
||||
)
|
||||
false -> AttachmentThumbnailInfo(
|
||||
textContent = messageContent.body,
|
||||
type = AttachmentThumbnailType.Audio,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
<item quantity="one">"%1$d room change"</item>
|
||||
<item quantity="other">"%1$d room changes"</item>
|
||||
</plurals>
|
||||
<string name="screen_room_mentions_at_room_subtitle">"Notify the whole room"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"Everyone"</string>
|
||||
<string name="screen_room_attachment_source_camera">"Camera"</string>
|
||||
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
|
||||
<string name="screen_room_attachment_source_camera_video">"Record video"</string>
|
||||
|
|
|
|||
|
|
@ -63,11 +63,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
|
|
@ -629,6 +631,7 @@ class MessagesPresenterTest {
|
|||
messageComposerContext = MessageComposerContextImpl(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
|
|||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
|
||||
import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
|
|
@ -42,13 +43,23 @@ import io.element.android.libraries.matrix.api.core.TransactionId
|
|||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_REPLY
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_4
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
|
|
@ -60,6 +71,8 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
|||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
|
|
@ -67,6 +80,7 @@ import io.mockk.mockk
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.internal.immutableListOf
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
|
@ -706,6 +720,104 @@ class MessageComposerPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room member mention suggestions`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
|
||||
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
|
||||
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
|
||||
val room = FakeMatrixRoom(
|
||||
isDirect = false,
|
||||
isOneToOne = false,
|
||||
).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||
immutableListOf(currentUser, invitedUser, bob, david),
|
||||
))
|
||||
givenCanTriggerRoomNotification(Result.success(true))
|
||||
}
|
||||
val flagsService = FakeFeatureFlagService(
|
||||
mapOf(
|
||||
FeatureFlags.Mentions.key to true,
|
||||
)
|
||||
)
|
||||
val presenter = createPresenter(this, room, featureFlagService = flagsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
|
||||
// A null suggestion (no suggestion was received) returns nothing
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(null))
|
||||
assertThat(awaitItem().memberSuggestions).isEmpty()
|
||||
|
||||
// An empty suggestion returns the room and joined members that are not the current user
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(RoomMemberSuggestion.Room, RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
|
||||
|
||||
// A suggestion containing a part of "room" will also return the room mention
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Room)
|
||||
|
||||
// A non-empty suggestion will return those joined members whose user id matches it
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(bob))
|
||||
|
||||
// A non-empty suggestion will return those joined members whose display name matches it
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
|
||||
assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(david))
|
||||
|
||||
// If the suggestion isn't a mention, no suggestions are returned
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
|
||||
assertThat(awaitItem().memberSuggestions).isEmpty()
|
||||
|
||||
// If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
|
||||
room.givenCanTriggerRoomNotification(Result.success(false))
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
|
||||
|
||||
// If room is a DM, `RoomMemberSuggestion.Room` is not returned
|
||||
room.givenCanTriggerRoomNotification(Result.success(true))
|
||||
room.isDirect
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - room member mention suggestions in a DM`() = runTest {
|
||||
val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
|
||||
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
|
||||
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
|
||||
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
|
||||
val room = FakeMatrixRoom(
|
||||
isDirect = true,
|
||||
isOneToOne = true,
|
||||
).apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(
|
||||
immutableListOf(currentUser, invitedUser, bob, david),
|
||||
))
|
||||
givenCanTriggerRoomNotification(Result.success(true))
|
||||
}
|
||||
val flagsService = FakeFeatureFlagService(
|
||||
mapOf(
|
||||
FeatureFlags.Mentions.key to true,
|
||||
)
|
||||
)
|
||||
val presenter = createPresenter(this, room, featureFlagService = flagsService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
|
||||
// An empty suggestion returns the joined members that are not the current user, but not the room
|
||||
initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
|
||||
assertThat(awaitItem().memberSuggestions)
|
||||
.containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
|
||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||
skipItems(skipCount)
|
||||
|
|
@ -733,6 +845,7 @@ class MessageComposerPresenterTest {
|
|||
analyticsService,
|
||||
MessageComposerContextImpl(),
|
||||
TestRichTextEditorStateFactory(),
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,41 +16,27 @@
|
|||
|
||||
package io.element.android.features.preferences.impl.notifications
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
|
|
@ -197,41 +183,14 @@ private fun InvalidNotificationSettingsView(
|
|||
onDismissError: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(CommonStrings.screen_notification_settings_configuration_mismatch),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(CommonStrings.screen_notification_settings_configuration_mismatch_description),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onContinueClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogLikeBannerMolecule(
|
||||
modifier = modifier,
|
||||
title = stringResource(CommonStrings.screen_notification_settings_configuration_mismatch),
|
||||
content = stringResource(CommonStrings.screen_notification_settings_configuration_mismatch_description),
|
||||
onSubmitClicked = onContinueClicked,
|
||||
onDismissClicked = null,
|
||||
)
|
||||
|
||||
if (showError) {
|
||||
ErrorDialog(
|
||||
title = stringResource(id = CommonStrings.dialog_title_error),
|
||||
|
|
|
|||
|
|
@ -16,31 +16,13 @@
|
|||
|
||||
package io.element.android.features.roomlist.impl.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.roomlist.impl.R
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
||||
@Composable
|
||||
internal fun RequestVerificationHeader(
|
||||
|
|
@ -48,50 +30,20 @@ internal fun RequestVerificationHeader(
|
|||
onDismissClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(R.string.session_verification_banner_title),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = onDismissClicked),
|
||||
resourceId = CommonDrawables.ic_compound_close,
|
||||
contentDescription = stringResource(CommonStrings.action_close)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.session_verification_banner_message),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onVerifyClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DialogLikeBannerMolecule(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.session_verification_banner_title),
|
||||
content = stringResource(R.string.session_verification_banner_message),
|
||||
onSubmitClicked = onVerifyClicked,
|
||||
onDismissClicked = onDismissClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RequestVerificationHeaderPreview() = ElementPreview {
|
||||
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
|
||||
RequestVerificationHeader(
|
||||
onVerifyClicked = {},
|
||||
onDismissClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue