mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 20:01:55 +00:00
Merge branch 'develop' into bma/settingsIterationStep2Bis
This commit is contained in:
@@ -96,6 +96,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -113,6 +114,7 @@ import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toKotlinDuration
|
||||
@@ -324,7 +326,9 @@ class LoggedInFlowNode(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> emptyNode(buildContext)
|
||||
// Rendered while the FtueState is Unknown. Render a loading state rather than nothing at all,
|
||||
// since there is no guarantee that this state will be left.
|
||||
NavTarget.Placeholder -> emptyNode(buildContext, state = EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||
NavTarget.LoggedInPermanent -> {
|
||||
val callback = object : LoggedInNode.Callback {
|
||||
override fun navigateToNotificationTroubleshoot() {
|
||||
|
||||
@@ -28,10 +28,14 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -60,6 +64,12 @@ class SyncOrchestrator(
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Number of consecutive attempts at restarting a sync service which stopped on its own.
|
||||
* Reset once the sync service has been running for [RESTART_BACKOFF_RESET_DELAY].
|
||||
*/
|
||||
private val restartAttempt = AtomicInteger(0)
|
||||
|
||||
/**
|
||||
* Starting observing the app state and network state to start/stop the sync service.
|
||||
*
|
||||
@@ -91,6 +101,17 @@ class SyncOrchestrator(
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun observeStates() = coroutineScope.launch {
|
||||
Timber.tag(tag).d("start observing the app and network state")
|
||||
// Reset the restart backoff once the sync service has been steadily running for a while. Note that
|
||||
// SyncService.startSync() moves the state to Running right away, so resetting on any Running value
|
||||
// would defeat the backoff when the sync service dies again immediately after each restart.
|
||||
syncService.syncState
|
||||
.debounce(RESTART_BACKOFF_RESET_DELAY)
|
||||
.onEach { syncState ->
|
||||
if (syncState == SyncState.Running) {
|
||||
restartAttempt.set(0)
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
val isAppActiveFlows = listOf(
|
||||
appForegroundStateService.isInForeground,
|
||||
appForegroundStateService.isInCall,
|
||||
@@ -117,14 +138,26 @@ class SyncOrchestrator(
|
||||
SyncStateAction.StopSync
|
||||
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
|
||||
SyncStateAction.StartSync
|
||||
} else if (syncState == SyncState.Error && isAppActive && isNetworkAvailable) {
|
||||
// The sync service has given up, for instance because the homeserver has expired our sliding sync
|
||||
// session and answered 400 M_UNKNOWN_POS. Nothing else will ever restart it, so do it from here,
|
||||
// else the application is stuck until its data is cleared.
|
||||
SyncStateAction.RestartSync
|
||||
} else {
|
||||
SyncStateAction.NoOp
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce { action ->
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
|
||||
when (action) {
|
||||
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
|
||||
SyncStateAction.StopSync -> 3.seconds
|
||||
// Back off before restarting a dead sync service, to avoid hammering a failing homeserver.
|
||||
// A state change in the meantime cancels the pending restart.
|
||||
SyncStateAction.RestartSync -> restartDelay(restartAttempt.get())
|
||||
SyncStateAction.StartSync,
|
||||
SyncStateAction.NoOp -> 0.seconds
|
||||
}
|
||||
}
|
||||
.onCompletion {
|
||||
Timber.tag(tag).d("has been stopped")
|
||||
@@ -134,6 +167,11 @@ class SyncOrchestrator(
|
||||
SyncStateAction.StartSync -> {
|
||||
syncService.startSync()
|
||||
}
|
||||
SyncStateAction.RestartSync -> {
|
||||
val attempt = restartAttempt.incrementAndGet()
|
||||
Timber.tag(tag).w("sync service is dead, restarting it (attempt $attempt)")
|
||||
syncService.startSync()
|
||||
}
|
||||
SyncStateAction.StopSync -> {
|
||||
syncService.stopSync()
|
||||
}
|
||||
@@ -143,8 +181,35 @@ class SyncOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal val FIRST_RESTART_DELAY = 1.seconds
|
||||
|
||||
/**
|
||||
* Exponential backoff, from [FIRST_RESTART_DELAY], doubling on every attempt, up to [FIRST_RESTART_DELAY * 32].
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal val RESTART_BACKOFF_DELAYS = listOf(
|
||||
FIRST_RESTART_DELAY,
|
||||
FIRST_RESTART_DELAY * 2,
|
||||
FIRST_RESTART_DELAY * 4,
|
||||
FIRST_RESTART_DELAY * 8,
|
||||
FIRST_RESTART_DELAY * 16,
|
||||
FIRST_RESTART_DELAY * 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* Duration during which the sync service has to stay running before the restart backoff is reset.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal val RESTART_BACKOFF_RESET_DELAY = 30.seconds
|
||||
|
||||
private fun restartDelay(attempt: Int): Duration {
|
||||
return RESTART_BACKOFF_DELAYS[attempt.coerceIn(0, RESTART_BACKOFF_DELAYS.lastIndex)]
|
||||
}
|
||||
|
||||
private enum class SyncStateAction {
|
||||
StartSync,
|
||||
RestartSync,
|
||||
StopSync,
|
||||
NoOp,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.appnav
|
||||
|
||||
import io.element.android.appnav.session.FIRST_RESTART_DELAY
|
||||
import io.element.android.appnav.session.RESTART_BACKOFF_DELAYS
|
||||
import io.element.android.appnav.session.RESTART_BACKOFF_RESET_DELAY
|
||||
import io.element.android.appnav.session.SyncOrchestrator
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
@@ -414,6 +417,131 @@ class SyncOrchestratorTest {
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error, it will be restarted after a delay`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
// The restart does not happen right away, we back off first
|
||||
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
|
||||
// But it does happen once the backoff has elapsed
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service keeps dying, the delay between the restarts increases`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// The sync service starts, but dies again straight away
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
advanceTimeBy(200.milliseconds)
|
||||
syncService.emitSyncState(SyncState.Error)
|
||||
|
||||
// The second restart is not attempted after the first delay anymore
|
||||
advanceTimeBy(FIRST_RESTART_DELAY + 200.milliseconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// But after twice that delay
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service recovers, the delay between the restarts is reset`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(FIRST_RESTART_DELAY * 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// The sync service is now running, and keeps running for a while
|
||||
syncService.emitSyncState(SyncState.Running)
|
||||
advanceTimeBy(RESTART_BACKOFF_RESET_DELAY + 1.seconds)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
|
||||
// It dies again, and is restarted after the first delay, not a longer one
|
||||
syncService.emitSyncState(SyncState.Error)
|
||||
advanceTimeBy(FIRST_RESTART_DELAY / 2)
|
||||
startSyncRecorder.assertions().isCalledOnce()
|
||||
advanceTimeBy(FIRST_RESTART_DELAY)
|
||||
startSyncRecorder.assertions().isCalledExactly(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error but the app is in background, it is not restarted`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the sync service is in error but there is no network, it is not restarted`() = runTest {
|
||||
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val syncService = FakeSyncService(initialSyncState = SyncState.Error).apply {
|
||||
startSyncLambda = startSyncRecorder
|
||||
}
|
||||
val syncOrchestrator = createSyncOrchestrator(
|
||||
matrixClient = FakeMatrixClient(syncService = syncService),
|
||||
networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected),
|
||||
appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true),
|
||||
)
|
||||
|
||||
// We start observing
|
||||
syncOrchestrator.observeStates()
|
||||
|
||||
advanceTimeBy(RESTART_BACKOFF_DELAYS.last() * 2)
|
||||
startSyncRecorder.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun TestScope.createSyncOrchestrator(
|
||||
matrixClient: FakeMatrixClient = FakeMatrixClient(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
|
||||
+6
-1
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.ui.common.nodes.EmptyState
|
||||
import io.element.android.libraries.ui.common.nodes.emptyNode
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
@@ -84,7 +86,10 @@ class FtueFlowNode(
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> {
|
||||
emptyNode(buildContext)
|
||||
// Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
|
||||
// This can last for a while, or forever if the sync service never manages to run, so
|
||||
// render a loading state rather than nothing at all.
|
||||
emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds))
|
||||
}
|
||||
is NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
|
||||
@@ -12,6 +12,7 @@ import extension.testCommonDependencies
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -31,6 +32,8 @@ dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.encryptedDb)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
|
||||
+8
-10
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
|
||||
// Call and unread
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(16.dp)
|
||||
.heightIn(min = 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),
|
||||
@@ -358,21 +358,18 @@ 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
|
||||
val count = when {
|
||||
showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications
|
||||
!showUnreadCount -> 0
|
||||
else -> null
|
||||
}
|
||||
UnreadIndicatorAtom(
|
||||
color = tint,
|
||||
@@ -457,5 +454,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
|
||||
isInviteSeen = data.name == "Bob",
|
||||
onClick = {},
|
||||
eventSink = {},
|
||||
showUnreadCount = data.numberOfUnreadNotifications > 0,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
|
||||
numberOfUnreadMentions = 1,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = roomNotificationMode.name,
|
||||
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
|
||||
notificationMode = roomNotificationMode,
|
||||
numberOfUnreadMessages = 0,
|
||||
numberOfUnreadMentions = 0,
|
||||
numberOfUnreadNotifications = 1,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = roomNotificationMode.name,
|
||||
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
|
||||
notificationMode = roomNotificationMode,
|
||||
numberOfUnreadMessages = 0,
|
||||
numberOfUnreadMentions = 0,
|
||||
numberOfUnreadNotifications = 100,
|
||||
hasRoomCall = hasCall,
|
||||
),
|
||||
)
|
||||
}.flatten()
|
||||
}.flatten(),
|
||||
|
||||
+18
@@ -7,9 +7,27 @@
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
sealed interface GlobalSearchEvent {
|
||||
/** The user cleared the search query. */
|
||||
object ClearQuery : GlobalSearchEvent
|
||||
|
||||
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
|
||||
object ToggleSearchVisibility : GlobalSearchEvent
|
||||
|
||||
/** The visible range for the displayed search results changed. */
|
||||
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
|
||||
|
||||
/** The user changed the search target (e.g. from "Rooms" to "Messages"). */
|
||||
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
|
||||
|
||||
/** The user triggered the keyboard search action (enter key): save the current query to the search history. */
|
||||
object SaveQueryToHistory : GlobalSearchEvent
|
||||
|
||||
/** The user opened a room from the search results: save it to the search history. */
|
||||
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
|
||||
|
||||
/** The user selected a search result from the search history. */
|
||||
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
|
||||
}
|
||||
|
||||
+47
-4
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryResult
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryStore
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.async
|
||||
@@ -54,10 +57,8 @@ 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.jvm.optionals.getOrElse
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Inject
|
||||
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val searchHistoryStore: SearchHistoryStore,
|
||||
) : Presenter<GlobalSearchState> {
|
||||
@Composable
|
||||
override fun present(): GlobalSearchState {
|
||||
@@ -85,6 +87,21 @@ 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) {
|
||||
searchHistoryStore.history.collectLatest { history ->
|
||||
val mappedHistoryItems = history.mapNotNull { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
|
||||
is SearchHistoryResult.Room -> {
|
||||
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
|
||||
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
value = AsyncData.Success(mappedHistoryItems.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(queryState.text) {
|
||||
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
|
||||
delay(200.milliseconds)
|
||||
@@ -141,7 +158,7 @@ class GlobalSearchPresenter(
|
||||
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
|
||||
val mappedResults = results.map { result ->
|
||||
async(coroutineDispatchers.computation) {
|
||||
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
|
||||
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null
|
||||
val formattedTimestamp = dateFormatter.format(
|
||||
timestamp = result.timestamp,
|
||||
mode = DateFormatterMode.TimeOrDate,
|
||||
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
|
||||
|
||||
fun handleEvent(event: GlobalSearchEvent) {
|
||||
when (event) {
|
||||
GlobalSearchEvent.ClearQuery -> {
|
||||
GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
|
||||
searchResults = AsyncData.Uninitialized
|
||||
queryState.clearText()
|
||||
}
|
||||
GlobalSearchEvent.ToggleSearchVisibility -> {
|
||||
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
|
||||
GlobalSearchEvent.SaveQueryToHistory -> {
|
||||
val term = queryState.text.toString()
|
||||
if (term.isNotBlank()) {
|
||||
coroutineScope.launch {
|
||||
searchHistoryStore.add(SearchHistoryResult.Query(term))
|
||||
}
|
||||
}
|
||||
}
|
||||
is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch {
|
||||
searchHistoryStore.add(SearchHistoryResult.Room(event.roomId))
|
||||
}
|
||||
is GlobalSearchEvent.SearchHistoryResultSelected -> {
|
||||
when (event.resultItem) {
|
||||
is SearchHistoryResultItem.Query -> coroutineScope.launch {
|
||||
delay(300.milliseconds)
|
||||
queryState.edit {
|
||||
replace(0, length, event.resultItem.term)
|
||||
}
|
||||
searchResults = AsyncData.Loading()
|
||||
currentTarget = GlobalSearchTarget.ROOMS
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +256,7 @@ class GlobalSearchPresenter(
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = searchResults,
|
||||
history = searchHistory,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -28,6 +28,7 @@ data class GlobalSearchState(
|
||||
val queryState: TextFieldState,
|
||||
val currentTarget: GlobalSearchTarget,
|
||||
val results: AsyncData<GlobalSearchResults>,
|
||||
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
|
||||
val eventSink: (GlobalSearchEvent) -> Unit
|
||||
)
|
||||
|
||||
@@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem {
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SearchHistoryResultItem {
|
||||
val id: String
|
||||
|
||||
data class Query(val term: String) : SearchHistoryResultItem {
|
||||
override val id: String = "query:$term"
|
||||
}
|
||||
data class Room(
|
||||
val roomId: RoomId,
|
||||
val roomInfo: RoomInfo,
|
||||
) : SearchHistoryResultItem {
|
||||
override val id: String = "room:${roomId.value}"
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaSearchResultContent(
|
||||
val filename: String,
|
||||
val extension: String?,
|
||||
|
||||
+121
-57
@@ -36,77 +36,139 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
private val tombstonedRoomId = RoomId("!tombstone:server.org")
|
||||
private val roomId1 = RoomId("!roomId:server.org")
|
||||
private val roomId2 = RoomId("!roomId2:server.org")
|
||||
private val dmRoomId = RoomId("!dm:server.org")
|
||||
private const val AN_AVATAR_URL = "https://example.com/avatar.png"
|
||||
private val aCanonicalAlias = RoomAlias("#alias:server.org")
|
||||
|
||||
class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> {
|
||||
override val values: Sequence<GlobalSearchState>
|
||||
get() = sequenceOf(
|
||||
// Not enabled, no preview
|
||||
aGlobalSearchState(isEnabled = false),
|
||||
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Loading(),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.RoomListResults(
|
||||
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
|
||||
summary.copy(name = "Room with Query #${index + 1}")
|
||||
}.toPersistentList()
|
||||
get() {
|
||||
return sequenceOf(
|
||||
// Not enabled, no preview
|
||||
aGlobalSearchState(isEnabled = false),
|
||||
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Loading(),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.RoomListResults(
|
||||
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
|
||||
summary.copy(name = "Room with Query #${index + 1}")
|
||||
}.toPersistentList()
|
||||
)
|
||||
),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
|
||||
body = "A message with Query",
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
MessageSearchResultItem.Media(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
|
||||
mediaContent = MediaSearchResultContent(
|
||||
filename = "file.png",
|
||||
extension = "PNG",
|
||||
caption = "A caption containing Query",
|
||||
formattedSize = "1 MB",
|
||||
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
|
||||
thumbnailType = AttachmentThumbnailType.Image,
|
||||
blurhash = null,
|
||||
),
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
))),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(
|
||||
persistentListOf(
|
||||
SearchHistoryResultItem.Query("Query 1"),
|
||||
SearchHistoryResultItem.Query("Query 2"),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = roomId1,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId1,
|
||||
name = "A room with alias",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = roomId2,
|
||||
roomInfo = aRoomInfo(
|
||||
id = roomId2,
|
||||
name = "A room",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = dmRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = dmRoomId,
|
||||
name = "A DM",
|
||||
isDm = true,
|
||||
heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)),
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canonicalAlias = aCanonicalAlias,
|
||||
)
|
||||
),
|
||||
SearchHistoryResultItem.Room(
|
||||
roomId = tombstonedRoomId,
|
||||
roomInfo = aRoomInfo(
|
||||
id = tombstonedRoomId,
|
||||
name = "A tombstoned room",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
successorRoom = SuccessorRoom(
|
||||
roomId = RoomId("!successorRoom:server.org"),
|
||||
reason = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
|
||||
body = "A message with Query",
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
MessageSearchResultItem.Media(
|
||||
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
|
||||
mediaContent = MediaSearchResultContent(
|
||||
filename = "file.png",
|
||||
extension = "PNG",
|
||||
caption = "A caption containing Query",
|
||||
formattedSize = "1 MB",
|
||||
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
|
||||
thumbnailType = AttachmentThumbnailType.Image,
|
||||
blurhash = null,
|
||||
),
|
||||
roomInfo = aRoomInfo(),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
))),
|
||||
),
|
||||
aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun aRoomInfo(
|
||||
id: RoomId = RoomId("!roomId:server.org"),
|
||||
id: RoomId = roomId1,
|
||||
name: String? = "A room",
|
||||
rawName: String? = "A room raw name",
|
||||
topic: String? = "A room topic",
|
||||
avatarUrl: String? = "https://example.com/avatar.png",
|
||||
avatarUrl: String? = AN_AVATAR_URL,
|
||||
isPublic: Boolean = true,
|
||||
isDirect: Boolean = false,
|
||||
isEncrypted: Boolean = false,
|
||||
@@ -217,7 +279,7 @@ fun aMessageContent(
|
||||
)
|
||||
|
||||
internal fun aMessageSearchResult(
|
||||
roomId: RoomId = RoomId("!roomId:server.org"),
|
||||
roomId: RoomId = roomId1,
|
||||
eventId: EventId = EventId("\$eventId:server.org"),
|
||||
senderId: UserId = UserId("@user:server.org"),
|
||||
senderProfile: ProfileDetails = aProfileDetailsReady(),
|
||||
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
|
||||
queryState: TextFieldState = TextFieldState(),
|
||||
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
|
||||
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
|
||||
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
|
||||
eventSink: (GlobalSearchEvent) -> Unit = {},
|
||||
) = GlobalSearchState(
|
||||
isEnabled = isEnabled,
|
||||
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
|
||||
queryState = queryState,
|
||||
currentTarget = currentTarget,
|
||||
results = results,
|
||||
history = history,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
+204
-78
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -63,6 +68,7 @@ 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.theme.components.FilledTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
@@ -80,6 +86,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
@@ -103,7 +110,10 @@ fun GlobalSearchView(
|
||||
onBackButtonClick = {
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
},
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
onSelectSearchResult = { roomId, eventId ->
|
||||
onSelectSearchResult(roomId, eventId)
|
||||
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -115,63 +125,11 @@ private fun GlobalSearchContent(
|
||||
onBackButtonClick: () -> Unit,
|
||||
onSelectSearchResult: (RoomId, EventId?) -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
val strokeWidth = 1.dp
|
||||
|
||||
val drawBottomLineModifier = Modifier.drawBehind {
|
||||
drawLine(
|
||||
color = borderColor,
|
||||
start = Offset(0f, size.height),
|
||||
end = Offset(size.width, size.height),
|
||||
strokeWidth = strokeWidth.value
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton(onClick = onBackButtonClick) },
|
||||
title = {
|
||||
// The stateSaver will keep the selection state when returning to this UI
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val searchLabel = stringResource(CommonStrings.action_search)
|
||||
FilledTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = searchLabel },
|
||||
state = state.queryState,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
),
|
||||
trailingIcon = if (state.queryState.text.isNotEmpty()) {
|
||||
@Composable {
|
||||
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focusRequester.restoreFocusedChild()) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
focusRequester.saveFocusedChild()
|
||||
}
|
||||
}
|
||||
title = { SearchTopAppBarContent(state = state) }
|
||||
)
|
||||
},
|
||||
contentWindowInsets = scaffoldScrollableContentInsets,
|
||||
@@ -179,27 +137,9 @@ private fun GlobalSearchContent(
|
||||
Column(
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = drawBottomLineModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
SegmentedButton(
|
||||
index = 0,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
|
||||
text = stringResource(R.string.search_section_chats),
|
||||
)
|
||||
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
|
||||
|
||||
SegmentedButton(
|
||||
index = 1,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
|
||||
text = stringResource(R.string.search_section_messages),
|
||||
)
|
||||
}
|
||||
SearchTargetSelector(state = state)
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
|
||||
@@ -212,9 +152,21 @@ private fun GlobalSearchContent(
|
||||
state = lazyListState,
|
||||
) {
|
||||
val results = state.results.dataOrNull()
|
||||
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
|
||||
when {
|
||||
state.results.isUninitialized() -> startSearching()
|
||||
state.results.isLoading() -> loading()
|
||||
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
|
||||
searchHistory(historyResults) { result ->
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> Unit
|
||||
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
|
||||
}
|
||||
|
||||
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
|
||||
}
|
||||
} else {
|
||||
startSearching()
|
||||
}
|
||||
state.results.isLoading() -> loadingResults()
|
||||
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
|
||||
results is GlobalSearchResults.RoomListResults -> roomListResults(
|
||||
results = results.results,
|
||||
@@ -230,6 +182,89 @@ private fun GlobalSearchContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTopAppBarContent(state: GlobalSearchState) {
|
||||
// The stateSaver will keep the selection state when returning to this UI
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val searchLabel = stringResource(CommonStrings.action_search)
|
||||
FilledTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = searchLabel },
|
||||
state = state.queryState,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActionHandler { performDefault ->
|
||||
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
|
||||
performDefault()
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
),
|
||||
trailingIcon = if (state.queryState.text.isNotEmpty()) {
|
||||
@Composable {
|
||||
IconButton(
|
||||
modifier = Modifier.clip(CircleShape).size(20.dp),
|
||||
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = ElementTheme.colors.borderInteractivePrimary,
|
||||
contentColor = ElementTheme.colors.iconOnSolidPrimary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(16.dp),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!focusRequester.restoreFocusedChild()) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
focusRequester.saveFocusedChild()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchTargetSelector(state: GlobalSearchState) {
|
||||
if (state.queryState.text.isEmpty()) return
|
||||
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
) {
|
||||
SegmentedButton(
|
||||
index = 0,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
|
||||
text = stringResource(R.string.search_section_chats),
|
||||
)
|
||||
|
||||
SegmentedButton(
|
||||
index = 1,
|
||||
count = 2,
|
||||
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
|
||||
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
|
||||
text = stringResource(R.string.search_section_messages),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.startSearching() {
|
||||
item {
|
||||
IconTitleSubtitleMolecule(
|
||||
@@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loading() {
|
||||
private fun LazyListScope.searchHistory(
|
||||
historyResults: ImmutableList<SearchHistoryResultItem>,
|
||||
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> 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,
|
||||
)
|
||||
}
|
||||
items(
|
||||
items = historyResults,
|
||||
key = { it.id },
|
||||
) { result ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (result) {
|
||||
is SearchHistoryResultItem.Query -> {
|
||||
Icon(
|
||||
modifier = Modifier.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.History(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconPrimary,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = result.term,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
}
|
||||
is SearchHistoryResultItem.Room -> {
|
||||
val heroAvatars = remember(result.roomInfo.heroes) {
|
||||
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
|
||||
}
|
||||
Avatar(
|
||||
avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem),
|
||||
avatarType = AvatarType.Room(
|
||||
heroes = heroAvatars,
|
||||
isTombstoned = result.roomInfo.successorRoom != null,
|
||||
),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = result.roomInfo.name.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
|
||||
if (result.roomInfo.isDm) {
|
||||
val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId
|
||||
dmUserId?.let { userId ->
|
||||
Text(
|
||||
text = userId.value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
} else if (result.roomInfo.canonicalAlias != null) {
|
||||
Text(
|
||||
text = result.roomInfo.canonicalAlias?.value!!,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loadingResults() {
|
||||
item {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.encrypteddb.crypto.EncryptedFile
|
||||
import java.io.File
|
||||
|
||||
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
|
||||
|
||||
/**
|
||||
* Persists the search history in an encrypted file inside the current session file directory, using
|
||||
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSearchHistoryFileStore(
|
||||
@ApplicationContext private val context: Context,
|
||||
matrixClient: MatrixClient,
|
||||
) : SearchHistoryFileStore {
|
||||
private val file: File by lazy {
|
||||
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
|
||||
}
|
||||
|
||||
override fun read(): ByteArray? {
|
||||
if (!file.exists()) return null
|
||||
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
|
||||
}
|
||||
|
||||
override fun write(bytes: ByteArray) {
|
||||
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
|
||||
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun delete(): Boolean {
|
||||
return file.delete()
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSearchHistoryStore(
|
||||
private val fileStore: SearchHistoryFileStore,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : SearchHistoryStore {
|
||||
private val json by lazy { jsonProvider() }
|
||||
|
||||
// Guards [fileStore] access and the lazy initialization of [cache].
|
||||
private val mutex = Mutex()
|
||||
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
|
||||
private var isLoaded = false
|
||||
|
||||
override val history: Flow<List<SearchHistoryResult>> = flow {
|
||||
ensureLoaded()
|
||||
emitAll(cache)
|
||||
}
|
||||
|
||||
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
ensureLoadedLocked()
|
||||
val updated = buildList {
|
||||
add(result)
|
||||
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
|
||||
addAll(cache.value.filterNot { it == result })
|
||||
}
|
||||
.distinct()
|
||||
.take(MAX_SEARCH_HISTORY_SIZE)
|
||||
cache.value = updated
|
||||
persist(updated)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
isLoaded = true
|
||||
cache.value = emptyList()
|
||||
runCatchingExceptions { fileStore.delete() }
|
||||
.onFailure { Timber.e(it, "Failed to delete search history") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
|
||||
mutex.withLock {
|
||||
ensureLoadedLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called while holding [mutex].
|
||||
*/
|
||||
private fun ensureLoadedLocked() {
|
||||
if (isLoaded) return
|
||||
cache.value = readFromDisk()
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
private fun readFromDisk(): List<SearchHistoryResult> {
|
||||
return runCatchingExceptions {
|
||||
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
|
||||
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
|
||||
}.getOrElse {
|
||||
Timber.e(it, "Failed to read search history, resetting it")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(items: List<SearchHistoryResult>) {
|
||||
runCatchingExceptions {
|
||||
fileStore.write(json.encodeToString(items).encodeToByteArray())
|
||||
}.onFailure { Timber.e(it, "Failed to persist search history") }
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
/**
|
||||
* Low level, raw byte read/write of the persisted search history.
|
||||
*
|
||||
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
|
||||
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
|
||||
* bytes in an encrypted file.
|
||||
*/
|
||||
interface SearchHistoryFileStore {
|
||||
/**
|
||||
* Return the persisted bytes, or `null` if nothing has been persisted yet.
|
||||
*/
|
||||
fun read(): ByteArray?
|
||||
|
||||
/**
|
||||
* Persist [bytes], replacing any previously persisted content.
|
||||
*/
|
||||
fun write(bytes: ByteArray)
|
||||
|
||||
/**
|
||||
* Delete any persisted content.
|
||||
*
|
||||
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
|
||||
*/
|
||||
fun delete(): Boolean
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A single entry in the global search history.
|
||||
*
|
||||
* The history stores either the term used to perform a search (when the keyboard search action is
|
||||
* triggered) or the id of a room opened from the search results.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface SearchHistoryResult {
|
||||
@Serializable
|
||||
data class Query(val term: String) : SearchHistoryResult
|
||||
|
||||
@Serializable
|
||||
data class Room(val roomId: RoomId) : SearchHistoryResult
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Stores the global search history for the current session.
|
||||
*
|
||||
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
|
||||
* The most recent entry is always at the front of the list; when the cap is reached the oldest
|
||||
* entries are dropped.
|
||||
*/
|
||||
interface SearchHistoryStore {
|
||||
/**
|
||||
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
|
||||
*/
|
||||
val history: Flow<List<SearchHistoryResult>>
|
||||
|
||||
/**
|
||||
* Add a new [result] at the front of the history.
|
||||
*
|
||||
* If an equal entry already exists it is moved to the front instead of being duplicated. When
|
||||
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
|
||||
*/
|
||||
suspend fun add(result: SearchHistoryResult)
|
||||
|
||||
/**
|
||||
* Remove every entry from the history.
|
||||
*/
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of entries kept in the search history.
|
||||
*/
|
||||
const val MAX_SEARCH_HISTORY_SIZE = 25
|
||||
@@ -14,4 +14,5 @@
|
||||
<string name="search_section_messages">Messages</string>
|
||||
<string name="search_no_results_title">No results</string>
|
||||
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
|
||||
<string name="screen_search_recent_searches_section_title">Recent searches</string>
|
||||
</resources>
|
||||
|
||||
+95
-5
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
|
||||
import io.element.android.features.home.impl.search.history.FakeSearchHistoryStore
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryResult
|
||||
import io.element.android.features.home.impl.search.history.SearchHistoryStore
|
||||
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -28,6 +31,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.search.MessageSearchService
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||
@@ -41,12 +45,12 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.util.Optional
|
||||
|
||||
class GlobalSearchPresenterTest {
|
||||
@Test
|
||||
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
|
||||
}
|
||||
|
||||
cancelAndConsumeRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - toggle search visibility`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
// Skip the initial state
|
||||
skipItems(1)
|
||||
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.isSearchActive).isFalse()
|
||||
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - update target`() = runTest {
|
||||
val presenter = createGlobalSearchPresenter()
|
||||
presenter.test {
|
||||
// Skip initial item
|
||||
skipItems(1)
|
||||
|
||||
awaitItem().let { state ->
|
||||
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
|
||||
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
@@ -194,7 +206,7 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - message search results are mapped when the message search emits`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
|
||||
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
|
||||
val messageSearch = FakeMessageSearch()
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
|
||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||
}
|
||||
val presenter = createGlobalSearchPresenter(
|
||||
messageSearchService = FakeMessageSearchService(messageSearch),
|
||||
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SaveQueryToHistory stores the current query`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.queryState.edit { append("A query") }
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("A query"))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SaveQueryToHistory ignores a blank query`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).isEmpty()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SaveRoomToHistory stores the room`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(GlobalSearchEvent.SaveRoomToHistory(A_ROOM_ID))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - SearchHistoryResultSelected changes the search query when a previous search is selected`() = runTest {
|
||||
val searchHistoryStore = FakeSearchHistoryStore()
|
||||
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
// Skip loading search results state
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test")))
|
||||
|
||||
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
|
||||
assertThat(awaitItem().results.isLoading()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createGlobalSearchPresenter(
|
||||
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
|
||||
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
|
||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
|
||||
): GlobalSearchPresenter {
|
||||
return GlobalSearchPresenter(
|
||||
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
|
||||
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
|
||||
permalinkParser = permalinkParser,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixClient = matrixClient,
|
||||
searchHistoryStore = searchHistoryStore,
|
||||
)
|
||||
}
|
||||
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.home.impl.search
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.home.impl.R
|
||||
import io.element.android.features.home.impl.model.aRoomListRoomSummary
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.Test
|
||||
|
||||
class GlobalSearchViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `back button - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
pressBack()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back key - with search active toggles the search`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
pressBackKey()
|
||||
|
||||
// Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation)
|
||||
mainClock.advanceTimeBy(1)
|
||||
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState("Query"),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field)
|
||||
onNodeWithContentDescription(clearSearch).performClick()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
clickOn(R.string.search_section_chats)
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
clickOn(R.string.search_section_messages)
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
|
||||
val roomId = RoomId("!aRoom:server.org")
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.RoomListResults(
|
||||
persistentListOf(
|
||||
aRoomListRoomSummary(id = roomId.value, name = "A room result"),
|
||||
)
|
||||
)
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithText("A room result").performClick()
|
||||
onSelectSearchResult.assertSuccess()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
|
||||
val roomId = RoomId("!aMessageRoom:server.org")
|
||||
val eventId = EventId("\$anEvent:server.org")
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, eventId)
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.MESSAGES,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(
|
||||
GlobalSearchResults.MessageSearchResults(
|
||||
persistentListOf(
|
||||
MessageSearchResultItem.Message(
|
||||
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
|
||||
body = "A message body",
|
||||
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
|
||||
formattedTimestamp = "12:00",
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithText("A message room").performClick()
|
||||
onSelectSearchResult.assertSuccess()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
|
||||
val queryResult = SearchHistoryResultItem.Query("Recent term")
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(persistentListOf(queryResult)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
// A query history result must not trigger the search result callback
|
||||
onSelectSearchResult = EnsureNeverCalledWithTwoParams(),
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithText("Recent term").performClick()
|
||||
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
|
||||
}
|
||||
|
||||
@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(
|
||||
roomId = roomId,
|
||||
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
|
||||
)
|
||||
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
|
||||
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState(),
|
||||
results = AsyncData.Uninitialized,
|
||||
history = AsyncData.Success(persistentListOf(roomResult)),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
)
|
||||
// Remove automatic initial events
|
||||
eventsRecorder.clear()
|
||||
onNodeWithText("A history room").performClick()
|
||||
onSelectSearchResult.assertSuccess()
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
GlobalSearchEvent.SaveRoomToHistory(roomId),
|
||||
GlobalSearchEvent.SearchHistoryResultSelected(roomResult),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest {
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Uninitialized,
|
||||
),
|
||||
)
|
||||
val startSearching = activity!!.getString(R.string.start_searching_title)
|
||||
onNodeWithText(startSearching).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest {
|
||||
setGlobalSearchView(
|
||||
state = aGlobalSearchState(
|
||||
isSearchActive = true,
|
||||
currentTarget = GlobalSearchTarget.ROOMS,
|
||||
queryState = TextFieldState("Query"),
|
||||
results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())),
|
||||
),
|
||||
)
|
||||
val noResults = activity!!.getString(R.string.search_no_results_title)
|
||||
onNodeWithText(noResults).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setGlobalSearchView(
|
||||
state: GlobalSearchState,
|
||||
onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
) {
|
||||
setContent {
|
||||
GlobalSearchView(
|
||||
state = state,
|
||||
onSelectSearchResult = onSelectSearchResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultSearchHistoryStoreTest {
|
||||
@Test
|
||||
fun `history is empty when nothing has been persisted`() = runTest {
|
||||
val store = createStore()
|
||||
assertThat(store.history.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add prepends new entries so the most recent is first`() = runTest {
|
||||
val store = createStore()
|
||||
store.add(SearchHistoryResult.Query("first"))
|
||||
store.add(SearchHistoryResult.Query("second"))
|
||||
assertThat(store.history.first()).containsExactly(
|
||||
SearchHistoryResult.Query("second"),
|
||||
SearchHistoryResult.Query("first"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add supports both queries and rooms`() = runTest {
|
||||
val store = createStore()
|
||||
store.add(SearchHistoryResult.Query("term"))
|
||||
store.add(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
assertThat(store.history.first()).containsExactly(
|
||||
SearchHistoryResult.Room(A_ROOM_ID),
|
||||
SearchHistoryResult.Query("term"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding an existing entry moves it to the front without duplicating`() = runTest {
|
||||
val store = createStore()
|
||||
store.add(SearchHistoryResult.Query("a"))
|
||||
store.add(SearchHistoryResult.Query("b"))
|
||||
store.add(SearchHistoryResult.Query("a"))
|
||||
assertThat(store.history.first()).containsExactly(
|
||||
SearchHistoryResult.Query("a"),
|
||||
SearchHistoryResult.Query("b"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history is capped to the maximum size, dropping the oldest entries`() = runTest {
|
||||
val store = createStore()
|
||||
repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index ->
|
||||
store.add(SearchHistoryResult.Query("query$index"))
|
||||
}
|
||||
val history = store.history.first()
|
||||
assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE)
|
||||
// Newest entry is first...
|
||||
assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}"))
|
||||
// ...and the oldest ones have been dropped.
|
||||
assertThat(history).doesNotContain(SearchHistoryResult.Query("query0"))
|
||||
assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest {
|
||||
val fileStore = FakeSearchHistoryFileStore()
|
||||
createStore(fileStore).apply {
|
||||
add(SearchHistoryResult.Query("persisted term"))
|
||||
add(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
}
|
||||
|
||||
// A brand new store backed by the same file reads the same content back.
|
||||
val reloaded = createStore(fileStore)
|
||||
assertThat(reloaded.history.first()).containsExactly(
|
||||
SearchHistoryResult.Room(A_ROOM_ID),
|
||||
SearchHistoryResult.Query("persisted term"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear removes every entry and deletes the persisted content`() = runTest {
|
||||
val fileStore = FakeSearchHistoryFileStore()
|
||||
val store = createStore(fileStore)
|
||||
store.add(SearchHistoryResult.Query("term"))
|
||||
|
||||
store.clear()
|
||||
|
||||
assertThat(store.history.first()).isEmpty()
|
||||
assertThat(fileStore.content).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `corrupted content is ignored and results in an empty history`() = runTest {
|
||||
val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray())
|
||||
val store = createStore(fileStore)
|
||||
assertThat(store.history.first()).isEmpty()
|
||||
// The store recovers and can still add new entries.
|
||||
store.add(SearchHistoryResult.Room(RoomId("!recovered:domain")))
|
||||
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain")))
|
||||
}
|
||||
|
||||
private fun TestScope.createStore(
|
||||
fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(),
|
||||
) = DefaultSearchHistoryStore(
|
||||
fileStore = fileStore,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
jsonProvider = { Json { ignoreUnknownKeys = true } }
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
/**
|
||||
* In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore.
|
||||
*/
|
||||
class FakeSearchHistoryFileStore(
|
||||
initialContent: ByteArray? = null,
|
||||
) : SearchHistoryFileStore {
|
||||
var content: ByteArray? = initialContent
|
||||
private set
|
||||
|
||||
var writeCount = 0
|
||||
private set
|
||||
|
||||
override fun read(): ByteArray? = content
|
||||
|
||||
override fun write(bytes: ByteArray) {
|
||||
content = bytes
|
||||
writeCount++
|
||||
}
|
||||
|
||||
override fun delete(): Boolean {
|
||||
content = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeSearchHistoryStore : SearchHistoryStore {
|
||||
private val state = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
|
||||
|
||||
override val history: Flow<List<SearchHistoryResult>> = state
|
||||
|
||||
override suspend fun add(result: SearchHistoryResult) {
|
||||
state.value = buildList {
|
||||
add(result)
|
||||
addAll(state.value.filterNot { it == result })
|
||||
}.take(MAX_SEARCH_HISTORY_SIZE)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
state.value = emptyList()
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.search.history
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Test
|
||||
|
||||
class SearchHistoryResultSerializationTest {
|
||||
private val json = Json
|
||||
|
||||
@Test
|
||||
fun `Query round-trips through serialization`() {
|
||||
val original: SearchHistoryResult = SearchHistoryResult.Query("hello world")
|
||||
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
|
||||
assertThat(decoded).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Room round-trips through serialization`() {
|
||||
val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID)
|
||||
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
|
||||
assertThat(decoded).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RoomId is serialized as its raw string value`() {
|
||||
val encoded = json.encodeToString<SearchHistoryResult>(SearchHistoryResult.Room(A_ROOM_ID))
|
||||
assertThat(encoded).contains(A_ROOM_ID.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed list round-trips preserving order`() {
|
||||
val original = listOf(
|
||||
SearchHistoryResult.Query("first"),
|
||||
SearchHistoryResult.Room(A_ROOM_ID),
|
||||
SearchHistoryResult.Query("second"),
|
||||
SearchHistoryResult.Room(A_ROOM_ID_2),
|
||||
)
|
||||
val decoded = json.decodeFromString<List<SearchHistoryResult>>(json.encodeToString(original))
|
||||
assertThat(decoded).containsExactlyElementsIn(original).inOrder()
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -460,8 +460,7 @@ fun MessagesView(
|
||||
if (state.redactEventAction is MessagesState.ConfirmingRedaction) {
|
||||
TextFieldDialog(
|
||||
title = stringResource(R.string.screen_room_confirm_removal_title),
|
||||
content = stringResource(R.string.screen_room_confirm_removal_message),
|
||||
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder_android),
|
||||
placeholder = stringResource(R.string.screen_room_confirm_removal_reason_placeholder),
|
||||
supportingText = stringResource(R.string.screen_room_confirm_removal_reason_supporting_text),
|
||||
value = null,
|
||||
submitText = stringResource(CommonStrings.action_remove),
|
||||
|
||||
@@ -47,10 +47,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Standort teilen"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Umfrage"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Textformatierung"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Möchtest du diese Nachricht wirklich entfernen?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Grund (optional)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Eingeben …"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Grund …"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Entfernen bestätigen"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist derzeit nicht verfügbar."</string>
|
||||
|
||||
@@ -47,10 +47,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Jaa sijainti"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Kysely"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Tekstin muotoilu"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Oletko varma, että haluat poistaa tämän viestin?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Syy (valinnainen)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Kirjoita…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Syy…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Valinnainen)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Vahvista poistaminen"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Viestihistoria ei ole tällä hetkellä saatavilla"</string>
|
||||
|
||||
@@ -47,10 +47,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Dijeli lokaciju"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Anketa"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Oblikovanje teksta"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Jeste li sigurni da želite ukloniti ovu poruku?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Razlog (neobavezno)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Unesite…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Razlog"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Neobavezno)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Potvrdite uklanjanje"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Povijest poruka trenutačno nije dostupna."</string>
|
||||
|
||||
@@ -47,10 +47,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Hely megosztása"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Szavazás"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Szövegformázás"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Biztosan eltávolítja ezt az üzenetet?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Ok (nem kötelező)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Írja be…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Ok…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Választható)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Eltávolítás megerősítése"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Az üzenetelőzmények jelenleg nem érhetők el."</string>
|
||||
|
||||
@@ -46,10 +46,8 @@
|
||||
<string name="screen_room_attachment_source_location">"場所を共有"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"書式設定"</string>
|
||||
<string name="screen_room_confirm_removal_message">"このメッセージを削除しますか?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"理由 (任意)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"入力…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"理由…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(任意)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"削除の確認"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"過去のメッセージを現在表示できません。"</string>
|
||||
|
||||
@@ -48,10 +48,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Ankieta"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Formatowanie tekstu"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Czy na pewno chcesz usunąć wiadomość?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Powód (opcjonalne)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Powód…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Powód…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Opcjonalne)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Potwierdź usunięcie"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Historia wiadomości jest obecnie niedostępna."</string>
|
||||
|
||||
@@ -48,10 +48,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Поделиться местоположением"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Опрос"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Вы уверены, что хотите удалить это сообщение?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Причина (необязательно)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Введите…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Причина…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Необязательно)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Подтвердите удаление"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате."</string>
|
||||
|
||||
@@ -48,10 +48,8 @@
|
||||
<string name="screen_room_attachment_source_location">"Поділитися місцеперебуванням"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Опитування"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Ви впевнені, що хочете вилучити це повідомлення?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Причина (необов\'язково)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Ввести…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Причина…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Необов\'язково)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Підтвердити вилучення"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>
|
||||
|
||||
@@ -46,10 +46,8 @@
|
||||
<string name="screen_room_attachment_source_location">"共享位置"</string>
|
||||
<string name="screen_room_attachment_source_poll">"投票"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"文本格式化"</string>
|
||||
<string name="screen_room_confirm_removal_message">"确定要移除此消息?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"理由(可选)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"输入…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"理由…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(可选)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"确认移除"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"消息历史记录当前不可用。"</string>
|
||||
|
||||
@@ -47,12 +47,10 @@
|
||||
<string name="screen_room_attachment_source_location">"Share location"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Poll"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Text Formatting"</string>
|
||||
<string name="screen_room_confirm_removal_message">"Are you sure you wish to remove this message?"</string>
|
||||
<string name="screen_room_confirm_removal_reason_label">"Reason (optional)"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Enter…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder_android">"Reason…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_placeholder">"Enter reason…"</string>
|
||||
<string name="screen_room_confirm_removal_reason_supporting_text">"(Optional)"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Confirm removal"</string>
|
||||
<string name="screen_room_confirm_removal_title">"Remove message?"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Verify this device to see your message history."</string>
|
||||
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
||||
|
||||
@@ -17,12 +17,12 @@ constraintlayout = "2.2.2"
|
||||
constraintlayout_compose = "1.1.2"
|
||||
lifecycle = "2.11.0"
|
||||
activity = "1.13.0"
|
||||
media3 = "1.11.0"
|
||||
media3 = "1.11.1"
|
||||
camera = "1.6.2"
|
||||
work = "2.11.2"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2026.08.00"
|
||||
compose_bom = "2026.09.00"
|
||||
|
||||
# Coroutines
|
||||
coroutines = "1.11.0"
|
||||
@@ -49,7 +49,7 @@ haze = "1.7.2"
|
||||
maplibre-compose = "0.15.0"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "3.19.1"
|
||||
dependencyAnalysis = "3.19.2"
|
||||
|
||||
# DI
|
||||
metro = "1.4.3"
|
||||
@@ -205,7 +205,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
|
||||
sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" }
|
||||
sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||
sqlcipher = "net.zetetic:sqlcipher-android:4.19.0"
|
||||
sqlite = "androidx.sqlite:sqlite-ktx:2.7.0"
|
||||
sqlite = "androidx.sqlite:sqlite-ktx:2.7.1"
|
||||
unifiedpush = "org.unifiedpush.android:connector:3.3.5"
|
||||
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" }
|
||||
|
||||
+50
-36
@@ -8,39 +8,43 @@
|
||||
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceAtLeast
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.SemanticColors
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
private const val MAX_COUNT = 99
|
||||
private const val MAX_COUNT_STRING = "+$MAX_COUNT"
|
||||
private const val MAX_COUNT_STRING = "$MAX_COUNT+"
|
||||
|
||||
/**
|
||||
* A counter atom that displays a number in a circle.
|
||||
* Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev
|
||||
*
|
||||
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
|
||||
* If the number is less than 1, the counter will not be displayed.
|
||||
* If the number is less than 1, just a dot will be displayed instead.
|
||||
* @param modifier The modifier to apply to this layout.
|
||||
* @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
|
||||
* @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
|
||||
* @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary].
|
||||
* @param contentPadding The padding to apply to the text inside the counter.
|
||||
* @param textStyle The style to apply to the text inside the counter.
|
||||
* @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`.
|
||||
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
|
||||
* Only used when [containerColor] is null.
|
||||
*/
|
||||
@@ -50,41 +54,51 @@ fun CounterAtom(
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
textStyle: TextStyle = CounterAtomDefaults.textStyle,
|
||||
dotSize: Dp = 12.0.dp,
|
||||
isCritical: Boolean = false,
|
||||
) {
|
||||
if (count < 1) return
|
||||
val countAsText = when (count) {
|
||||
in 0..MAX_COUNT -> count.toString()
|
||||
in Int.MIN_VALUE..0 -> null
|
||||
in 1..MAX_COUNT -> count.toString()
|
||||
else -> MAX_COUNT_STRING
|
||||
}
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = MAX_COUNT_STRING,
|
||||
style = textStyle
|
||||
)
|
||||
val textSize = textLayoutResult.size
|
||||
val squareSize = maxOf(textSize.width, textSize.height)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(squareSize.toDp() + 1.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
containerColor ?: if (isCritical) {
|
||||
ElementTheme.colors.iconCriticalPrimary
|
||||
} else {
|
||||
ElementTheme.colors.iconAccentPrimary
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
|
||||
val minSingleDigitSize = if (countAsText?.length == 1) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
// Measure the maximum count string size
|
||||
val textLayoutResult = textMeasurer.measure(
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
style = textStyle
|
||||
)
|
||||
val textSize = textLayoutResult.size
|
||||
maxOf(textSize.width, textSize.height)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
androidx.compose.material3.Badge(
|
||||
modifier = modifier.then(
|
||||
if (countAsText == null) Modifier.sizeIn(minWidth = dotSize, minHeight = dotSize) else Modifier
|
||||
),
|
||||
containerColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary,
|
||||
contentColor = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
content = countAsText?.let {
|
||||
@Composable {
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(
|
||||
minWidth = (minSingleDigitSize.toDp() - 8.dp).coerceAtLeast(0.dp),
|
||||
minHeight = minSingleDigitSize.toDp()
|
||||
).padding(contentPadding),
|
||||
text = countAsText,
|
||||
style = textStyle,
|
||||
color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
object CounterAtomDefaults {
|
||||
@@ -95,7 +109,7 @@ object CounterAtomDefaults {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CounterAtomPreview() = ElementPreview {
|
||||
Column(verticalArrangement = spacedBy(2.dp)) {
|
||||
Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CounterAtom(count = 0)
|
||||
CounterAtom(count = 4)
|
||||
CounterAtom(count = 99)
|
||||
|
||||
+28
-26
@@ -9,15 +9,16 @@
|
||||
package io.element.android.libraries.designsystem.atomic.atoms
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -37,35 +38,36 @@ fun UnreadIndicatorAtom(
|
||||
isVisible: Boolean = true,
|
||||
contentDescription: String? = null,
|
||||
border: BorderStroke? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
) {
|
||||
when {
|
||||
!isVisible -> Spacer(modifier = modifier.size(size))
|
||||
count != null && count >= 1 -> CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
else -> Box(
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
|
||||
)
|
||||
else -> count?.let {
|
||||
CounterAtom(
|
||||
count = count.toInt(),
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription?.let { this.contentDescription = it }
|
||||
}
|
||||
.then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 50)) else Modifier),
|
||||
containerColor = color,
|
||||
contentColor = ElementTheme.colors.bgCanvasDefault,
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UnreadIndicatorAtomPreview() = ElementPreview {
|
||||
UnreadIndicatorAtom()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
UnreadIndicatorAtom(count = null)
|
||||
UnreadIndicatorAtom(count = 0)
|
||||
UnreadIndicatorAtom(count = 1)
|
||||
UnreadIndicatorAtom(count = 10)
|
||||
UnreadIndicatorAtom(count = 99)
|
||||
UnreadIndicatorAtom(count = 999)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -32,8 +32,8 @@ import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import io.element.android.compound.theme.LinkColor
|
||||
import io.element.android.libraries.androidutils.text.LinkifyHelper
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -142,7 +142,7 @@ fun ClickableLinkText(
|
||||
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
|
||||
val original = this
|
||||
val spannable = SpannableString.valueOf(this.text)
|
||||
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
|
||||
LinkifyHelper.linkify(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
|
||||
|
||||
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||
return buildAnnotatedString {
|
||||
@@ -150,7 +150,9 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
|
||||
for (span in spans) {
|
||||
val start = spannable.getSpanStart(span)
|
||||
val end = spannable.getSpanEnd(span)
|
||||
if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
|
||||
if (original.getLinkAnnotations(start, end).isEmpty() &&
|
||||
original.getStringAnnotations(LINK_TAG, start, end).isEmpty() &&
|
||||
original.getStringAnnotations("inline_code", start, end).isEmpty()) {
|
||||
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
|
||||
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
|
||||
|
||||
|
||||
+60
-27
@@ -8,17 +8,18 @@
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FloatingToolbarColors
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
import androidx.compose.material3.FloatingToolbarHorizontalFabPosition
|
||||
@@ -28,7 +29,6 @@ import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
@@ -139,8 +139,20 @@ fun HorizontalFloatingToolbarItem(
|
||||
contentColor = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
}
|
||||
Box {
|
||||
FilledIconButton(
|
||||
BadgedBox(
|
||||
badge = {
|
||||
counter?.let {
|
||||
CounterAtom(
|
||||
count = it,
|
||||
// Tweak the offset so it's centered on the icon
|
||||
modifier = Modifier.offset(x = (-16).dp, y = 10.dp),
|
||||
contentPadding = PaddingValues(vertical = 0.5.dp),
|
||||
textStyle = ElementTheme.typography.fontBodySmMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.widthIn(min = 56.dp),
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
@@ -151,15 +163,6 @@ fun HorizontalFloatingToolbarItem(
|
||||
contentDescription = tooltipLabel,
|
||||
)
|
||||
}
|
||||
if (counter != null) {
|
||||
CounterAtom(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 6.dp, end = 3.dp),
|
||||
count = counter,
|
||||
textStyle = ElementTheme.typography.fontBodyXsMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,18 +175,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) {
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HorizontalFloatingToolbarPreview() = ElementPreview {
|
||||
ContentToPreview(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
Column {
|
||||
ContentToPreview(
|
||||
count = 1,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
ContentToPreview(
|
||||
count = 99,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
ContentToPreview(
|
||||
count = 999,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Plus(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@@ -197,10 +229,11 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
count: Int? = 6,
|
||||
floatingActionButton: (@Composable () -> Unit)?,
|
||||
) {
|
||||
HorizontalFloatingToolbar(
|
||||
modifier = Modifier.padding(28.dp),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp),
|
||||
floatingActionButton = floatingActionButton,
|
||||
) {
|
||||
listOf(
|
||||
@@ -214,7 +247,7 @@ private fun ContentToPreview(
|
||||
icon = icon,
|
||||
tooltipLabel = "Label",
|
||||
isSelected = index == 0,
|
||||
counter = if (index == 0) 6 else null,
|
||||
counter = if (index == 0) count else null,
|
||||
forceRenderingTooltip = true,
|
||||
onClick = { },
|
||||
)
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import java.io.FileOutputStream
|
||||
*
|
||||
* It uses hardcoded constants that are used in that library, for backwards compatibility reasons.
|
||||
*/
|
||||
internal class EncryptedFile(
|
||||
class EncryptedFile(
|
||||
private val context: Context,
|
||||
private val file: File
|
||||
) {
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.libraries.htmlrenderer.api
|
||||
|
||||
import io.element.android.libraries.designsystem.components.LINK_TAG
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
/**
|
||||
@@ -28,7 +29,7 @@ interface HtmlMessageParser {
|
||||
* Tag used for the string annotation attached to link (`<a href>`) text ranges in a
|
||||
* [ParagraphNode.text]. The annotation value is the link target URL.
|
||||
*/
|
||||
const val LINK_ANNOTATION_TAG = "url"
|
||||
const val LINK_ANNOTATION_TAG = LINK_TAG
|
||||
|
||||
/**
|
||||
* Tag used for the string annotation attached to inline code (`<code>`) text ranges in a
|
||||
|
||||
+12
@@ -54,6 +54,18 @@ data class ParagraphNode(
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A heading (`<h1>`–`<h6>`). Like a [ParagraphNode] it carries inline content ([text] and
|
||||
* [inlineContent]), plus the heading [level] from 1 (largest) to 6 (smallest), used by the renderer
|
||||
* to pick the typography.
|
||||
*/
|
||||
@Immutable
|
||||
data class HeaderNode(
|
||||
val level: Int,
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent> = persistentMapOf(),
|
||||
) : BlockNode
|
||||
|
||||
/**
|
||||
* A preformatted code block (`<pre>`). Rendered in a monospaced, horizontally
|
||||
* scrollable container. The text is kept raw: no inline styling is applied inside it.
|
||||
|
||||
+82
-13
@@ -62,6 +62,7 @@ import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
@@ -70,11 +71,13 @@ import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.linkify
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
@@ -157,6 +160,7 @@ internal fun BlockNode.lastBlockNode(): BlockNode? = when (this) {
|
||||
is ListNode -> this
|
||||
is QuoteNode -> this
|
||||
is CodeBlockNode -> this
|
||||
is HeaderNode -> this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -179,7 +183,7 @@ private fun BlockNodeView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode) {
|
||||
if (node === context.lastBlockNode && node !is ParagraphNode && node !is HeaderNode) {
|
||||
SideEffect { context.onContentLayoutChange(ContentAvoidingLayoutData.NotOverlapping) }
|
||||
}
|
||||
|
||||
@@ -188,6 +192,7 @@ private fun BlockNodeView(
|
||||
is ParagraphNode -> {
|
||||
ParagraphView(node = node, context = context, modifier = modifier)
|
||||
}
|
||||
is HeaderNode -> HeaderView(node = node, context = context, modifier = modifier)
|
||||
is CodeBlockNode -> CodeBlockView(node = node, modifier = modifier)
|
||||
is QuoteNode -> QuoteView(node = node, context = context, modifier = modifier)
|
||||
is ListNode -> ListView(node = node, context = context, modifier = modifier)
|
||||
@@ -200,14 +205,6 @@ private fun ParagraphView(
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(node.text, linkColor) {
|
||||
node.text.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val inlineContent = rememberMentionInlineContent(node.inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
@@ -215,21 +212,87 @@ private fun ParagraphView(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = ElementTheme.typography.fontBodyMdRegular,
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderView(
|
||||
node: HeaderNode,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Only the last rendered paragraph (and only when not inside a code block / quote) reports its
|
||||
// measured last line to the timeline.
|
||||
val measureLastLine = if (node === context.lastBlockNode) {
|
||||
ContentAvoidingLayout.measureLastTextLine(onContentLayoutChange = context.onContentLayoutChange)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
TextBlock(
|
||||
text = node.text,
|
||||
inlineContent = node.inlineContent,
|
||||
textStyle = headerTextStyle(node.level),
|
||||
context = context,
|
||||
modifier = modifier,
|
||||
onTextLayout = { measureLastLine?.invoke(it) }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an inline-content block (a paragraph or a header) as a [Text] with [textStyle], applying
|
||||
* link styling, the inline-code background boxes, mention/image inline content and link taps.
|
||||
*/
|
||||
@Composable
|
||||
private fun TextBlock(
|
||||
text: AnnotatedString,
|
||||
inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
textStyle: TextStyle,
|
||||
context: RenderContext,
|
||||
modifier: Modifier = Modifier,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
) {
|
||||
val linkColor = ElementTheme.colors.textLinkExternal
|
||||
val codeBackgroundColor = ElementTheme.colors.bgSubtleSecondary
|
||||
val codeBorderColor = ElementTheme.colors.borderInteractiveSecondary
|
||||
val styledText = remember(text, linkColor) {
|
||||
text.linkify(SpanStyle(color = linkColor))
|
||||
.applyLinkStyles(linkColor = linkColor)
|
||||
}
|
||||
val resolvedInlineContent = rememberMentionInlineContent(inlineContent, context)
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = styledText,
|
||||
modifier = modifier
|
||||
.drawInlineCodeBackgrounds(styledText, layoutResult, codeBackgroundColor, codeBorderColor, LocalDensity.current)
|
||||
.linkTapHandler(styledText, layoutResult, context),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
style = textStyle,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
inlineContent = inlineContent,
|
||||
inlineContent = resolvedInlineContent,
|
||||
onTextLayout = { result ->
|
||||
layoutResult.value = result
|
||||
measureLastLine?.invoke(result)
|
||||
onTextLayout(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps an `<h1>`–`<h6>` [level] to a Compound typography style, largest first. */
|
||||
@Composable
|
||||
private fun headerTextStyle(level: Int): TextStyle = when (level) {
|
||||
// For safety reasons, use the same style for H1 and H2, to avoid people using H1 for 'shouting' spam that fills the timeline with huge text
|
||||
1, 2 -> ElementTheme.typography.fontHeadingLgBold
|
||||
3 -> ElementTheme.typography.fontHeadingMdBold
|
||||
4 -> ElementTheme.typography.fontHeadingSmMedium
|
||||
5 -> ElementTheme.typography.fontBodyLgMedium
|
||||
else -> ElementTheme.typography.fontBodyMdMedium
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBlockView(
|
||||
node: CodeBlockNode,
|
||||
@@ -411,7 +474,13 @@ private fun AnnotatedString.applyLinkStyles(linkColor: Color): AnnotatedString {
|
||||
if (linkRanges.isEmpty()) return this
|
||||
return buildAnnotatedString {
|
||||
append(this@applyLinkStyles)
|
||||
linkRanges.forEach { addStyle(SpanStyle(color = linkColor), it.start, it.end) }
|
||||
linkRanges.forEach { linkRange ->
|
||||
val containsColorSpan =
|
||||
spanStyles.any { colorRange -> colorRange.start == linkRange.start && colorRange.end == linkRange.end && colorRange.item.color == linkColor }
|
||||
if (!containsColorSpan) {
|
||||
addStyle(SpanStyle(color = linkColor), linkRange.start, linkRange.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withAnnotation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
@@ -25,6 +26,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.ListItemNode
|
||||
@@ -54,6 +56,7 @@ internal fun HtmlMessageContentPreview(
|
||||
internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<DocumentNode> {
|
||||
override val values: Sequence<DocumentNode>
|
||||
get() = sequenceOf(
|
||||
headers(),
|
||||
richParagraph(),
|
||||
inlineCode(),
|
||||
multilineInlineCode(),
|
||||
@@ -65,6 +68,7 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
mentionOwn(),
|
||||
nestedList(),
|
||||
nestedListWithComplexContents(),
|
||||
linkifiedParagraph(),
|
||||
)
|
||||
|
||||
private fun richParagraph() = document(
|
||||
@@ -82,6 +86,28 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
),
|
||||
)
|
||||
|
||||
private fun linkifiedParagraph() = document(
|
||||
paragraph(
|
||||
buildAnnotatedString {
|
||||
append("Hello me@matrix.org, check out element.io and and call +1 234 567 8900, then get in touch with ")
|
||||
appendInlineContent(MENTION_ID, "@alice:example.org")
|
||||
append(".\n\n")
|
||||
append("Also, ignore this link: ")
|
||||
val start = length
|
||||
append("https://matrix.org")
|
||||
addStringAnnotation(LINK_ANNOTATION_TAG, "https://matrix.org", start, length)
|
||||
append(" and this one: ")
|
||||
withAnnotation(INLINE_CODE_ANNOTATION_TAG, "") {
|
||||
append("https://element.io")
|
||||
}
|
||||
append(".")
|
||||
},
|
||||
inlineContent = persistentMapOf(
|
||||
MENTION_ID to MentionNodeContent.User(displayText = "@alice", userId = UserId("@alice:example.org")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun inlineCode() = document(
|
||||
paragraph(
|
||||
buildAnnotatedString {
|
||||
@@ -213,6 +239,16 @@ internal class HtmlMessageContentPreviewParam : PreviewParameterProvider<Documen
|
||||
),
|
||||
)
|
||||
|
||||
private fun headers() = document(
|
||||
HeaderNode(level = 1, text = AnnotatedString("Heading 1")),
|
||||
HeaderNode(level = 2, text = AnnotatedString("Heading 2")),
|
||||
HeaderNode(level = 3, text = AnnotatedString("Heading 3")),
|
||||
HeaderNode(level = 4, text = AnnotatedString("Heading 4")),
|
||||
HeaderNode(level = 5, text = AnnotatedString("Heading 5")),
|
||||
HeaderNode(level = 6, text = AnnotatedString("Heading 6")),
|
||||
paragraph(AnnotatedString("Body text under the headings.")),
|
||||
)
|
||||
|
||||
private fun document(vararg blocks: BlockNode) = DocumentNode(blocks.toList().toImmutableList())
|
||||
|
||||
private fun paragraph(
|
||||
|
||||
+26
-4
@@ -18,6 +18,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.htmlrenderer.api.BlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.DocumentNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.INLINE_CODE_ANNOTATION_TAG
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser.Companion.LINK_ANNOTATION_TAG
|
||||
@@ -30,6 +31,7 @@ import io.element.android.libraries.htmlrenderer.api.spans.InlineCodeSpanStyle
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.jsoup.nodes.Document
|
||||
@@ -81,12 +83,13 @@ class DefaultHtmlMessageParser(
|
||||
return blocks.toImmutableList()
|
||||
}
|
||||
|
||||
private fun parseBlock(element: Element): BlockNode? = when (element.tagName()) {
|
||||
private fun parseBlock(element: Element): BlockNode? = when (val tag = element.tagName()) {
|
||||
TAG_P -> buildParagraph(element.childNodes())
|
||||
TAG_PRE -> CodeBlockNode(code = element.wholeText().trimEnd('\n'))
|
||||
TAG_BLOCKQUOTE -> QuoteNode(children = parseBlocks(element))
|
||||
TAG_UL -> buildList(element, ordered = false)
|
||||
TAG_OL -> buildList(element, ordered = true)
|
||||
in HEADER_TAGS -> buildHeader(element, level = tag.removePrefix("h").toInt())
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -102,6 +105,17 @@ class DefaultHtmlMessageParser(
|
||||
|
||||
/** Builds a [ParagraphNode] from a run of inline [nodes], or `null` if it holds no content. */
|
||||
private fun buildParagraph(nodes: List<Node>): ParagraphNode? {
|
||||
return buildInlineContent(nodes)?.let { ParagraphNode(text = it.text, inlineContent = it.inlineContent) }
|
||||
}
|
||||
|
||||
/** Builds a [HeaderNode] of the given [level] from the inline content of [element], or `null` if empty. */
|
||||
private fun buildHeader(element: Element, level: Int): HeaderNode? {
|
||||
return buildInlineContent(element.childNodes())?.let {
|
||||
HeaderNode(level = level, text = it.text, inlineContent = it.inlineContent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInlineContent(nodes: List<Node>): InlineContentBuilder.Result? {
|
||||
val builder = InlineContentBuilder()
|
||||
nodes.forEach { builder.append(it) }
|
||||
return builder.build()
|
||||
@@ -199,21 +213,29 @@ class DefaultHtmlMessageParser(
|
||||
}
|
||||
}
|
||||
|
||||
fun build(): ParagraphNode? {
|
||||
fun build(): Result? {
|
||||
val text = builder.toAnnotatedString()
|
||||
if (text.isBlank() && mentions.isEmpty()) return null
|
||||
return ParagraphNode(
|
||||
return Result(
|
||||
text = text,
|
||||
inlineContent = mentions.toImmutableMap(),
|
||||
)
|
||||
}
|
||||
|
||||
/** The flattened inline content of a block: styled text plus its inline-content placeholders. */
|
||||
inner class Result(
|
||||
val text: AnnotatedString,
|
||||
val inlineContent: ImmutableMap<String, MentionNodeContent>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tags that start a new block-level node. Anything else is treated as inline content. */
|
||||
private fun Element.isBlockLevel(): Boolean = tagName() in BLOCK_TAGS
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL)
|
||||
private val HEADER_TAGS = setOf("h1", "h2", "h3", "h4", "h5", "h6")
|
||||
|
||||
private val BLOCK_TAGS = setOf(TAG_P, TAG_PRE, TAG_BLOCKQUOTE, TAG_UL, TAG_OL) + HEADER_TAGS
|
||||
|
||||
private const val TAG_P = "p"
|
||||
private const val TAG_PRE = "pre"
|
||||
|
||||
+19
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.htmlrenderer.api.CodeBlockNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HeaderNode
|
||||
import io.element.android.libraries.htmlrenderer.api.HtmlMessageParser
|
||||
import io.element.android.libraries.htmlrenderer.api.ListNode
|
||||
import io.element.android.libraries.htmlrenderer.api.MentionNodeContent
|
||||
@@ -191,6 +192,24 @@ class DefaultHtmlMessageParserTest : RobolectricTest() {
|
||||
assertThat(paragraph.text.text).isEqualTo("Actual message")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headers become HeaderNodes with their level and inline content`() {
|
||||
val document = parse("<h1>Title</h1><h3>Subtitle with <strong>bold</strong></h3><h6>Small</h6>")
|
||||
assertThat(document.children).hasSize(3)
|
||||
|
||||
val h1 = document.children[0] as HeaderNode
|
||||
assertThat(h1.level).isEqualTo(1)
|
||||
assertThat(h1.text.text).isEqualTo("Title")
|
||||
|
||||
val h3 = document.children[1] as HeaderNode
|
||||
assertThat(h3.level).isEqualTo(3)
|
||||
assertThat(h3.text.text).isEqualTo("Subtitle with bold")
|
||||
|
||||
val h6 = document.children[2] as HeaderNode
|
||||
assertThat(h6.level).isEqualTo(6)
|
||||
assertThat(h6.text.text).isEqualTo("Small")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank input produces an empty document`() {
|
||||
val document = parse(" ")
|
||||
|
||||
+5
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
|
||||
*/
|
||||
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
|
||||
|
||||
/**
|
||||
* Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
|
||||
*/
|
||||
suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?>
|
||||
|
||||
/**
|
||||
* Get a room info flow for a given room ID.
|
||||
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
|
||||
import io.element.android.libraries.androidutils.metadata.isInDebug
|
||||
import java.io.Serializable
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@JvmInline
|
||||
value class RoomId(val value: String) : Serializable {
|
||||
init {
|
||||
|
||||
+8
@@ -779,6 +779,14 @@ class RustMatrixClient(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoomListService.roomOrNull(roomId.value)?.use { room ->
|
||||
roomInfoMapper.map(room.roomInfo())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> {
|
||||
return mxCallbackFlow {
|
||||
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
|
||||
|
||||
+4
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import java.io.File
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
@@ -173,6 +174,7 @@ class FakeMatrixClient(
|
||||
var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ ->
|
||||
Result.success(null)
|
||||
}
|
||||
var getRoomInfoLambda = { _: RoomId -> Result.success<RoomInfo?>(null) }
|
||||
var getRoomInfoFlowLambda = { _: RoomId ->
|
||||
flowOf<Optional<RoomInfo>>(Optional.empty())
|
||||
}
|
||||
@@ -379,6 +381,8 @@ class FakeMatrixClient(
|
||||
return Result.success(visitedRoomsId)
|
||||
}
|
||||
|
||||
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = getRoomInfoLambda(roomId)
|
||||
|
||||
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
|
||||
|
||||
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
|
||||
|
||||
+6
@@ -89,6 +89,12 @@ private object CustomHtmlToDomParser {
|
||||
"blockquote",
|
||||
"p",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
// Add custom `mx-reply` tag, even if it's just to remove its contents from the plain text version of the message
|
||||
"mx-reply"
|
||||
)
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.libraries.matrix.ui.model
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -20,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.ui.R
|
||||
|
||||
@Immutable
|
||||
data class InviteSender(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
|
||||
@@ -18,4 +18,5 @@ dependencies {
|
||||
implementation(libs.appyx.core)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
}
|
||||
|
||||
+9
-24
@@ -8,38 +8,23 @@
|
||||
|
||||
package io.element.android.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.node
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1518-85323
|
||||
*
|
||||
* @param buildContext the [BuildContext] of the Node.
|
||||
* @param state the [EmptyState] of the Node. Default value will render an empty node forever.
|
||||
*/
|
||||
fun emptyNode(
|
||||
buildContext: BuildContext,
|
||||
state: EmptyState = EmptyState(Duration.INFINITE),
|
||||
): Node = node(buildContext) { modifier ->
|
||||
EmptyView(modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyView(
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
)
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview() = ElementPreview {
|
||||
EmptyView(Modifier)
|
||||
EmptyView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.libraries.ui.common.nodes
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* @property delayBeforeShowingContent when finite, a loading indicator fades in after this delay. Use it for
|
||||
* placeholders which can stay on screen for a long time, or forever, else the application looks frozen.
|
||||
*/
|
||||
data class EmptyState(
|
||||
val delayBeforeShowingContent: Duration,
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
open class EmptyStatePreviewParam : PreviewParameterProvider<EmptyState> {
|
||||
override val values: Sequence<EmptyState>
|
||||
get() = sequenceOf(
|
||||
EmptyState(Duration.INFINITE),
|
||||
EmptyState(1.seconds),
|
||||
)
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.libraries.ui.common.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.DelayedVisibility
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun EmptyView(
|
||||
state: EmptyState,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ElementTheme.colors.bgCanvasDefault),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.delayBeforeShowingContent.isFinite()) {
|
||||
DelayedVisibility(duration = state.delayBeforeShowingContent) {
|
||||
LoadingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() = Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringResource(CommonStrings.common_loading),
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmptyViewPreview(
|
||||
@PreviewParameter(EmptyStatePreviewParam::class) state: EmptyState
|
||||
) = ElementPreview {
|
||||
EmptyView(state = state)
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user