mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 03:06:14 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
+61
-83
@@ -9,9 +9,11 @@
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
@@ -43,18 +45,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.appconfig.RoomListConfig
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -296,11 +290,15 @@ private fun NavigationIcon(
|
||||
onAccountSwitch: (SessionId) -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
// Reserve the status emoji slot for all the accounts, else the width of the navigation icon,
|
||||
// and so the position of the title, would change while scrolling the pager.
|
||||
val reserveStatusEmojiSlot = currentUserAndNeighbors.any { it.displayedStatus != null }
|
||||
if (currentUserAndNeighbors.size == 1) {
|
||||
AccountIcon(
|
||||
matrixUser = currentUserAndNeighbors.single(),
|
||||
isCurrentAccount = true,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
|
||||
onClick = onClick,
|
||||
)
|
||||
} else {
|
||||
@@ -321,6 +319,7 @@ private fun NavigationIcon(
|
||||
matrixUser = currentUserAndNeighbors[page],
|
||||
isCurrentAccount = page == 1,
|
||||
showAvatarIndicator = page == 1 && showAvatarIndicator,
|
||||
reserveStatusEmojiSlot = reserveStatusEmojiSlot,
|
||||
onClick = if (page == 1) {
|
||||
onClick
|
||||
} else {
|
||||
@@ -336,95 +335,74 @@ private fun AccountIcon(
|
||||
matrixUser: MatrixUser,
|
||||
isCurrentAccount: Boolean,
|
||||
showAvatarIndicator: Boolean,
|
||||
reserveStatusEmojiSlot: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
Row(
|
||||
modifier = modifier
|
||||
.then(testTag)
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
indication = ripple(bounded = false),
|
||||
// The ripple is rendered on the avatar only, see below.
|
||||
indication = null,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.indication(
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple(bounded = false),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
|
||||
}
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
contentDescription = if (isCurrentAccount) {
|
||||
if (showAvatarIndicator) {
|
||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||
} else {
|
||||
stringResource(CommonStrings.common_settings)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
|
||||
val avatarModifier = if (statusEmoji != null) {
|
||||
Modifier.eraseStatusEmojiBackground(
|
||||
parentSize = AvatarSize.CurrentUserTopBar.dp,
|
||||
layoutDirection = LocalLayoutDirection.current,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
modifier = avatarModifier,
|
||||
contentDescription = if (isCurrentAccount) {
|
||||
if (showAvatarIndicator) {
|
||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||
} else {
|
||||
stringResource(CommonStrings.common_settings)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
if (statusEmoji != null) {
|
||||
StatusEmojiBadge(
|
||||
emoji = statusEmoji,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
if (reserveStatusEmojiSlot) {
|
||||
StatusEmoji(
|
||||
emoji = matrixUser.displayedStatus?.toEmojiText(),
|
||||
modifier = Modifier.offset(x = statusEmojiStartOffset),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val statusEmojiBadgeSize = 20.dp
|
||||
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
|
||||
private val statusEmojiBadgeOffset = 8.dp
|
||||
private val statusEmojiSize = 20.dp
|
||||
|
||||
private fun Modifier.eraseStatusEmojiBackground(
|
||||
parentSize: Dp,
|
||||
layoutDirection: LayoutDirection,
|
||||
): Modifier = this
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = if (layoutDirection == LayoutDirection.Ltr) {
|
||||
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
|
||||
} else {
|
||||
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
|
||||
},
|
||||
y = size.height - statusEmojiBadgeRadius.toPx(),
|
||||
),
|
||||
radius = statusEmojiBadgeRadius.toPx(),
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
// The avatar is centered inside a 48dp touch target, so there is already 8dp of free space
|
||||
// on its trailing side. Compensate it to end up with a 4dp gap after the avatar itself.
|
||||
private val statusEmojiStartOffset = 4.dp - (48.dp - AvatarSize.CurrentUserTopBar.dp) / 2
|
||||
|
||||
@Composable
|
||||
private fun StatusEmojiBadge(
|
||||
emoji: String,
|
||||
private fun StatusEmoji(
|
||||
emoji: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -433,16 +411,16 @@ private fun StatusEmojiBadge(
|
||||
Density(density = density.density, fontScale = 1f)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.offset(x = statusEmojiBadgeOffset)
|
||||
.size(statusEmojiBadgeSize),
|
||||
modifier = modifier.size(statusEmojiSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
if (emoji != null) {
|
||||
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
|
||||
Text(
|
||||
text = emoji,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-21
@@ -8,7 +8,6 @@
|
||||
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -23,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -129,6 +127,9 @@ private fun SkeletonView(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyView(
|
||||
state: RoomListContentState.Empty,
|
||||
@@ -139,13 +140,19 @@ private fun EmptyView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val text = buildString {
|
||||
append(stringResource(R.string.screen_roomlist_empty_title))
|
||||
append("\n")
|
||||
append(stringResource(R.string.screen_roomlist_empty_message))
|
||||
}
|
||||
EmptyScaffold(
|
||||
title = R.string.screen_roomlist_empty_title,
|
||||
subtitle = R.string.screen_roomlist_empty_message,
|
||||
action = {
|
||||
title = null,
|
||||
subtitle = text,
|
||||
bottomPart = {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_start_chat),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Chat()),
|
||||
onClick = onCreateRoomClick,
|
||||
)
|
||||
},
|
||||
@@ -280,6 +287,7 @@ private fun RoomsViewList(
|
||||
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
|
||||
state.seenRoomInvites.contains(room.roomId),
|
||||
showUnreadCount = state.showUnreadCount,
|
||||
showAllActivity = state.showAllActivity,
|
||||
onClick = onRoomClick,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -298,39 +306,44 @@ private fun EmptyViewForFilterStates(
|
||||
) {
|
||||
val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return
|
||||
EmptyScaffold(
|
||||
title = emptyStateResources.title,
|
||||
subtitle = emptyStateResources.subtitle,
|
||||
title = stringResource(emptyStateResources.title),
|
||||
subtitle = stringResource(emptyStateResources.subtitle),
|
||||
modifier = modifier,
|
||||
bottomPart = {
|
||||
// Keep a spacer so that the content is not too low on the screen
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyScaffold(
|
||||
@StringRes title: Int,
|
||||
@StringRes subtitle: Int,
|
||||
title: String?,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: @Composable (ColumnScope.() -> Unit)? = null,
|
||||
bottomPart: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 60.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(subtitle),
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
action?.invoke(this)
|
||||
bottomPart()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-14
@@ -80,6 +80,7 @@ internal fun RoomSummaryRow(
|
||||
onClick: (RoomListRoomSummary) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showUnreadCount: Boolean = false,
|
||||
showAllActivity: Boolean = false,
|
||||
eventSink: (RoomListEvent) -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -131,7 +132,7 @@ internal fun RoomSummaryRow(
|
||||
isHighlighted = room.isHighlighted,
|
||||
dmUserStatus = room.dmUserStatus,
|
||||
)
|
||||
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
|
||||
MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount, showAllActivity = showAllActivity)
|
||||
}
|
||||
}
|
||||
RoomSummaryDisplayType.KNOCKED -> {
|
||||
@@ -278,6 +279,7 @@ private fun InviteSubtitle(
|
||||
private fun MessagePreviewAndIndicatorRow(
|
||||
room: RoomListRoomSummary,
|
||||
showUnreadCount: Boolean,
|
||||
showAllActivity: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
@@ -348,7 +350,7 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
.height(16.dp)
|
||||
// Used to force this line to be read aloud earlier than the latest event when using Talkback
|
||||
.zIndex(-1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
|
||||
@@ -358,25 +360,17 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
isAudio = room.activeCallIntent == CallIntent.AUDIO
|
||||
)
|
||||
}
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
|
||||
if (isMuted) {
|
||||
NotificationOffIndicatorAtom()
|
||||
} else if (room.numberOfUnreadMentions > 0) {
|
||||
MentionIndicatorAtom()
|
||||
}
|
||||
if (room.hasNewContent) {
|
||||
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
|
||||
val count = if (showUnreadCount) {
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
room.numberOfUnreadMessages
|
||||
} else {
|
||||
room.numberOfUnreadNotifications
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
UnreadIndicatorAtom(
|
||||
color = tint,
|
||||
count = count,
|
||||
count = room.unreadIndicatorCount(showUnreadCount = showUnreadCount, showAllActivity = showAllActivity),
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
@@ -384,6 +378,27 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the value to pass to [UnreadIndicatorAtom] as `count`.
|
||||
* - `null`: nothing is displayed.
|
||||
* - `0`: dot indicator without a count.
|
||||
* - `> 0`: the number to display.
|
||||
*/
|
||||
internal fun RoomListRoomSummary.unreadIndicatorCount(
|
||||
showUnreadCount: Boolean,
|
||||
showAllActivity: Boolean,
|
||||
): Long? {
|
||||
val isMuted = userDefinedNotificationMode == RoomNotificationMode.MUTE
|
||||
return when {
|
||||
// Show only unread notifications when the room is not muted and there are unread notifications
|
||||
!isMuted && numberOfUnreadNotifications > 0 -> if (showUnreadCount) numberOfUnreadNotifications else 0
|
||||
// Otherwise, show a dot for unread messages when showAllActivity is true and there are unread messages
|
||||
showAllActivity -> if (numberOfUnreadMessages > 0) 0 else null
|
||||
// Or don't display anything
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteNameAndIndicatorRow(
|
||||
name: String?,
|
||||
@@ -408,7 +423,8 @@ private fun InviteNameAndIndicatorRow(
|
||||
)
|
||||
if (!isInviteSeen) {
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator
|
||||
color = ElementTheme.colors.unreadIndicator,
|
||||
count = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -457,5 +473,7 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
|
||||
isInviteSeen = data.name == "Bob",
|
||||
onClick = {},
|
||||
eventSink = {},
|
||||
showUnreadCount = data.numberOfUnreadNotifications > 0,
|
||||
showAllActivity = true,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
|
||||
numberOfUnreadMentions = 1,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = roomNotificationMode.name,
|
||||
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
|
||||
notificationMode = roomNotificationMode,
|
||||
numberOfUnreadMessages = 0,
|
||||
numberOfUnreadMentions = 0,
|
||||
numberOfUnreadNotifications = 1,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = roomNotificationMode.name,
|
||||
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
|
||||
notificationMode = roomNotificationMode,
|
||||
numberOfUnreadMessages = 0,
|
||||
numberOfUnreadMentions = 0,
|
||||
numberOfUnreadNotifications = 100,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}.flatten(),
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ internal fun aRoomsContentState(
|
||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
|
||||
showAllActivity: Boolean = false,
|
||||
seenRoomInvites: Set<RoomId> = emptySet(),
|
||||
) = RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
@@ -48,6 +49,7 @@ internal fun aRoomsContentState(
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||
batteryOptimizationState = batteryOptimizationState,
|
||||
summaries = summaries,
|
||||
showAllActivity = showAllActivity,
|
||||
seenRoomInvites = seenRoomInvites.toImmutableSet(),
|
||||
)
|
||||
|
||||
|
||||
+8
-1
@@ -118,6 +118,10 @@ class RoomListPresenter(
|
||||
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
|
||||
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
|
||||
|
||||
val showAllActivity by produceState(initialValue = false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList).collect { value = it }
|
||||
}
|
||||
|
||||
fun handleEvent(event: RoomListEvent) {
|
||||
when (event) {
|
||||
is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch {
|
||||
@@ -165,13 +169,14 @@ class RoomListPresenter(
|
||||
|
||||
val canReportRoom by produceState(false) { value = client.canReportRoom() }
|
||||
val showUnreadCount by produceState(false) {
|
||||
value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount)
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UnreadIndicatorCount).collect { value = it }
|
||||
}
|
||||
|
||||
val contentState = roomListContentState(
|
||||
securityBannerDismissed,
|
||||
showNewNotificationSoundBanner,
|
||||
showUnreadCount,
|
||||
showAllActivity,
|
||||
)
|
||||
|
||||
return RoomListState(
|
||||
@@ -230,6 +235,7 @@ class RoomListPresenter(
|
||||
securityBannerDismissed: Boolean,
|
||||
showNewNotificationSoundBanner: Boolean,
|
||||
showUnreadCount: Boolean,
|
||||
showAllActivity: Boolean,
|
||||
): RoomListContentState {
|
||||
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
|
||||
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
|
||||
@@ -262,6 +268,7 @@ class RoomListPresenter(
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
|
||||
showAllActivity = showAllActivity,
|
||||
seenRoomInvites = seenRoomInvites.toImmutableSet(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -76,5 +76,6 @@ sealed interface RoomListContentState {
|
||||
val showUnreadCount: Boolean,
|
||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||
val seenRoomInvites: ImmutableSet<RoomId>,
|
||||
val showAllActivity: Boolean,
|
||||
) : RoomListContentState
|
||||
}
|
||||
|
||||
+7
-1
@@ -29,5 +29,11 @@ sealed interface GlobalSearchEvent {
|
||||
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
|
||||
|
||||
/** The user selected a search result from the search history. */
|
||||
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
|
||||
data class SearchHistoryResultSelected(val resultItem: SearchHistoryListItem) : GlobalSearchEvent
|
||||
|
||||
/** The user removed a search history entry. */
|
||||
data class RemoveSearchHistoryResult(val result: SearchHistoryListItem) : GlobalSearchEvent
|
||||
|
||||
/** The user cleared the search history. */
|
||||
data object ClearSearchHistory : GlobalSearchEvent
|
||||
}
|
||||
|
||||
+22
-9
@@ -57,6 +57,7 @@ import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -87,14 +88,14 @@ class GlobalSearchPresenter(
|
||||
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
||||
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
|
||||
|
||||
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) {
|
||||
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
|
||||
searchHistoryStore.history.collectLatest { history ->
|
||||
val mappedHistoryItems = history.mapNotNull { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
|
||||
is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term)
|
||||
is SearchHistoryResult.Room -> {
|
||||
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
|
||||
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
|
||||
SearchHistoryListItem.Room(roomId = result.roomId, roomInfo = roomInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +237,7 @@ class GlobalSearchPresenter(
|
||||
}
|
||||
is GlobalSearchEvent.SearchHistoryResultSelected -> {
|
||||
when (event.resultItem) {
|
||||
is SearchHistoryResultItem.Query -> coroutineScope.launch {
|
||||
is SearchHistoryListItem.Query -> coroutineScope.launch {
|
||||
delay(300.milliseconds)
|
||||
queryState.edit {
|
||||
replace(0, length, event.resultItem.term)
|
||||
@@ -244,9 +245,21 @@ class GlobalSearchPresenter(
|
||||
searchResults = AsyncData.Loading()
|
||||
currentTarget = GlobalSearchTarget.ROOMS
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> Unit
|
||||
is SearchHistoryListItem.Room -> Unit
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.RemoveSearchHistoryResult -> coroutineScope.launch {
|
||||
val currentHistory = searchHistoryStore.history.first()
|
||||
val id = when (event.result) {
|
||||
is SearchHistoryListItem.Query -> event.result.term
|
||||
is SearchHistoryListItem.Room -> event.result.roomId.value
|
||||
}
|
||||
val result = currentHistory.find { it.id == id } ?: return@launch
|
||||
searchHistoryStore.remove(result)
|
||||
}
|
||||
GlobalSearchEvent.ClearSearchHistory -> coroutineScope.launch {
|
||||
searchHistoryStore.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +278,7 @@ class GlobalSearchPresenter(
|
||||
result: MessageSearchResult,
|
||||
roomInfo: RoomInfo,
|
||||
formattedTimestamp: String,
|
||||
): MessageSearchResultItem.Message? {
|
||||
): MessageSearchListItem.Message? {
|
||||
val body = latestEventFormatter.format(
|
||||
latestEvent = LatestEventValue.Remote(
|
||||
timestamp = result.timestamp,
|
||||
@@ -276,7 +289,7 @@ class GlobalSearchPresenter(
|
||||
),
|
||||
isDmRoom = false,
|
||||
)
|
||||
return MessageSearchResultItem.Message(
|
||||
return MessageSearchListItem.Message(
|
||||
messageSearchResult = result,
|
||||
body = body?.toString() ?: "",
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
@@ -288,7 +301,7 @@ class GlobalSearchPresenter(
|
||||
result: MessageSearchResult,
|
||||
roomInfo: RoomInfo,
|
||||
formattedTimestamp: String,
|
||||
): MessageSearchResultItem.Media? {
|
||||
): MessageSearchListItem.Media? {
|
||||
val messageType = when (val content = result.content) {
|
||||
is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment
|
||||
else -> return null
|
||||
@@ -342,7 +355,7 @@ class GlobalSearchPresenter(
|
||||
blurhash = blurhash
|
||||
)
|
||||
|
||||
return MessageSearchResultItem.Media(
|
||||
return MessageSearchListItem.Media(
|
||||
messageSearchResult = result,
|
||||
mediaContent = mediaContent,
|
||||
formattedTimestamp = formattedTimestamp,
|
||||
|
||||
+31
-8
@@ -28,20 +28,26 @@ data class GlobalSearchState(
|
||||
val queryState: TextFieldState,
|
||||
val currentTarget: GlobalSearchTarget,
|
||||
val results: AsyncData<GlobalSearchResults>,
|
||||
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
|
||||
val history: AsyncData<ImmutableList<SearchHistoryListItem>>,
|
||||
val eventSink: (GlobalSearchEvent) -> Unit
|
||||
)
|
||||
|
||||
/**
|
||||
* The target of the global search: either rooms or messages.
|
||||
*/
|
||||
@Immutable
|
||||
enum class GlobalSearchTarget {
|
||||
ROOMS,
|
||||
MESSAGES,
|
||||
}
|
||||
|
||||
/**
|
||||
* The results of a global search, either a list of rooms or a list of messages.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface GlobalSearchResults {
|
||||
data class RoomListResults(val results: ImmutableList<RoomListRoomSummary>) : GlobalSearchResults
|
||||
data class MessageSearchResults(val results: ImmutableList<MessageSearchResultItem>) : GlobalSearchResults
|
||||
data class MessageSearchResults(val results: ImmutableList<MessageSearchListItem>) : GlobalSearchResults
|
||||
|
||||
fun isEmpty(): Boolean = when (this) {
|
||||
is RoomListResults -> results.isEmpty()
|
||||
@@ -49,7 +55,10 @@ sealed interface GlobalSearchResults {
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface MessageSearchResultItem {
|
||||
/**
|
||||
* A single item in the message search results. It's the UI representation of [MessageSearchResult], which is the mapped model returned by the Matrix SDK.
|
||||
*/
|
||||
sealed interface MessageSearchListItem {
|
||||
val roomId: RoomId
|
||||
val eventId: EventId
|
||||
val senderId: UserId
|
||||
@@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem {
|
||||
val roomInfo: RoomInfo
|
||||
val formattedTimestamp: String
|
||||
|
||||
/**
|
||||
* A message item in the message search results.
|
||||
*/
|
||||
data class Message(
|
||||
val messageSearchResult: MessageSearchResult,
|
||||
val body: String,
|
||||
override val roomInfo: RoomInfo,
|
||||
override val formattedTimestamp: String,
|
||||
) : MessageSearchResultItem {
|
||||
) : MessageSearchListItem {
|
||||
override val roomId: RoomId = messageSearchResult.roomId
|
||||
override val eventId: EventId = messageSearchResult.eventId
|
||||
override val senderId: UserId = messageSearchResult.senderId
|
||||
override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with media item in the message search results.
|
||||
*/
|
||||
data class Media(
|
||||
val messageSearchResult: MessageSearchResult,
|
||||
val mediaContent: MediaSearchResultContent,
|
||||
override val roomInfo: RoomInfo,
|
||||
override val formattedTimestamp: String,
|
||||
) : MessageSearchResultItem {
|
||||
) : MessageSearchListItem {
|
||||
override val roomId: RoomId = messageSearchResult.roomId
|
||||
override val eventId: EventId = messageSearchResult.eventId
|
||||
override val senderId: UserId = messageSearchResult.senderId
|
||||
@@ -82,20 +97,28 @@ sealed interface MessageSearchResultItem {
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SearchHistoryResultItem {
|
||||
/**
|
||||
* A single entry in the global search history, displayed in the list of recent searches.
|
||||
*
|
||||
* It's the UI representation of [io.element.android.features.home.impl.search.history.SearchHistoryResult], which is the persisted model.
|
||||
*/
|
||||
sealed interface SearchHistoryListItem {
|
||||
val id: String
|
||||
|
||||
data class Query(val term: String) : SearchHistoryResultItem {
|
||||
data class Query(val term: String) : SearchHistoryListItem {
|
||||
override val id: String = "query:$term"
|
||||
}
|
||||
data class Room(
|
||||
val roomId: RoomId,
|
||||
val roomInfo: RoomInfo,
|
||||
) : SearchHistoryResultItem {
|
||||
) : SearchHistoryListItem {
|
||||
override val id: String = "room:${roomId.value}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the media content of a message search result.
|
||||
*/
|
||||
data class MediaSearchResultContent(
|
||||
val filename: String,
|
||||
val extension: String?,
|
||||
|
||||
+9
-9
@@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
MessageSearchListItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
|
||||
body = "A message with Query",
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
MessageSearchResultItem.Media(
|
||||
MessageSearchListItem.Media(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
|
||||
mediaContent = MediaSearchResultContent(
|
||||
filename = "file.png",
|
||||
@@ -114,9 +114,9 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(
|
||||
persistentListOf(
|
||||
SearchHistoryResultItem.Query("Query 1"),
|
||||
SearchHistoryResultItem.Query("Query 2"),
|
||||
SearchHistoryResultItem.Room(
|
||||
SearchHistoryListItem.Query("Query 1"),
|
||||
SearchHistoryListItem.Query("Query 2"),
|
||||
SearchHistoryListItem.Room(
|
||||
roomId = roomId1,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId1,
|
||||
@@ -125,7 +125,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
SearchHistoryListItem.Room(
|
||||
roomId = roomId2,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId2,
|
||||
@@ -133,7 +133,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
SearchHistoryListItem.Room(
|
||||
roomId = dmRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = dmRoomId,
|
||||
@@ -144,7 +144,7 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
SearchHistoryListItem.Room(
|
||||
roomId = tombstonedRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = tombstonedRoomId,
|
||||
@@ -300,7 +300,7 @@ internal fun aGlobalSearchState(
|
||||
queryState: TextFieldState = TextFieldState(),
|
||||
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
|
||||
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
|
||||
history: AsyncData<ImmutableList<SearchHistoryListItem>> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
|
||||
+63
-20
@@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -31,9 +33,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -67,6 +71,8 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.FilledTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
@@ -75,6 +81,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
|
||||
import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding
|
||||
@@ -155,10 +162,14 @@ private fun GlobalSearchContent(
|
||||
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
|
||||
when {
|
||||
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
|
||||
searchHistory(historyResults) { result ->
|
||||
searchHistory(
|
||||
historyResults = historyResults,
|
||||
onRemoveResult = { item -> state.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(item)) },
|
||||
onClearAllResults = { state.eventSink(GlobalSearchEvent.ClearSearchHistory) },
|
||||
) { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> Unit
|
||||
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
|
||||
is SearchHistoryListItem.Query -> Unit
|
||||
is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
|
||||
}
|
||||
|
||||
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
|
||||
@@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) {
|
||||
modifier = Modifier.clip(CircleShape).size(20.dp),
|
||||
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.borderInteractivePrimary,
|
||||
containerColor = ElementTheme.colors.iconSecondary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary
|
||||
)
|
||||
) {
|
||||
@@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() {
|
||||
}
|
||||
|
||||
private fun LazyListScope.searchHistory(
|
||||
historyResults: ImmutableList<SearchHistoryResultItem>,
|
||||
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
|
||||
historyResults: ImmutableList<SearchHistoryListItem>,
|
||||
onRemoveResult: (SearchHistoryListItem) -> Unit,
|
||||
onClearAllResults: () -> Unit,
|
||||
onSelectSearchHistoryResult: (SearchHistoryListItem) -> Unit,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
text = stringResource(R.string.screen_search_recent_searches_section_title),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp).weight(1f),
|
||||
text = stringResource(R.string.screen_search_recent_searches_section_title),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_clear_all),
|
||||
size = ButtonSize.Small,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = ElementTheme.colors.textActionAccent),
|
||||
onClick = onClearAllResults,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = historyResults,
|
||||
key = { it.id },
|
||||
) { result ->
|
||||
val twoLineHeight =
|
||||
// Vertical padding
|
||||
12.dp * 2 +
|
||||
// Text line heights
|
||||
ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() +
|
||||
ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
|
||||
.clickable(onClick = { onSelectSearchHistoryResult(result) })
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
.padding(start = 16.dp, end = 2.dp)
|
||||
.height(twoLineHeight),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> {
|
||||
is SearchHistoryListItem.Query -> {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
@@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory(
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
text = result.term,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> {
|
||||
is SearchHistoryListItem.Room -> {
|
||||
val heroAvatars = remember(result.roomInfo.heroes) {
|
||||
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
|
||||
}
|
||||
@@ -333,7 +366,7 @@ private fun LazyListScope.searchHistory(
|
||||
),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.weight(1f, fill = true)) {
|
||||
Text(
|
||||
text = result.roomInfo.name.orEmpty(),
|
||||
maxLines = 1,
|
||||
@@ -365,6 +398,16 @@ private fun LazyListScope.searchHistory(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
modifier = Modifier.minimumInteractiveComponentSize()
|
||||
.niceClickable {
|
||||
onRemoveResult(result)
|
||||
},
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_remove),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +494,7 @@ private fun LazyListScope.roomListResults(
|
||||
}
|
||||
|
||||
private fun LazyListScope.messageSearchResults(
|
||||
results: ImmutableList<MessageSearchResultItem>,
|
||||
results: ImmutableList<MessageSearchListItem>,
|
||||
onSearchResultSelected: (RoomId, EventId?) -> Unit,
|
||||
) {
|
||||
if (results.isNotEmpty()) {
|
||||
@@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults(
|
||||
) { index, result ->
|
||||
val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) })
|
||||
when (result) {
|
||||
is MessageSearchResultItem.Message -> TextMessageSearchResultItemView(
|
||||
is MessageSearchListItem.Message -> TextMessageSearchResultItemView(
|
||||
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
@@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults(
|
||||
body = result.body,
|
||||
formattedTimestamp = result.formattedTimestamp,
|
||||
)
|
||||
is MessageSearchResultItem.Media -> {
|
||||
is MessageSearchListItem.Media -> {
|
||||
MediaMessageSearchResultItemView(
|
||||
modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex),
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem),
|
||||
|
||||
+11
@@ -54,6 +54,17 @@ class DefaultSearchHistoryStore(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(result: SearchHistoryResult) {
|
||||
withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
ensureLoadedLocked()
|
||||
val updated = cache.value.filterNot { it == result }
|
||||
cache.value = updated
|
||||
persist(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
isLoaded = true
|
||||
|
||||
+9
-3
@@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface SearchHistoryResult {
|
||||
@Serializable
|
||||
data class Query(val term: String) : SearchHistoryResult
|
||||
val id: String
|
||||
|
||||
@Serializable
|
||||
data class Room(val roomId: RoomId) : SearchHistoryResult
|
||||
data class Query(val term: String) : SearchHistoryResult {
|
||||
override val id: String = term
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Room(val roomId: RoomId) : SearchHistoryResult {
|
||||
override val id: String = roomId.value
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -30,6 +30,11 @@ interface SearchHistoryStore {
|
||||
*/
|
||||
suspend fun add(result: SearchHistoryResult)
|
||||
|
||||
/**
|
||||
* Remove a [result] from the history.
|
||||
*/
|
||||
suspend fun remove(result: SearchHistoryResult)
|
||||
|
||||
/**
|
||||
* Remove every entry from the history.
|
||||
*/
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.home.impl.components
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.impl.model.createRoomListRoomSummary
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import org.junit.Test
|
||||
|
||||
class RoomSummaryRowUnreadIndicatorCountTest {
|
||||
@Test
|
||||
fun `unread notifications are used when the room is not muted`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 3,
|
||||
numberOfUnreadMessages = 5,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isEqualTo(3L)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unread notifications only show a dot when showUnreadCount is false`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 3,
|
||||
numberOfUnreadMessages = 5,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isEqualTo(0L)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unread messages are used when there are no unread notifications and showAllActivity is true, but always display a dot`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 0,
|
||||
numberOfUnreadMessages = 5,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unread messages are ignored when showAllActivity is false`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 0,
|
||||
numberOfUnreadMessages = 5,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `muted room ignores unread notifications and falls back to unread messages displaying a dot when showAllActivity is true`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 3,
|
||||
numberOfUnreadMessages = 5,
|
||||
userDefinedNotificationMode = RoomNotificationMode.MUTE,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no count when there are no unread notifications nor unread messages`() {
|
||||
val room = createRoomListRoomSummary(
|
||||
numberOfUnreadNotifications = 0,
|
||||
numberOfUnreadMessages = 0,
|
||||
isMarkedUnread = true,
|
||||
)
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isNull()
|
||||
assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull()
|
||||
}
|
||||
}
|
||||
+50
-4
@@ -39,6 +39,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
@@ -218,7 +219,7 @@ class RoomListPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
|
||||
// Skip intermediate event for loading the value of the feature flags
|
||||
skipItems(1)
|
||||
|
||||
val summary = createRoomListRoomSummary()
|
||||
@@ -265,7 +266,7 @@ class RoomListPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
|
||||
// Skip intermediate event for loading the value of the feature flags
|
||||
skipItems(1)
|
||||
|
||||
val summary = createRoomListRoomSummary()
|
||||
@@ -318,7 +319,7 @@ class RoomListPresenterTest {
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// Skip intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
|
||||
// Skip intermediate event for loading the value of the feature flags
|
||||
skipItems(1)
|
||||
|
||||
eventRecorder.assertEmpty()
|
||||
@@ -629,7 +630,7 @@ class RoomListPresenterTest {
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(announcementService.announcementsToShowFlow().first()).isEmpty()
|
||||
skipItems(2) // Skip initial state and intermediate event for loading the value of the `UnreadIndicatorCount` feature flag
|
||||
skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
|
||||
val state = awaitItem()
|
||||
assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse()
|
||||
announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound))
|
||||
@@ -643,6 +644,51 @@ class RoomListPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
|
||||
val roomList = FakeDynamicRoomList(
|
||||
summaries = MutableStateFlow(listOf(aRoomSummary())),
|
||||
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)),
|
||||
)
|
||||
val matrixClient = FakeMatrixClient(
|
||||
roomListService = FakeRoomListService(createRoomListLambda = { roomList }),
|
||||
)
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(
|
||||
FeatureFlags.ShowAllActivityInRoomList.key to true,
|
||||
FeatureFlags.UnreadIndicatorCount.key to true,
|
||||
)
|
||||
)
|
||||
val presenter = createRoomListPresenter(
|
||||
client = matrixClient,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
presenter.test {
|
||||
val enabledState = consumeItemsUntilPredicate { state ->
|
||||
state.contentState is RoomListContentState.Rooms &&
|
||||
state.contentAsRooms().showAllActivity &&
|
||||
state.contentAsRooms().showUnreadCount
|
||||
}.last()
|
||||
assertThat(enabledState.contentAsRooms().showAllActivity).isTrue()
|
||||
assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue()
|
||||
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
|
||||
val allActivityDisabledState = consumeItemsUntilPredicate { state ->
|
||||
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity
|
||||
}.last()
|
||||
assertThat(allActivityDisabledState.contentAsRooms().showAllActivity).isFalse()
|
||||
assertThat(allActivityDisabledState.contentAsRooms().showUnreadCount).isTrue()
|
||||
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.UnreadIndicatorCount, false)
|
||||
val unreadCountDisabledState = consumeItemsUntilPredicate { state ->
|
||||
state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showUnreadCount
|
||||
}.last()
|
||||
assertThat(unreadCountDisabledState.contentAsRooms().showAllActivity).isFalse()
|
||||
assertThat(unreadCountDisabledState.contentAsRooms().showUnreadCount).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTestMarkRoomAsRead(
|
||||
client: MatrixClient,
|
||||
notificationCleaner: NotificationCleaner,
|
||||
|
||||
+69
-4
@@ -224,7 +224,7 @@ class GlobalSearchPresenterTest {
|
||||
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ class GlobalSearchPresenterTest {
|
||||
val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last()
|
||||
val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results
|
||||
assertThat(results).hasSize(1)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java)
|
||||
assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java)
|
||||
|
||||
// Remove the query
|
||||
successState.queryState.edit { replace(0, length, "") }
|
||||
@@ -337,6 +337,71 @@ class GlobalSearchPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - RemoveSearchHistoryResult removes a query entry`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore().apply {
|
||||
add(SearchHistoryResult.Query("keep"))
|
||||
add(SearchHistoryResult.Query("drop"))
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("drop")))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - RemoveSearchHistoryResult removes a room entry`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore().apply {
|
||||
add(SearchHistoryResult.Query("keep"))
|
||||
add(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - RemoveSearchHistoryResult with an unknown id does nothing`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore().apply { add(SearchHistoryResult.Query("keep")) }
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("query:unknown")))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep"))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ClearSearchHistory removes every entry`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore().apply {
|
||||
add(SearchHistoryResult.Query("a"))
|
||||
add(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.ClearSearchHistory)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
@@ -345,7 +410,7 @@ class GlobalSearchPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo())))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
@@ -361,7 +426,7 @@ class GlobalSearchPresenterTest {
|
||||
// Skip loading search results state
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test")))
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Query("Test")))
|
||||
|
||||
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
|
||||
assertThat(awaitItem().results.isLoading()).isTrue()
|
||||
|
||||
+40
-3
@@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() {
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.MessageSearchResults(
|
||||
persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
MessageSearchListItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
|
||||
body = "A message body",
|
||||
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
|
||||
@@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() {
|
||||
|
||||
@Test
|
||||
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val queryResult = SearchHistoryResultItem.Query("Recent term")
|
||||
val queryResult = SearchHistoryListItem.Query("Recent term")
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
@@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() {
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the remove button of a history result - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val queryResult = SearchHistoryListItem.Query("Recent term")
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(persistentListOf(queryResult)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithContentDescription(activity!!.getString(CommonStrings.action_remove)).performClick()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.RemoveSearchHistoryResult(queryResult))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on clear all in the history - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(persistentListOf(SearchHistoryListItem.Query("Recent term"))),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithText("Clear all").performClick()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.ClearSearchHistory)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
|
||||
val roomId = RoomId("!aHistoryRoom:server.org")
|
||||
val roomResult = SearchHistoryResultItem.Room(
|
||||
val roomResult = SearchHistoryListItem.Room(
|
||||
roomId = roomId,
|
||||
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
|
||||
)
|
||||
|
||||
+25
@@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest {
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove deletes only the given entry and persists the change`() = runTest {
|
||||
val fileStore = FakeSearchHistoryFileStore()
|
||||
val store = createStore(fileStore)
|
||||
store.add(SearchHistoryResult.Query("a"))
|
||||
store.add(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
store.add(SearchHistoryResult.Query("b"))
|
||||
|
||||
store.remove(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
|
||||
val expected = listOf(SearchHistoryResult.Query("b"), SearchHistoryResult.Query("a"))
|
||||
assertThat(store.history.first()).containsExactlyElementsIn(expected).inOrder()
|
||||
assertThat(createStore(fileStore).history.first()).containsExactlyElementsIn(expected).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove of an unknown entry leaves the history unchanged`() = runTest {
|
||||
val store = createStore()
|
||||
store.add(SearchHistoryResult.Query("a"))
|
||||
|
||||
store.remove(SearchHistoryResult.Query("unknown"))
|
||||
|
||||
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Query("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear removes every entry and deletes the persisted content`() = runTest {
|
||||
val fileStore = FakeSearchHistoryFileStore()
|
||||
|
||||
+4
@@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore {
|
||||
}.take(MAX_SEARCH_HISTORY_SIZE)
|
||||
}
|
||||
|
||||
override suspend fun remove(result: SearchHistoryResult) {
|
||||
state.value = state.value.filterNot { it == result }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
state.value = emptyList()
|
||||
}
|
||||
|
||||
+5
-7
@@ -8,7 +8,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -650,8 +649,9 @@ class MessagesPresenter(
|
||||
event.eventId ?: return
|
||||
room.getPermalinkFor(event.eventId).fold(
|
||||
onSuccess = { permalink ->
|
||||
clipboardHelper.copyPlainText(permalink)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
|
||||
clipboardHelper.copyPlainText(permalink) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to get permalink for event ${event.eventId}")
|
||||
@@ -666,16 +666,14 @@ class MessagesPresenter(
|
||||
is TimelineItemStateContent -> event.content.body
|
||||
else -> return
|
||||
}
|
||||
clipboardHelper.copyPlainText(content)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
clipboardHelper.copyPlainText(content) {
|
||||
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCopyCaption(event: TimelineItem.Event) {
|
||||
val content = event.content.captionOrNull() ?: return
|
||||
clipboardHelper.copyPlainText(content)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
clipboardHelper.copyPlainText(content) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
@@ -26,8 +25,9 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
@@ -53,6 +53,8 @@ class PinnedMessagesListNode(
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
|
||||
@@ -114,7 +116,6 @@ class PinnedMessagesListNode(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
val view = LocalView.current
|
||||
val state = presenter.present()
|
||||
PinnedMessagesListView(
|
||||
@@ -132,10 +133,9 @@ class PinnedMessagesListNode(
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
)
|
||||
context.copyToClipboard(
|
||||
text = it.url,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
clipboardHelper.copyPlainText(it.url) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
modifier = modifier
|
||||
|
||||
+1
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
|
||||
UnreadIndicatorAtom(
|
||||
size = 14.dp,
|
||||
isVisible = hasUnreadNotifications,
|
||||
count = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,10 +28,10 @@ sealed interface TimelineEvent {
|
||||
data object HideShieldDialog : TimelineEvent
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent
|
||||
data class ToggleSelection(val eventId: EventId) : TimelineEvent
|
||||
data object ExitSelectionMode : TimelineEvent
|
||||
data class CopyToClipboard(val text: String) : TimelineEvent
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
|
||||
+9
@@ -44,6 +44,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted
|
||||
import io.element.android.features.poll.api.actions.EndPollAction
|
||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -112,6 +114,8 @@ class TimelinePresenter(
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val markAsFullyRead: MarkAsFullyRead,
|
||||
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Presenter<TimelineState> {
|
||||
private val tag = "TimelinePresenter"
|
||||
|
||||
@@ -355,6 +359,11 @@ class TimelinePresenter(
|
||||
is TimelineEvent.ValidateMedia -> {
|
||||
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
|
||||
}
|
||||
is TimelineEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-8
@@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
@@ -96,7 +95,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
|
||||
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
@@ -171,8 +169,6 @@ fun TimelineView(
|
||||
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
val view = LocalView.current
|
||||
fun inReplyToClick(eventId: EventId) {
|
||||
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
|
||||
@@ -182,10 +178,7 @@ fun TimelineView(
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
)
|
||||
context.copyToClipboard(
|
||||
text = link.url,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
state.eventSink(TimelineEvent.CopyToClipboard(link.url))
|
||||
}
|
||||
|
||||
fun prefetchMoreItems() {
|
||||
@@ -606,6 +599,7 @@ private fun JumpToPositionButton(
|
||||
.offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) },
|
||||
color = ElementTheme.colors.iconSuccessPrimary,
|
||||
border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault),
|
||||
count = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class EventDebugInfoNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(
|
||||
val eventId: EventId?,
|
||||
@@ -39,6 +44,12 @@ class EventDebugInfoNode(
|
||||
navigateUp()
|
||||
}
|
||||
|
||||
private fun onCopyText(text: String) {
|
||||
clipboardHelper.copyPlainText(text) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) = with(inputs) {
|
||||
EventDebugInfoView(
|
||||
@@ -46,7 +57,8 @@ class EventDebugInfoNode(
|
||||
model = timelineItemDebugInfo.model,
|
||||
originalJson = timelineItemDebugInfo.originalJson,
|
||||
latestEditedJson = timelineItemDebugInfo.latestEditedJson,
|
||||
onBackClick = ::onBackClick
|
||||
onBackClick = ::onBackClick,
|
||||
onCopyText = ::onCopyText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-13
@@ -8,8 +8,6 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.debug
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -34,11 +32,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.getSystemService
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
@@ -63,6 +59,7 @@ fun EventDebugInfoView(
|
||||
originalJson: String?,
|
||||
latestEditedJson: String?,
|
||||
onBackClick: () -> Unit,
|
||||
onCopyText: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isTest: Boolean = false,
|
||||
) {
|
||||
@@ -87,20 +84,30 @@ fun EventDebugInfoView(
|
||||
item {
|
||||
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = "Event ID:")
|
||||
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
|
||||
CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
item {
|
||||
CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded)
|
||||
CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded)
|
||||
}
|
||||
if (originalJson != null) {
|
||||
item {
|
||||
CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded)
|
||||
CollapsibleSection(
|
||||
title = "Original JSON:",
|
||||
text = prettyJSON(originalJson),
|
||||
onCopyText = onCopyText,
|
||||
initiallyExpanded = sectionsInitiallyExpanded,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (latestEditedJson != null) {
|
||||
item {
|
||||
CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded)
|
||||
CollapsibleSection(
|
||||
title = "Latest edited JSON:",
|
||||
text = prettyJSON(latestEditedJson),
|
||||
onCopyText = onCopyText,
|
||||
initiallyExpanded = sectionsInitiallyExpanded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String {
|
||||
private fun CollapsibleSection(
|
||||
title: String,
|
||||
text: String,
|
||||
onCopyText: (String) -> Unit,
|
||||
initiallyExpanded: Boolean = false,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
@@ -139,7 +147,7 @@ private fun CollapsibleSection(
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
|
||||
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
|
||||
CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,16 +155,15 @@ private fun CollapsibleSection(
|
||||
@Composable
|
||||
private fun CopyableText(
|
||||
text: String,
|
||||
onClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) }
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(6.dp)
|
||||
.clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) }
|
||||
.clickable { onClick(text) }
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -174,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview {
|
||||
model = "Rust(\n\tModel()\n)",
|
||||
originalJson = "{\"name\": \"original\"}",
|
||||
latestEditedJson = "{\"name\": \"edited\"}",
|
||||
onBackClick = { }
|
||||
onBackClick = { },
|
||||
onCopyText = { },
|
||||
)
|
||||
}
|
||||
|
||||
+41
@@ -33,6 +33,10 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.poll.test.actions.FakeEndPollAction
|
||||
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.aStandByCallState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
@@ -74,6 +78,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
@@ -1543,6 +1548,38 @@ class TimelinePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on old device shows a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createTimelinePresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
|
||||
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createTimelinePresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show shield hide shield`() = runTest {
|
||||
val presenter = createTimelinePresenter()
|
||||
@@ -2045,6 +2082,8 @@ class TimelinePresenterTest {
|
||||
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
|
||||
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
|
||||
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
): TimelinePresenter {
|
||||
return TimelinePresenter(
|
||||
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
|
||||
@@ -2072,6 +2111,8 @@ class TimelinePresenterTest {
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
markAsFullyRead = markAsFullyRead,
|
||||
timelineProtectionPresenter = timelineProtectionPresenter,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -81,8 +81,9 @@ class BlockedUsersPresenter(
|
||||
unblockUserAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
is BlockedUsersEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.userId.value)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
clipboardHelper.copyPlainText(event.userId.value) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
BlockedUsersEvent.ConfirmUnblock -> {
|
||||
pendingUserToUnblock?.let {
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent {
|
||||
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
|
||||
data object OpenPushRules : DeveloperSettingsEvent
|
||||
data object DismissPushRulesError : DeveloperSettingsEvent
|
||||
data class CopyToClipboard(val text: String) : DeveloperSettingsEvent
|
||||
}
|
||||
|
||||
+10
@@ -26,7 +26,9 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
|
||||
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -61,6 +64,8 @@ class DeveloperSettingsPresenter(
|
||||
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val notificationSettingsService: NotificationSettingsService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Presenter<DeveloperSettingsState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
@@ -130,6 +135,11 @@ class DeveloperSettingsPresenter(
|
||||
DeveloperSettingsEvent.DismissPushRulesError -> {
|
||||
pushRulesAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is DeveloperSettingsEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-12
@@ -15,14 +15,12 @@ import androidx.compose.foundation.progressSemantics
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
@@ -84,7 +82,10 @@ fun DeveloperSettingsView(
|
||||
AppDeveloperSettingsView(
|
||||
state = state.appDeveloperSettingsState,
|
||||
)
|
||||
SessionCategory(deviceId = state.deviceId)
|
||||
SessionCategory(
|
||||
deviceId = state.deviceId,
|
||||
onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) },
|
||||
)
|
||||
NotificationCategory(
|
||||
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
|
||||
onPushHistoryClick = onPushHistoryClick,
|
||||
@@ -182,19 +183,15 @@ fun DeveloperSettingsView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionCategory(deviceId: DeviceId) {
|
||||
private fun SessionCategory(
|
||||
deviceId: DeviceId,
|
||||
onCopyToClipboard: (String) -> Unit,
|
||||
) {
|
||||
PreferenceCategory(title = "Session") {
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
val context = LocalContext.current
|
||||
ListItem(
|
||||
content = { Text("DeviceId") },
|
||||
supportingContent = { Text(text = deviceId.value) },
|
||||
onClick = {
|
||||
context.copyToClipboard(
|
||||
text = deviceId.value,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
}
|
||||
onClick = { onCopyToClipboard(deviceId.value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent {
|
||||
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
|
||||
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
|
||||
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent
|
||||
data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent
|
||||
}
|
||||
|
||||
+10
@@ -22,6 +22,8 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
|
||||
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
|
||||
import io.element.android.features.preferences.impl.model.EnabledFeature
|
||||
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
@@ -29,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter(
|
||||
private val rageshakePresenter: Presenter<RageshakePreferencesState>,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Presenter<AppDeveloperSettingsState> {
|
||||
@Composable
|
||||
override fun present(): AppDeveloperSettingsState {
|
||||
@@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter(
|
||||
}
|
||||
appPreferencesStore.setTracingLogPacks(currentPacks)
|
||||
}
|
||||
is AppDeveloperSettingsEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-16
@@ -14,7 +14,6 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -23,7 +22,6 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
|
||||
@@ -108,6 +106,7 @@ fun AppDeveloperSettingsView(
|
||||
GitCategory(
|
||||
gitBranch = state.gitBranch,
|
||||
gitSha = state.gitSha,
|
||||
onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,29 +115,18 @@ fun AppDeveloperSettingsView(
|
||||
private fun GitCategory(
|
||||
gitBranch: String,
|
||||
gitSha: String,
|
||||
onCopyToClipboard: (String) -> Unit,
|
||||
) {
|
||||
PreferenceCategory(title = "Git") {
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
val context = LocalContext.current
|
||||
ListItem(
|
||||
content = { Text("Git branch") },
|
||||
supportingContent = { Text(text = gitBranch) },
|
||||
onClick = {
|
||||
context.copyToClipboard(
|
||||
text = gitBranch,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
}
|
||||
onClick = { onCopyToClipboard(gitBranch) }
|
||||
)
|
||||
ListItem(
|
||||
content = { Text("Git SHA") },
|
||||
supportingContent = { Text(text = gitSha) },
|
||||
onClick = {
|
||||
context.copyToClipboard(
|
||||
text = gitSha,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
}
|
||||
onClick = { onCopyToClipboard(gitSha) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -34,4 +34,5 @@ sealed interface NotificationSettingsEvent {
|
||||
data object DismissCallRingtoneDialog : NotificationSettingsEvent
|
||||
data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent
|
||||
data object LaunchCallRingtonePicker : NotificationSettingsEvent
|
||||
data object ToggleShowAllActivityInRoomList : NotificationSettingsEvent
|
||||
}
|
||||
|
||||
+12
@@ -28,6 +28,8 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
|
||||
@@ -73,6 +75,7 @@ class NotificationSettingsPresenter(
|
||||
private val stringProvider: StringProvider,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<NotificationSettingsState> {
|
||||
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
|
||||
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
|
||||
@@ -196,6 +199,11 @@ class NotificationSettingsPresenter(
|
||||
)
|
||||
}
|
||||
|
||||
val showAllActivityInRoomList by produceState(false) {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowAllActivityInRoomList)
|
||||
.collect { value = it }
|
||||
}
|
||||
|
||||
fun handleEvent(event: NotificationSettingsEvent) {
|
||||
when (event) {
|
||||
is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> {
|
||||
@@ -276,6 +284,9 @@ class NotificationSettingsPresenter(
|
||||
NotificationSettingsEvent.DismissCallRingtoneCopyError -> {
|
||||
callRingtoneCopyError = false
|
||||
}
|
||||
NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch {
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, !showAllActivityInRoomList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +315,7 @@ class NotificationSettingsPresenter(
|
||||
showCallRingtoneDialog = showCallRingtoneDialog,
|
||||
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
|
||||
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
|
||||
showAllActivityInRoomList = showAllActivityInRoomList,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -42,6 +42,10 @@ data class NotificationSettingsState(
|
||||
* auto-opens on screen entry.
|
||||
*/
|
||||
val pendingCallRingtonePickerLaunch: Int,
|
||||
/**
|
||||
* Whether to show all activity indicators in the room list, using unread messages instead of notifications.
|
||||
*/
|
||||
val showAllActivityInRoomList: Boolean,
|
||||
val eventSink: (NotificationSettingsEvent) -> Unit,
|
||||
) {
|
||||
sealed interface MatrixSettings {
|
||||
|
||||
+3
@@ -99,6 +99,7 @@ fun aValidNotificationSettingsState(
|
||||
callRingtoneCopyError: Boolean = false,
|
||||
showMessageSoundDialog: Boolean = false,
|
||||
showCallRingtoneDialog: Boolean = false,
|
||||
showAllActivityInRoomList: Boolean = false,
|
||||
pendingMessageSoundPickerLaunch: Int = 0,
|
||||
pendingCallRingtonePickerLaunch: Int = 0,
|
||||
eventSink: (NotificationSettingsEvent) -> Unit = {},
|
||||
@@ -133,6 +134,7 @@ fun aValidNotificationSettingsState(
|
||||
showCallRingtoneDialog = showCallRingtoneDialog,
|
||||
pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch,
|
||||
pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch,
|
||||
showAllActivityInRoomList = showAllActivityInRoomList,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
@@ -166,6 +168,7 @@ fun aInvalidNotificationSettingsState(
|
||||
showCallRingtoneDialog = false,
|
||||
pendingMessageSoundPickerLaunch = 0,
|
||||
pendingCallRingtonePickerLaunch = 0,
|
||||
showAllActivityInRoomList = false,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
||||
+10
@@ -199,6 +199,16 @@ private fun NotificationSettingsContentView(
|
||||
onCheckedChange = onMentionNotificationsChange
|
||||
)
|
||||
}
|
||||
|
||||
PreferenceCategory {
|
||||
PreferenceSwitch(
|
||||
title = stringResource(R.string.screen_notification_settings_show_all_activity_title),
|
||||
subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle),
|
||||
isChecked = state.showAllActivityInRoomList,
|
||||
onCheckedChange = { state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList) }
|
||||
)
|
||||
}
|
||||
|
||||
SoundsPreferenceCategory(state = state)
|
||||
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) {
|
||||
// TODO We are removing the call notification toggle until support for call notifications has been added
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
<string name="screen_notification_settings_mode_mentions">"Згадки"</string>
|
||||
<string name="screen_notification_settings_notification_section_title">"Сповіщати мене про"</string>
|
||||
<string name="screen_notification_settings_room_mention_label">"Згадки @room"</string>
|
||||
<string name="screen_notification_settings_show_all_activity">"Показати всю діяльність у списку кімнат (крапки або кількість непрочитаних повідомлень)"</string>
|
||||
<string name="screen_notification_settings_sound_custom_fallback">"Власний"</string>
|
||||
<string name="screen_notification_settings_sound_custom_sound_button_title">"Користувацький звук…"</string>
|
||||
<string name="screen_notification_settings_sound_delete_sound_error_title">"Помилка видалення файлу"</string>
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
<string name="screen_notification_settings_mode_mentions">"提及"</string>
|
||||
<string name="screen_notification_settings_notification_section_title">"通知我以下类型"</string>
|
||||
<string name="screen_notification_settings_room_mention_label">"含有 @room 的提及"</string>
|
||||
<string name="screen_notification_settings_show_all_activity">"在房间列表上显示所有活动(用于未读消息的圆点或数字)"</string>
|
||||
<string name="screen_notification_settings_sound_custom_fallback">"自定义"</string>
|
||||
<string name="screen_notification_settings_sound_custom_sound_button_title">"自定义提示音…"</string>
|
||||
<string name="screen_notification_settings_sound_delete_sound_error_title">"删除文件时出错"</string>
|
||||
|
||||
@@ -92,7 +92,8 @@ If you proceed, some of your settings may change."</string>
|
||||
<string name="screen_notification_settings_mode_mentions">"Mentions"</string>
|
||||
<string name="screen_notification_settings_notification_section_title">"Notify me for"</string>
|
||||
<string name="screen_notification_settings_room_mention_label">"Mentions of @room"</string>
|
||||
<string name="screen_notification_settings_show_all_activity">"Show all activity in the room list (dots or number of unread messages)"</string>
|
||||
<string name="screen_notification_settings_show_all_activity_subtitle">"Dots or number of unread messages"</string>
|
||||
<string name="screen_notification_settings_show_all_activity_title">"Show all activity in the room list"</string>
|
||||
<string name="screen_notification_settings_sound_custom_fallback">"Custom"</string>
|
||||
<string name="screen_notification_settings_sound_custom_sound_button_title">"Custom sound…"</string>
|
||||
<string name="screen_notification_settings_sound_delete_sound_error_title">"Error deleting file"</string>
|
||||
|
||||
+41
@@ -19,7 +19,11 @@ import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead
|
||||
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.data.megaBytes
|
||||
@@ -33,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -250,6 +255,38 @@ class DeveloperSettingsPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on old device shows a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createDeveloperSettingsPresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
|
||||
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createDeveloperSettingsPresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeveloperSettingsPresenter(
|
||||
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
@@ -262,6 +299,8 @@ class DeveloperSettingsPresenterTest {
|
||||
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
): DeveloperSettingsPresenter {
|
||||
return DeveloperSettingsPresenter(
|
||||
navigator = navigator,
|
||||
@@ -277,6 +316,8 @@ class DeveloperSettingsPresenterTest {
|
||||
markAllRoomsAsRead = markAllRoomsAsRead,
|
||||
buildMeta = buildMeta,
|
||||
notificationSettingsService = notificationSettingsService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
|
||||
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -151,6 +156,38 @@ class AppDeveloperSettingsPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on old device shows a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createAppDeveloperSettingsPresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
|
||||
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createAppDeveloperSettingsPresenter(
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAppDeveloperSettingsPresenter(
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
|
||||
getAvailableFeaturesResult = { _, _ ->
|
||||
@@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest {
|
||||
}
|
||||
),
|
||||
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
): AppDeveloperSettingsPresenter {
|
||||
return AppDeveloperSettingsPresenter(
|
||||
featureFlagService = featureFlagService,
|
||||
@@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest {
|
||||
buildMeta = aBuildMeta(
|
||||
gitRevision = "1234567890",
|
||||
gitBranchName = "feature/awesome-feature"
|
||||
)
|
||||
),
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -10,6 +10,9 @@ package io.element.android.features.preferences.impl.notifications
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
@@ -1104,6 +1107,46 @@ class NotificationSettingsPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to true)
|
||||
)
|
||||
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
|
||||
assertThat(state.showAllActivityInRoomList).isTrue()
|
||||
featureFlagService.setFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList, false)
|
||||
|
||||
val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
|
||||
assertThat(updatedState.showAllActivityInRoomList).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest {
|
||||
val featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.ShowAllActivityInRoomList.key to false)
|
||||
)
|
||||
val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.showAllActivityInRoomList).isFalse()
|
||||
initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
|
||||
|
||||
val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last()
|
||||
assertThat(enabledState.showAllActivityInRoomList).isTrue()
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isTrue()
|
||||
enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList)
|
||||
|
||||
val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last()
|
||||
assertThat(disabledState.showAllActivityInRoomList).isFalse()
|
||||
assertThat(featureFlagService.isFeatureEnabled(FeatureFlags.ShowAllActivityInRoomList)).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createNotificationSettingsPresenter(
|
||||
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
pushService: PushService = FakePushService(),
|
||||
@@ -1116,6 +1159,7 @@ class NotificationSettingsPresenterTest {
|
||||
NotificationSoundCopier.CopyResult.Success(source, source)
|
||||
}),
|
||||
soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
): NotificationSettingsPresenter {
|
||||
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
|
||||
return NotificationSettingsPresenter(
|
||||
@@ -1131,6 +1175,7 @@ class NotificationSettingsPresenterTest {
|
||||
soundDisplayNameResolver = soundDisplayNameResolver,
|
||||
stringProvider = FakeStringProvider(),
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -183,6 +183,35 @@ class NotificationSettingsViewTest : RobolectricTest() {
|
||||
)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on disable show all activity emits the expected events`() {
|
||||
testShowAllActivityToggle(true)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `clicking on enable show all activity emits the expected events`() {
|
||||
testShowAllActivityToggle(false)
|
||||
}
|
||||
|
||||
private fun testShowAllActivityToggle(initialState: Boolean) = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<NotificationSettingsEvent>()
|
||||
setNotificationSettingsView(
|
||||
state = aValidNotificationSettingsState(
|
||||
showAllActivityInRoomList = initialState,
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
clickOn(R.string.screen_notification_settings_show_all_activity_title)
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
NotificationSettingsEvent.RefreshSystemNotificationsEnabled,
|
||||
NotificationSettingsEvent.ToggleShowAllActivityInRoomList,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest {
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent {
|
||||
data object MuteNotification : RoomDetailsEvent
|
||||
data object UnmuteNotification : RoomDetailsEvent
|
||||
data class CopyToClipboard(val text: String) : RoomDetailsEvent
|
||||
data object CopyRoomId : RoomDetailsEvent
|
||||
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
|
||||
data object MarkAsRead : RoomDetailsEvent
|
||||
data object MarkAsUnread : RoomDetailsEvent
|
||||
|
||||
+10
-2
@@ -32,6 +32,7 @@ import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermission
|
||||
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
|
||||
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||
@@ -74,6 +75,7 @@ class RoomDetailsPresenter(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
@@ -167,8 +169,14 @@ class RoomDetailsPresenter(
|
||||
}
|
||||
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
|
||||
is RoomDetailsEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
clipboardHelper.copyPlainText(event.text) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
is RoomDetailsEvent.CopyRoomId -> {
|
||||
clipboardHelper.copyPlainText(room.roomId.value) {
|
||||
toastHelper.show(CommonStrings.common_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
is RoomDetailsEvent.MarkAsRead -> scope.markAsRead()
|
||||
is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread()
|
||||
|
||||
+3
-10
@@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -48,7 +47,6 @@ import io.element.android.features.userprofile.api.UserProfileState
|
||||
import io.element.android.features.userprofile.api.UserProfileVerificationState
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
|
||||
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
|
||||
@@ -339,6 +337,7 @@ fun RoomDetailsView(
|
||||
DebugInfoSection(
|
||||
roomId = state.roomId,
|
||||
roomVersion = state.roomVersion,
|
||||
onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -865,10 +864,9 @@ private fun OtherActionsSection(
|
||||
private fun DebugInfoSection(
|
||||
roomId: RoomId,
|
||||
roomVersion: String?,
|
||||
onCopyRoomIdClick: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
PreferenceCategory {
|
||||
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
|
||||
ListItem(
|
||||
content = {
|
||||
Text("Internal room ID")
|
||||
@@ -882,12 +880,7 @@ private fun DebugInfoSection(
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())),
|
||||
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())),
|
||||
onClick = {
|
||||
context.copyToClipboard(
|
||||
text = roomId.value,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
},
|
||||
onClick = onCopyRoomIdClick,
|
||||
)
|
||||
ListItem(
|
||||
content = {
|
||||
|
||||
+3
-2
@@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter(
|
||||
encryptionService.withdrawVerification(roomMemberId)
|
||||
}
|
||||
is UserProfileEvent.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.text)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
clipboardHelper.copyPlainText(event.text) {
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
}
|
||||
else -> userProfileState.eventSink(event)
|
||||
}
|
||||
|
||||
+45
@@ -19,6 +19,8 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
|
||||
import io.element.android.features.userprofile.shared.aUserProfileState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -49,6 +51,7 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
@@ -85,6 +88,7 @@ class RoomDetailsPresenterTest {
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(),
|
||||
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
|
||||
@@ -115,12 +119,53 @@ class RoomDetailsPresenterTest {
|
||||
dispatchers = dispatchers,
|
||||
analyticsService = analyticsService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
notificationCleaner = notificationCleaner,
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy room id on old device shows a toast`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
)
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createRoomDetailsPresenter(
|
||||
room = room,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
|
||||
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy room id on new device does not show a toast`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
roomPermissions = roomPermissions(),
|
||||
)
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createRoomDetailsPresenter(
|
||||
room = room,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
|
||||
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state is created from initial room info`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
|
||||
+31
-3
@@ -9,14 +9,21 @@
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.ContentType
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentType
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -42,6 +49,11 @@ fun ResetIdentityPasswordView(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val passwordState = textFieldState(stateValue = "")
|
||||
val submit = {
|
||||
if (passwordState.value.isNotEmpty()) {
|
||||
state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value))
|
||||
}
|
||||
}
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
|
||||
@@ -58,13 +70,14 @@ fun ResetIdentityPasswordView(
|
||||
passwordState.value = newText
|
||||
},
|
||||
hasError = state.resetAction.isFailure(),
|
||||
onSubmit = submit,
|
||||
)
|
||||
},
|
||||
buttons = {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_reset_identity),
|
||||
onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) },
|
||||
onClick = submit,
|
||||
destructive = true,
|
||||
enabled = passwordState.value.isNotEmpty(),
|
||||
)
|
||||
@@ -78,12 +91,20 @@ fun ResetIdentityPasswordView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) {
|
||||
private fun Content(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
hasError: Boolean,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onTabOrEnterKeyFocusNext(LocalFocusManager.current),
|
||||
.onTabOrEnterKeyFocusNext(LocalFocusManager.current)
|
||||
.semantics {
|
||||
contentType = ContentType.Password
|
||||
},
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
placeholder = stringResource(CommonStrings.common_password),
|
||||
@@ -95,6 +116,13 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool
|
||||
onToggle = { showPassword = !showPassword },
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onSubmit() }
|
||||
),
|
||||
validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None,
|
||||
supportingText = if (hasError) {
|
||||
stringResource(R.string.screen_reset_encryption_password_error)
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup
|
||||
sealed interface SecureBackupSetupEvent {
|
||||
data object CreateRecoveryKey : SecureBackupSetupEvent
|
||||
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent
|
||||
data object CopyRecoveryKey : SecureBackupSetupEvent
|
||||
data object Done : SecureBackupSetupEvent
|
||||
data object DismissDialog : SecureBackupSetupEvent
|
||||
}
|
||||
|
||||
+12
@@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.features.securebackup.impl.loggerTagSetup
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
@@ -38,6 +41,8 @@ class SecureBackupSetupPresenter(
|
||||
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
|
||||
private val stateMachine: SecureBackupSetupStateMachine,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Presenter<SecureBackupSetupState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -60,6 +65,13 @@ class SecureBackupSetupPresenter(
|
||||
}
|
||||
SecureBackupSetupEvent.RecoveryKeyHasBeenSaved ->
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
||||
SecureBackupSetupEvent.CopyRecoveryKey -> {
|
||||
val recoveryKey = setupState.recoveryKey() ?: return
|
||||
clipboardHelper.copyPlainText(recoveryKey, isSensitive = true) {
|
||||
toastHelper.show(R.string.screen_recovery_key_copied_to_clipboard)
|
||||
}
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
||||
}
|
||||
SecureBackupSetupEvent.DismissDialog -> {
|
||||
showSaveConfirmationDialog = false
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError)
|
||||
|
||||
+1
-8
@@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
|
||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
@@ -114,16 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String {
|
||||
private fun Content(
|
||||
state: SecureBackupSetupState,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
|
||||
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
|
||||
val clickLambda = if (formattedRecoveryKey != null) {
|
||||
{
|
||||
context.copyToClipboard(
|
||||
text = formattedRecoveryKey,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved)
|
||||
state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
|
||||
}
|
||||
} else {
|
||||
if (!state.recoveryKeyViewState.inProgress) {
|
||||
|
||||
+4
@@ -15,6 +15,8 @@ import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
|
||||
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
|
||||
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter
|
||||
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
@@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest {
|
||||
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
|
||||
stateMachine = SecureBackupSetupStateMachine(),
|
||||
encryptionService = FakeEncryptionService(),
|
||||
clipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper = FakeToastHelper(),
|
||||
)
|
||||
},
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
|
||||
+59
@@ -12,8 +12,13 @@ import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
|
||||
@@ -186,16 +191,70 @@ class SecureBackupSetupPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy recovery key on old device shows a toast`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createSecureBackupSetupPresenter(
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
|
||||
skipItems(1)
|
||||
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
|
||||
val createdState = awaitItem()
|
||||
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
|
||||
assertThat(clipboardHelper.isSensitive).isTrue()
|
||||
assertThat(toastHelper.shownToasts).containsExactly(R.string.screen_recovery_key_copied_to_clipboard)
|
||||
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy recovery key on new device does not show a toast`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createSecureBackupSetupPresenter(
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
|
||||
skipItems(1)
|
||||
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
|
||||
val createdState = awaitItem()
|
||||
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
|
||||
assertThat(clipboardHelper.isSensitive).isTrue()
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSecureBackupSetupPresenter(
|
||||
isChangeRecoveryKeyUserStory: Boolean = false,
|
||||
encryptionService: EncryptionService = FakeEncryptionService(
|
||||
enableRecoveryLambda = { _, _ -> Result.success("") },
|
||||
),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
): SecureBackupSetupPresenter {
|
||||
return SecureBackupSetupPresenter(
|
||||
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
|
||||
stateMachine = SecureBackupSetupStateMachine(),
|
||||
encryptionService = encryptionService,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies {
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.features.logout.api)
|
||||
api(libs.statemachine)
|
||||
@@ -44,4 +45,5 @@ dependencies {
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
}
|
||||
|
||||
+24
@@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -45,6 +50,8 @@ class IncomingVerificationPresenter(
|
||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val sessionId: SessionId,
|
||||
private val sessionStore: SessionStore,
|
||||
private val stateMachine: IncomingVerificationStateMachine,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : Presenter<IncomingVerificationState> {
|
||||
@@ -92,9 +99,24 @@ class IncomingVerificationPresenter(
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
)
|
||||
}
|
||||
val currentUser by remember {
|
||||
sessionStore.sessionsFlow().mapNotNull { sessions ->
|
||||
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
|
||||
MatrixUser(
|
||||
userId = sessionId,
|
||||
displayName = sessionData.userDisplayName,
|
||||
avatarUrl = sessionData.userAvatarUrl,
|
||||
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
|
||||
// for instance for a session which is not the current one
|
||||
avatarThumbnail = sessionData.userAvatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.collectAsState(MatrixUser(sessionId))
|
||||
val step by remember {
|
||||
derivedStateOf {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
currentUser = currentUser,
|
||||
sessionVerificationRequestDetails = verificationRequest.details,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
)
|
||||
@@ -147,6 +169,7 @@ class IncomingVerificationPresenter(
|
||||
}
|
||||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
currentUser: MatrixUser,
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
formattedSignInTime: String,
|
||||
): Step =
|
||||
@@ -156,6 +179,7 @@ class IncomingVerificationPresenter(
|
||||
StateMachineState.RejectingIncomingVerification,
|
||||
null -> {
|
||||
Step.Initial(
|
||||
currentUser = currentUser,
|
||||
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
|
||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
|
||||
@@ -21,6 +22,7 @@ data class IncomingVerificationState(
|
||||
@Stable
|
||||
sealed interface Step {
|
||||
data class Initial(
|
||||
val currentUser: MatrixUser,
|
||||
val deviceDisplayName: String?,
|
||||
val deviceId: DeviceId,
|
||||
val formattedSignInTime: String,
|
||||
|
||||
+6
@@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
|
||||
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
|
||||
internal fun aStepInitial(
|
||||
isWaiting: Boolean = false,
|
||||
) = Step.Initial(
|
||||
currentUser = MatrixUser(
|
||||
userId = UserId("@bob:example.com"),
|
||||
displayName = USER_NAME_BOB,
|
||||
avatarUrl = null,
|
||||
),
|
||||
deviceDisplayName = "Element X Android",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
formattedSignInTime = "12:34",
|
||||
|
||||
+32
-25
@@ -31,10 +31,8 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
@@ -42,11 +40,15 @@ import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
@@ -167,16 +169,21 @@ private fun ContentInitial(
|
||||
stepInitial: Step.Initial,
|
||||
request: VerificationRequest.Incoming,
|
||||
) {
|
||||
when (request) {
|
||||
is VerificationRequest.Incoming.OtherSession -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
SessionDetailsView(
|
||||
deviceName = stepInitial.deviceDisplayName,
|
||||
deviceId = stepInitial.deviceId,
|
||||
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
when (request) {
|
||||
is VerificationRequest.Incoming.OtherSession -> {
|
||||
AccountInfoCard(
|
||||
state = AccountInfoCardState(
|
||||
matrixUser = stepInitial.currentUser,
|
||||
mode = AccountInfoCardMode.DeviceVerification(
|
||||
deviceName = stepInitial.deviceDisplayName,
|
||||
deviceId = stepInitial.deviceId,
|
||||
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
@@ -187,15 +194,15 @@ private fun ContentInitial(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
is VerificationRequest.Incoming.User -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
) {
|
||||
VerificationUserProfileContent(
|
||||
user = request.details.senderProfile,
|
||||
is VerificationRequest.Incoming.User -> {
|
||||
AccountInfoCard(
|
||||
state = AccountInfoCardState(
|
||||
matrixUser = stepInitial.currentUser,
|
||||
mode = AccountInfoCardMode.UserVerification(
|
||||
otherUser = request.details.senderProfile,
|
||||
hint = stringResource(R.string.screen_session_verification_user_requesting_verification),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +224,7 @@ private fun IncomingVerificationBottomMenu(
|
||||
showProgress = step.isWaiting,
|
||||
onClick = { eventSink(IncomingVerificationViewEvent.StartVerification) },
|
||||
)
|
||||
TextButton(
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_ignore),
|
||||
enabled = !step.isWaiting,
|
||||
@@ -262,9 +269,9 @@ private fun IncomingVerificationBottomMenu(
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun IncomingVerificationViewPreview(@PreviewParameter(
|
||||
IncomingVerificationStatePreviewParam::class
|
||||
) state: IncomingVerificationState) = ElementPreview {
|
||||
internal fun IncomingVerificationViewPreview(
|
||||
@PreviewParameter(IncomingVerificationStatePreviewParam::class) state: IncomingVerificationState
|
||||
) = ElementPreview {
|
||||
IncomingVerificationView(
|
||||
state = state,
|
||||
)
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.incoming.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
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.shape.RoundedCornerShape
|
||||
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.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.verifysession.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun SessionDetailsView(
|
||||
deviceName: String?,
|
||||
deviceId: DeviceId,
|
||||
signInFormattedTimestamp: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = ElementTheme.colors.borderDisabled,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(24.dp)
|
||||
.semantics(mergeDescendants = true) {},
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RoundedIconAtom(
|
||||
modifier = Modifier,
|
||||
size = RoundedIconAtomSize.Big,
|
||||
resourceId = CompoundDrawables.ic_compound_devices
|
||||
)
|
||||
Text(
|
||||
text = deviceName ?: deviceId.value,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextWithLabelMolecule(
|
||||
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
|
||||
text = signInFormattedTimestamp,
|
||||
modifier = Modifier.weight(2f),
|
||||
)
|
||||
TextWithLabelMolecule(
|
||||
label = stringResource(CommonStrings.common_device_id),
|
||||
text = deviceId.value,
|
||||
modifier = Modifier.weight(5f),
|
||||
spellText = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SessionDetailsViewPreview() = ElementPreview {
|
||||
Column {
|
||||
SessionDetailsView(
|
||||
deviceName = "Element X Android",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
signInFormattedTimestamp = "12:34",
|
||||
)
|
||||
SessionDetailsView(
|
||||
deviceName = null,
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
signInFormattedTimestamp = "12:34",
|
||||
)
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.verifysession.impl.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049
|
||||
*/
|
||||
@Composable
|
||||
fun VerificationUserProfileContent(
|
||||
user: MatrixUser,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val avatarData = remember(user) {
|
||||
user.getAvatarData(AvatarSize.UserVerification)
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = user.getBestName(),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
|
||||
if (user.displayName.isNullOrEmpty().not()) {
|
||||
Text(
|
||||
text = user.userId.value,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun VerificationUserProfileContentPreview() = ElementPreview(
|
||||
drawableFallbackForImages = CommonDrawables.sample_avatar
|
||||
) {
|
||||
VerificationUserProfileContent(
|
||||
user = MatrixUser(
|
||||
userId = UserId("@alice:example.com"),
|
||||
displayName = USER_NAME_ALICE,
|
||||
avatarUrl = "https://example.com/avatar.png",
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
<string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string>
|
||||
<string name="screen_session_verification_user_initiator_subtitle">"For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate."</string>
|
||||
<string name="screen_session_verification_user_initiator_title">"Verify this user?"</string>
|
||||
<string name="screen_session_verification_user_requesting_verification">"User requesting verification:"</string>
|
||||
<string name="screen_session_verification_user_responder_subtitle">"For extra security, another user wants to verify your digital identity. You’ll be shown a set of emojis to compare."</string>
|
||||
<string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string>
|
||||
<string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string>
|
||||
|
||||
+73
@@ -18,10 +18,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||
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_NAME
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -41,6 +48,51 @@ class IncomingVerificationPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - the current user is built from the stored session`() = runTest {
|
||||
assertCurrentUserIsBuiltFromStoredSession(anIncomingSessionVerificationRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - incoming user verification - the current user is built from the stored session`() = runTest {
|
||||
assertCurrentUserIsBuiltFromStoredSession(anIncomingUserVerificationRequest)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.assertCurrentUserIsBuiltFromStoredSession(
|
||||
verificationRequest: VerificationRequest.Incoming,
|
||||
) {
|
||||
createPresenter(
|
||||
verificationRequest = verificationRequest,
|
||||
service = FakeSessionVerificationService(
|
||||
acknowledgeVerificationRequestLambda = { },
|
||||
resetLambda = { },
|
||||
),
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(
|
||||
sessionId = A_SESSION_ID.value,
|
||||
userDisplayName = A_USER_NAME,
|
||||
userAvatarUrl = AN_AVATAR_URL,
|
||||
userAvatarData = "avatarData",
|
||||
)
|
||||
),
|
||||
),
|
||||
).test {
|
||||
val step = awaitItem().step as IncomingVerificationState.Step.Initial
|
||||
assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID))
|
||||
val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial
|
||||
assertThat(stepWithUser.currentUser).isEqualTo(
|
||||
MatrixUser(
|
||||
userId = A_SESSION_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
avatarThumbnail = "avatarData",
|
||||
)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||
@@ -59,6 +111,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -128,6 +181,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -193,6 +247,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -228,6 +283,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -310,15 +366,32 @@ private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming.
|
||||
)
|
||||
)
|
||||
|
||||
private val anIncomingUserVerificationRequest = VerificationRequest.Incoming.User(
|
||||
details = SessionVerificationRequestDetails(
|
||||
senderProfile = MatrixUser(
|
||||
userId = A_USER_ID_2,
|
||||
displayName = "another user name",
|
||||
avatarUrl = null,
|
||||
),
|
||||
flowId = FlowId("flowId"),
|
||||
deviceId = A_DEVICE_ID,
|
||||
deviceDisplayName = "a device name",
|
||||
firstSeenTimestamp = A_TIMESTAMP,
|
||||
)
|
||||
)
|
||||
|
||||
internal fun TestScope.createPresenter(
|
||||
verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest,
|
||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = IncomingVerificationPresenter(
|
||||
verificationRequest = verificationRequest,
|
||||
navigator = navigator,
|
||||
sessionVerificationService = service,
|
||||
sessionId = A_SESSION_ID,
|
||||
sessionStore = sessionStore,
|
||||
stateMachine = IncomingVerificationStateMachine(service),
|
||||
dateFormatter = dateFormatter,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
|
||||
+12
-1
@@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||
import io.element.android.features.viewfolder.impl.file.ColorationMode
|
||||
import io.element.android.features.viewfolder.impl.file.FileContent
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultTextFileViewer : TextFileViewer {
|
||||
class DefaultTextFileViewer(
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : TextFileViewer {
|
||||
@Composable
|
||||
override fun Render(
|
||||
lines: ImmutableList<String>,
|
||||
@@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer {
|
||||
FileContent(
|
||||
lines = lines,
|
||||
colorationMode = ColorationMode.None,
|
||||
onLineClick = { line ->
|
||||
clipboardHelper.copyPlainText(line) {
|
||||
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+4
-10
@@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
internal fun FileContent(
|
||||
lines: ImmutableList<String>,
|
||||
colorationMode: ColorationMode,
|
||||
onLineClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -60,6 +59,7 @@ internal fun FileContent(
|
||||
lineNumber = index + 1,
|
||||
line = line,
|
||||
colorationMode = colorationMode,
|
||||
onClick = { onLineClick(line) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,18 +71,12 @@ private fun LineRow(
|
||||
lineNumber: Int,
|
||||
line: String,
|
||||
colorationMode: ColorationMode,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = {
|
||||
context.copyToClipboard(
|
||||
text = line,
|
||||
toastMessage = toastMessage,
|
||||
)
|
||||
})
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
|
||||
+1
@@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file
|
||||
sealed interface ViewFileEvent {
|
||||
data object SaveOnDisk : ViewFileEvent
|
||||
data object Share : ViewFileEvent
|
||||
data class CopyToClipboard(val text: String) : ViewFileEvent
|
||||
}
|
||||
|
||||
+8
@@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -30,6 +33,8 @@ class ViewFilePresenter(
|
||||
private val fileContentReader: FileContentReader,
|
||||
private val fileShare: FileShare,
|
||||
private val fileSave: FileSave,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val toastHelper: ToastHelper,
|
||||
) : Presenter<ViewFileState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -48,6 +53,9 @@ class ViewFilePresenter(
|
||||
when (event) {
|
||||
ViewFileEvent.Share -> coroutineScope.share(path)
|
||||
ViewFileEvent.SaveOnDisk -> coroutineScope.save(path)
|
||||
is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) {
|
||||
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -80,6 +80,7 @@ fun ViewFileView(
|
||||
modifier = Modifier.weight(1f),
|
||||
lines = state.lines.data.toImmutableList(),
|
||||
colorationMode = state.colorationMode,
|
||||
onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) },
|
||||
)
|
||||
is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
|
||||
}
|
||||
|
||||
+39
@@ -18,8 +18,13 @@ import io.element.android.features.viewfolder.impl.file.FileSave
|
||||
import io.element.android.features.viewfolder.impl.file.FileShare
|
||||
import io.element.android.features.viewfolder.impl.file.ViewFileEvent
|
||||
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter
|
||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
||||
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
|
||||
import io.element.android.libraries.androidutils.toast.FakeToastHelper
|
||||
import io.element.android.libraries.androidutils.toast.ToastHelper
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -126,17 +131,51 @@ class ViewFilePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy line on old device shows a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
|
||||
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_line_copied_to_clipboard)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - copy line on new device does not show a toast`() = runTest {
|
||||
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
|
||||
val toastHelper = FakeToastHelper()
|
||||
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
|
||||
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
|
||||
assertThat(toastHelper.shownToasts).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
path: String = "aPath",
|
||||
name: String = "aName",
|
||||
fileContentReader: FileContentReader = FakeFileContentReader(),
|
||||
fileShare: FileShare = FakeFileShare(),
|
||||
fileSave: FileSave = FakeFileSave(),
|
||||
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
|
||||
toastHelper: ToastHelper = FakeToastHelper(),
|
||||
) = ViewFilePresenter(
|
||||
path = path,
|
||||
name = name,
|
||||
fileContentReader = fileContentReader,
|
||||
fileShare = fileShare,
|
||||
fileSave = fileSave,
|
||||
clipboardHelper = clipboardHelper,
|
||||
toastHelper = toastHelper,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user