Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-05 17:54:00 +02:00
335 changed files with 2657 additions and 1194 deletions
@@ -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,
)
}
}
}
}
@@ -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()
}
}
@@ -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,
)
}
@@ -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(),
@@ -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(),
)
@@ -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(),
)
}
@@ -76,5 +76,6 @@ sealed interface RoomListContentState {
val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
val showAllActivity: Boolean,
) : RoomListContentState
}
@@ -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
}
@@ -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,
@@ -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?,
@@ -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,
@@ -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),
@@ -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
@@ -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
}
}
@@ -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.
*/
@@ -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()
}
}
@@ -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,
@@ -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()
@@ -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"),
)
@@ -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()
@@ -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()
}
@@ -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))
}
}
@@ -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
@@ -267,6 +267,7 @@ private fun ThreadListItemRow(
UnreadIndicatorAtom(
size = 14.dp,
isVisible = hasUnreadNotifications,
count = 0,
)
}
}
@@ -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.
@@ -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)
}
}
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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 = { },
)
}
@@ -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,
)
}
}
@@ -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 {
@@ -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
}
@@ -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)
}
}
}
}
@@ -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) }
)
}
}
@@ -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
}
@@ -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)
}
}
}
}
@@ -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) }
)
}
}
@@ -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
}
@@ -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,
)
}
@@ -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 {
@@ -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,
)
@@ -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>
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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,
)
}
}
@@ -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 {
@@ -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
@@ -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()
@@ -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 = {
@@ -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)
}
@@ -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(
@@ -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)
@@ -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
}
@@ -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)
@@ -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) {
@@ -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(),
@@ -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)
}
@@ -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,
@@ -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,
@@ -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",
@@ -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,
)
@@ -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",
)
}
}
@@ -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>
@@ -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,
@@ -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
)
}
@@ -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
@@ -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
}
@@ -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)
}
}
}
@@ -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)
}
@@ -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,
)
}