Merge branch 'develop' of https://github.com/vector-im/element-x-android into langleyd/live_waveform

This commit is contained in:
David Langley 2023-10-27 12:28:46 +01:00
commit 37f8195956
352 changed files with 2007 additions and 254 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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()

View file

@ -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 = {}
)
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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 = {},
)

View file

@ -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,
)
}

View file

@ -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
}
}

View file

@ -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>

View file

@ -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,

View file

@ -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),
)
}

View file

@ -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),

View file

@ -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 = {},
)
}