Merge branch 'release/0.4.8' into main

This commit is contained in:
ganfra 2024-04-10 15:48:46 +02:00
commit 06c34d69ab
1015 changed files with 12210 additions and 2845 deletions

View file

@ -0,0 +1,11 @@
---
name: Task that belongs to a story/epic
about: A skeleton task where the details are all contained within a story or epic
on element-meta.
title: "[Task] "
labels: T-Task
assignees: ''
---
Please see and discuss the details in the meta issue.

View file

@ -32,7 +32,7 @@ jobs:
mkdir -p screenshots/en mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages - name: Deploy GitHub Pages
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots publish_dir: ./screenshots

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.22" /> <option name="version" value="1.9.23" />
</component> </component>
</project> </project>

View file

@ -23,7 +23,8 @@ appId: ${MAESTRO_APP_ID}
- inputText: ${MAESTRO_PASSWORD} - inputText: ${MAESTRO_PASSWORD}
- pressKey: Enter - pressKey: Enter
- tapOn: "Continue" - tapOn: "Continue"
- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
- runFlow: ./verifySession.yaml
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml - runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now" - tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml - runFlow: ../assertions/assertHomeDisplayed.yaml
- runFlow: ./verifySession.yaml

View file

@ -1,11 +1,13 @@
appId: ${MAESTRO_APP_ID} appId: ${MAESTRO_APP_ID}
--- ---
- tapOn: "Continue"
- takeScreenshot: build/maestro/150-Verify - takeScreenshot: build/maestro/150-Verify
- tapOn: "Enter recovery key" - tapOn: "Enter recovery key"
- tapOn: - tapOn:
id: "verification-recovery_key" id: "verification-recovery_key"
- inputText: ${MAESTRO_RECOVERY_KEY} - inputText: ${MAESTRO_RECOVERY_KEY}
- hideKeyboard - hideKeyboard
- tapOn: "Confirm" - tapOn: "Continue"
- runFlow: ../assertions/assertHomeDisplayed.yaml - extendedWaitUntil:
visible: "Device verified"
timeout: 10000
- tapOn: "Continue"

View file

@ -0,0 +1,5 @@
appId: ${MAESTRO_APP_ID}
---
- extendedWaitUntil:
visible: "Confirm that it's you"
timeout: 20000

View file

@ -7,8 +7,4 @@ appId: ${MAESTRO_APP_ID}
- tapOn: ${MAESTRO_ROOM_NAME} - tapOn: ${MAESTRO_ROOM_NAME}
# Back from timeline # Back from timeline
- back - back
- assertVisible: "MyR"
- hideKeyboard
# Back from search
- back
- runFlow: ../assertions/assertHomeDisplayed.yaml - runFlow: ../assertions/assertHomeDisplayed.yaml

View file

@ -1,3 +1,30 @@
Changes in Element X v0.4.8 (2024-04-10)
========================================
Features ✨
----------
- Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579))
- Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580))
- Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601))
- Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650))
Bugfixes 🐛
----------
- Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612))
- Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619))
- Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625))
- Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667))
Other changes
-------------
- Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581))
- Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593))
- Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608))
- Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634))
- Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678))
- Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684))
Changes in Element X v0.4.7 (2024-03-26) Changes in Element X v0.4.7 (2024-03-26)
======================================== ========================================

View file

@ -19,6 +19,7 @@ package io.element.android.x
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -31,7 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
@ -60,7 +60,7 @@ class MainActivity : NodeActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appBindings = bindings() appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this) appBindings.lockScreenService().handleSecureFlag(this)
WindowCompat.setDecorFitsSystemWindows(window, false) enableEdgeToEdge()
setContent { setContent {
MainContent(appBindings) MainContent(appBindings)
} }

View file

@ -14,16 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("java-library") id("io.element.android-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
java { android {
sourceCompatibility = JavaVersion.VERSION_17 namespace = "io.element.android.appconfig"
targetCompatibility = JavaVersion.VERSION_17
} }
anvil { anvil {
@ -33,4 +30,5 @@ anvil {
dependencies { dependencies {
implementation(libs.dagger) implementation(libs.dagger)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
} }

View file

@ -16,6 +16,29 @@
package io.element.android.appconfig package io.element.android.appconfig
import io.element.android.libraries.matrix.api.room.StateEventType
object TimelineConfig { object TimelineConfig {
const val MAX_READ_RECEIPT_TO_DISPLAY = 3 const val MAX_READ_RECEIPT_TO_DISPLAY = 3
/**
* Event types that will be filtered out from the timeline (i.e. not displayed).
*/
val excludedEvents = listOf(
StateEventType.CALL_MEMBER,
StateEventType.ROOM_ALIASES,
StateEventType.ROOM_CANONICAL_ALIAS,
StateEventType.ROOM_GUEST_ACCESS,
StateEventType.ROOM_HISTORY_VISIBILITY,
StateEventType.ROOM_JOIN_RULES,
StateEventType.ROOM_PINNED_EVENTS,
StateEventType.ROOM_POWER_LEVELS,
StateEventType.ROOM_SERVER_ACL,
StateEventType.ROOM_TOMBSTONE,
StateEventType.SPACE_CHILD,
StateEventType.SPACE_PARENT,
StateEventType.POLICY_RULE_ROOM,
StateEventType.POLICY_RULE_SERVER,
StateEventType.POLICY_RULE_USER,
)
} }

View file

@ -65,6 +65,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.login.impl) testImplementation(projects.features.login.impl)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -19,8 +19,6 @@ package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -34,16 +32,12 @@ import javax.inject.Inject
class LoggedInEventProcessor @Inject constructor( class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver, roomMembershipObserver: RoomMembershipObserver,
sessionVerificationService: SessionVerificationService,
) { ) {
private var observingJob: Job? = null private var observingJob: Job? = null
private val displayLeftRoomMessage = roomMembershipObserver.updates private val displayLeftRoomMessage = roomMembershipObserver.updates
.map { !it.isUserInRoom } .map { !it.isUserInRoom }
private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
.map { it == VerificationFlowState.Finished }
fun observeEvents(coroutineScope: CoroutineScope) { fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = coroutineScope.launch { observingJob = coroutineScope.launch {
displayLeftRoomMessage displayLeftRoomMessage
@ -52,13 +46,6 @@ class LoggedInEventProcessor @Inject constructor(
displayMessage(CommonStrings.common_current_user_left_room) displayMessage(CommonStrings.common_current_user_left_room)
} }
.launchIn(this) .launchIn(this)
displayVerificationSuccessfulMessage
.filter { it }
.onEach {
displayMessage(CommonStrings.common_verification_complete)
}
.launchIn(this)
} }
} }

View file

@ -45,6 +45,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
@ -53,15 +54,16 @@ import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -74,6 +76,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -87,21 +91,21 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint, private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint, private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState, private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService, private val lockScreenStateService: LockScreenService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.RoomList, initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
permanentNavModel = PermanentNavModel( permanentNavModel = PermanentNavModel(
@ -119,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher, snackbarDispatcher,
matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
matrixClient.sessionVerificationService(),
) )
override fun onBuilt() { override fun onBuilt() {
@ -131,9 +134,15 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) { ftueService.state
backstack.push(NavTarget.Ftue) .onEach { ftueState ->
} when (ftueState) {
is FtueState.Unknown -> Unit // Nothing to do
is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue)
is FtueState.Complete -> backstack.safeRoot(NavTarget.RoomList)
}
}
.launchIn(lifecycleScope)
}, },
onStop = { onStop = {
coroutineScope.launch { coroutineScope.launch {
@ -189,6 +198,9 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget
@Parcelize @Parcelize
data object LoggedInPermanent : NavTarget data object LoggedInPermanent : NavTarget
@ -212,9 +224,6 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object CreateRoom : NavTarget data object CreateRoom : NavTarget
@Parcelize
data object VerifySession : NavTarget
@Parcelize @Parcelize
data class SecureBackup( data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@ -225,10 +234,14 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object Ftue : NavTarget data object Ftue : NavTarget
@Parcelize
data object RoomDirectorySearch : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
NavTarget.LoggedInPermanent -> { NavTarget.LoggedInPermanent -> {
createNode<LoggedInNode>(buildContext) createNode<LoggedInNode>(buildContext)
} }
@ -251,10 +264,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.CreateRoom) backstack.push(NavTarget.CreateRoom)
} }
override fun onSessionVerificationClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSessionConfirmRecoveryKeyClicked() { override fun onSessionConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
} }
@ -270,6 +279,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onReportBugClicked() { override fun onReportBugClicked() {
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onRoomDirectorySearchClicked() {
backstack.push(NavTarget.RoomDirectorySearch)
}
} }
roomListEntryPoint roomListEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
@ -299,10 +312,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenBugReport() } plugins<Callback>().forEach { it.onOpenBugReport() }
} }
override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession)
}
override fun onSecureBackupClicked() { override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup()) backstack.push(NavTarget.SecureBackup())
} }
@ -329,25 +338,6 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.VerifySession -> {
val callback = object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() {
backstack.replace(
NavTarget.SecureBackup(
initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey
)
)
}
override fun onDone() {
backstack.pop()
}
}
verifySessionEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
is NavTarget.SecureBackup -> { is NavTarget.SecureBackup -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext) secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement)) .params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
@ -372,7 +362,16 @@ class LoggedInFlowNode @AssistedInject constructor(
ftueEntryPoint.nodeBuilder(this, buildContext) ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback { .callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() { override fun onFtueFlowFinished() {
backstack.pop() lifecycleScope.launch { attachRoomList() }
}
})
.build()
}
NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) }
} }
}) })
.build() .build()
@ -380,20 +379,23 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
suspend fun attachRoot(): Node { suspend fun attachRoomList() {
return attachChild { if (!canShowRoomList()) return
attachChild<Node> {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
} }
} }
suspend fun attachRoom(roomId: RoomId): RoomFlowNode { suspend fun attachRoom(roomId: RoomId) {
return attachChild { if (!canShowRoomList()) return
attachChild<RoomFlowNode> {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId))
} }
} }
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) { internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
if (!canShowRoomList()) return@withContext
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId) notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.InviteList) backstack.push(NavTarget.InviteList)
@ -402,13 +404,17 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
private fun canShowRoomList(): Boolean {
return ftueService.state.value is FtueState.Complete
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState() val lockScreenState by lockScreenStateService.lockState.collectAsState()
val isFtueDisplayed by ftueService.state.collectAsState()
BackstackView() BackstackView()
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() if (isFtueDisplayed is FtueState.Complete) {
if (!isFtueDisplayed) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
} }
if (lockScreenState == LockScreenLockState.Locked) { if (lockScreenState == LockScreenLockState.Locked) {
@ -416,4 +422,10 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
} }
@ContributesNode(AppScope::class)
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
} }

View file

@ -288,7 +288,7 @@ class RootFlowNode @AssistedInject constructor(
.attachSession() .attachSession()
.apply { .apply {
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> attachRoot() is DeeplinkData.Root -> attachRoomList()
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId) is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
is DeeplinkData.InviteList -> attachInviteList(deeplinkData) is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} }

View file

@ -27,22 +27,32 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
class LoggedInPresenter @Inject constructor( class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val pushService: PushService, private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
LaunchedEffect(Unit) { val isVerified by remember {
// Ensure pusher is registered sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
// TODO Manually select push provider for now }.collectAsState(initial = false)
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect LaunchedEffect(isVerified) {
pushService.registerWith(matrixClient, pushProvider, distributor) if (isVerified) {
// Ensure pusher is registered
// TODO Manually select push provider for now
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
}
} }
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState() val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()

View file

@ -40,6 +40,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -61,6 +62,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver, roomMembershipObserver: RoomMembershipObserver,
) : BaseFlowNode<RoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<RoomLoadedFlowNode.NavTarget>(
@ -92,6 +94,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
Timber.v("OnCreate => ${inputs.room.roomId}") Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers() fetchRoomMembers()
trackVisitedRoom()
}, },
onResume = { onResume = {
appCoroutineScope.launch { appCoroutineScope.launch {
@ -117,6 +120,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
inputs<Inputs>() inputs<Inputs>()
} }
private fun trackVisitedRoom() = lifecycleScope.launch {
matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
}
private fun fetchRoomMembers() = lifecycleScope.launch { private fun fetchRoomMembers() = lifecycleScope.launch {
inputs.room.updateMembers() inputs.room.updateMembers()
} }

View file

@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -101,6 +102,7 @@ class RoomFlowNodeTest {
roomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope, appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
matrixClient = FakeMatrixClient(),
) )
@Test @Test

View file

@ -22,13 +22,11 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -73,20 +71,8 @@ class LoggedInPresenterTest {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService), matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus), networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService { pushService = FakePushService(),
override fun notificationStyleChanged() { sessionVerificationService = FakeSessionVerificationService(),
}
override fun getAvailablePushProviders(): List<PushProvider> {
return emptyList()
}
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
}
override suspend fun testPush() {
}
}
) )
} }
} }

View file

@ -0,0 +1,2 @@
Main changes in this version: Enable room moderation feature.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -17,19 +17,14 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Poll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -45,18 +40,18 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -82,6 +77,7 @@ fun AnalyticsOptInView(
.fillMaxSize() .fillMaxSize()
.systemBarsPadding() .systemBarsPadding()
.imePadding(), .imePadding(),
background = { OnboardingBackground() },
header = { AnalyticsOptInHeader(state, onClickTerms) }, header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() }, content = { AnalyticsOptInContent() },
footer = { footer = {
@ -103,11 +99,11 @@ private fun AnalyticsOptInHeader(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
IconTitleSubtitleMolecule( PageTitle(
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconImageVector = Icons.Filled.Poll iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
) )
val text = buildAnnotatedStringWithStyledPart( val text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms, R.string.screen_analytics_prompt_read_terms,
@ -136,19 +132,6 @@ private fun AnalyticsOptInHeader(
} }
} }
@Composable
private fun CheckIcon() {
Icon(
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = CompoundIcons.Check(),
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
@Composable @Composable
private fun AnalyticsOptInContent() { private fun AnalyticsOptInContent() {
Box( Box(
@ -162,20 +145,20 @@ private fun AnalyticsOptInContent() {
items = persistentListOf( items = persistentListOf(
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage), message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
InfoListItem( InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_settings), message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconComposable = { CheckIcon() }, iconVector = CompoundIcons.CheckCircle(),
), ),
), ),
textStyle = ElementTheme.typography.fontBodyMdMedium, textStyle = ElementTheme.typography.fontBodyLgMedium,
iconTint = ElementTheme.colors.textPrimary, iconTint = ElementTheme.colors.iconSuccessPrimary,
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
) )
} }
} }

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"Folyamatban lévő hívás"</string> <string name="call_foreground_service_channel_title_android">"Folyamatban lévő hívás"</string>
<string name="call_foreground_service_message_android">"Koppints a híváshoz való visszatéréshez"</string> <string name="call_foreground_service_message_android">"Koppintson a híváshoz való visszatéréshez"</string>
<string name="call_foreground_service_title_android">"☎️ Hívás folyamatban"</string> <string name="call_foreground_service_title_android">"☎️ Hívás folyamatban"</string>
</resources> </resources>

View file

@ -69,6 +69,8 @@ dependencies {
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
override val values: Sequence<UserListState> override val values: Sequence<UserListState>
get() = sequenceOf( get() = sequenceOf(
aUserListState(), aUserListState(),
aUserListState().copy( aUserListState(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false, isSearchActive = false,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
), ),
aUserListState().copy( aUserListState(
searchResults = SearchBarResultState.Results( searchResults = SearchBarResultState.Results(
aMatrixUserList() aMatrixUserList()
.mapIndexed { index, matrixUser -> .mapIndexed { index, matrixUser ->
@ -46,6 +47,9 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true, isSearchActive = true,
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
) ),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
) )
} }

View file

@ -16,10 +16,8 @@
package io.element.android.features.createroom.impl.addpeople package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -64,21 +62,16 @@ fun AddPeopleView(
) )
} }
) { padding -> ) { padding ->
Column( UserListView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
) { state = state,
UserListView( showBackButton = false,
modifier = Modifier onUserSelected = {},
.fillMaxWidth(), onUserDeselected = {},
state = state, )
showBackButton = false,
onUserSelected = { },
onUserDeselected = {},
)
}
} }
} }

View file

@ -19,17 +19,27 @@ package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.userlist.UserListEvents import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun UserListView( fun UserListView(
@ -74,6 +84,43 @@ fun UserListView(
}, },
) )
} }
if (!state.isSearchActive && state.recentDirectRooms.isNotEmpty()) {
LazyColumn {
item {
ListSectionHeader(
title = stringResource(id = CommonStrings.common_suggestions),
hasDivider = false,
)
}
state.recentDirectRooms.forEachIndexed { index, recentDirectRoom ->
item {
val isSelected = state.selectedUsers.any {
recentDirectRoom.matrixUser.userId == it.userId
}
CheckableUserRow(
checked = isSelected,
onCheckedChange = {
if (isSelected) {
state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser))
onUserDeselected(recentDirectRoom.matrixUser)
} else {
state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser))
onUserSelected(recentDirectRoom.matrixUser)
}
},
data = CheckableUserRowData.Resolved(
avatarData = recentDirectRoom.matrixUser.getAvatarData(AvatarSize.UserListItem),
name = recentDirectRoom.matrixUser.getBestName(),
subtext = recentDirectRoom.matrixUser.userId.value,
),
)
if (index < state.recentDirectRooms.lastIndex) {
HorizontalDivider()
}
}
}
}
}
} }
} }

View file

@ -17,9 +17,12 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -28,7 +31,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
override val values: Sequence<CreateRoomRootState> override val values: Sequence<CreateRoomRootState>
get() = sequenceOf( get() = sequenceOf(
aCreateRoomRootState(), aCreateRoomRootState(),
aCreateRoomRootState().copy( aCreateRoomRootState(
startDmAction = AsyncAction.Loading, startDmAction = AsyncAction.Loading,
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
) )
} }
), ),
aCreateRoomRootState().copy( aCreateRoomRootState(
startDmAction = AsyncAction.Failure(Throwable("error")), startDmAction = AsyncAction.Failure(Throwable("error")),
userListState = aMatrixUser().let { userListState = aMatrixUser().let {
aUserListState().copy( aUserListState().copy(
@ -50,12 +53,22 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
) )
} }
), ),
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = aRecentDirectRoomList()
)
),
) )
} }
fun aCreateRoomRootState() = CreateRoomRootState( fun aCreateRoomRootState(
eventSink = {}, applicationName: String = "Element X Preview",
applicationName = "Element X Preview", userListState: UserListState = aUserListState(),
startDmAction = AsyncAction.Uninitialized, startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
userListState = aUserListState(), eventSink: (CreateRoomRootEvents) -> Unit = {},
) = CreateRoomRootState(
applicationName = applicationName,
userListState = userListState,
startDmAction = startDmAction,
eventSink = eventSink,
) )

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -46,11 +47,14 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
fun CreateRoomRootView( fun CreateRoomRootView(
@ -77,7 +81,11 @@ fun CreateRoomRootView(
) { ) {
UserListView( UserListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
state = state.userListState, // Do not render suggestions in this case, the suggestion will be rendered
// by CreateRoomActionButtonsList
state = state.userListState.copy(
recentDirectRooms = persistentListOf(),
),
onUserSelected = { onUserSelected = {
state.eventSink(CreateRoomRootEvents.StartDM(it)) state.eventSink(CreateRoomRootEvents.StartDM(it))
}, },
@ -89,6 +97,7 @@ fun CreateRoomRootView(
state = state, state = state,
onNewRoomClicked = onNewRoomClicked, onNewRoomClicked = onNewRoomClicked,
onInvitePeopleClicked = onInviteFriendsClicked, onInvitePeopleClicked = onInviteFriendsClicked,
onDmClicked = onOpenDM,
) )
} }
} }
@ -106,7 +115,7 @@ fun CreateRoomRootView(
onRetry = { onRetry = {
state.userListState.selectedUsers.firstOrNull() state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) } ?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen) // Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM) ?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
}, },
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
@ -139,18 +148,43 @@ private fun CreateRoomActionButtonsList(
state: CreateRoomRootState, state: CreateRoomRootState,
onNewRoomClicked: () -> Unit, onNewRoomClicked: () -> Unit,
onInvitePeopleClicked: () -> Unit, onInvitePeopleClicked: () -> Unit,
onDmClicked: (RoomId) -> Unit,
) { ) {
Column { LazyColumn {
CreateRoomActionButton( item {
iconRes = CompoundDrawables.ic_compound_plus, CreateRoomActionButton(
text = stringResource(id = R.string.screen_create_room_action_create_room), iconRes = CompoundDrawables.ic_compound_plus,
onClick = onNewRoomClicked, text = stringResource(id = R.string.screen_create_room_action_create_room),
) onClick = onNewRoomClicked,
CreateRoomActionButton( )
iconRes = CompoundDrawables.ic_compound_share_android, }
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), item {
onClick = onInvitePeopleClicked, CreateRoomActionButton(
) iconRes = CompoundDrawables.ic_compound_share_android,
text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName),
onClick = onInvitePeopleClicked,
)
}
if (state.userListState.recentDirectRooms.isNotEmpty()) {
item {
ListSectionHeader(
title = stringResource(id = CommonStrings.common_suggestions),
hasDivider = false,
)
}
state.userListState.recentDirectRooms.forEach { recentDirectRoom ->
item {
MatrixUserRow(
modifier = Modifier.clickable(
onClick = {
onDmClicked(recentDirectRoom.roomId)
}
),
matrixUser = recentDirectRoom.matrixUser,
)
}
}
}
} }
} }

View file

@ -30,6 +30,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -41,6 +44,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs, @Assisted val args: UserListPresenterArgs,
@Assisted val userRepository: UserRepository, @Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore, @Assisted val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
) : UserListPresenter { ) : UserListPresenter {
@AssistedFactory @AssistedFactory
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@ -54,6 +58,10 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): UserListState { override fun present(): UserListState {
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
LaunchedEffect(Unit) {
recentDirectRooms = matrixClient.getRecentDirectRooms()
}
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
@ -82,6 +90,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader, showSearchLoader = showSearchLoader,
selectionMode = args.selectionMode, selectionMode = args.selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = { event -> eventSink = { event ->
when (event) { when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active

View file

@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.userlist package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -28,6 +29,7 @@ data class UserListState(
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, val selectionMode: SelectionMode,
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
val eventSink: (UserListEvents) -> Unit, val eventSink: (UserListEvents) -> Unit,
) { ) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple

View file

@ -18,54 +18,82 @@ package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.recent.RecentDirectRoom
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class UserListStateProvider : PreviewParameterProvider<UserListState> { open class UserListStateProvider : PreviewParameterProvider<UserListState> {
override val values: Sequence<UserListState> override val values: Sequence<UserListState>
get() = sequenceOf( get() = sequenceOf(
aUserListState(), aUserListState(),
aUserListState().copy( aUserListState(
isSearchActive = false, isSearchActive = false,
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
), ),
aUserListState().copy(isSearchActive = true), aUserListState(isSearchActive = true),
aUserListState().copy(isSearchActive = true, searchQuery = "someone"), aUserListState(isSearchActive = true, searchQuery = "someone"),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple), aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
), ),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
selectedUsers = aMatrixUserList().toImmutableList(), selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()), searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
), ),
aUserListState().copy( aUserListState(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound() searchResults = SearchBarResultState.NoResultsFound()
), ),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Single), aUserListState(
isSearchActive = true,
searchQuery = "someone",
selectionMode = SelectionMode.Single,
),
aUserListState(
recentDirectRooms = aRecentDirectRoomList(),
),
) )
} }
fun aUserListState() = UserListState( fun aUserListState(
isSearchActive = false, searchQuery: String = "",
searchQuery = "", isSearchActive: Boolean = false,
searchResults = SearchBarResultState.Initial(), searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
selectedUsers = persistentListOf(), selectedUsers: List<MatrixUser> = emptyList(),
selectionMode = SelectionMode.Single, showSearchLoader: Boolean = false,
showSearchLoader = false, selectionMode: SelectionMode = SelectionMode.Single,
eventSink = {} recentDirectRooms: List<RecentDirectRoom> = emptyList(),
eventSink: (UserListEvents) -> Unit = {},
) = UserListState(
searchQuery = searchQuery,
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
showSearchLoader = showSearchLoader,
selectionMode = selectionMode,
recentDirectRooms = recentDirectRooms.toImmutableList(),
eventSink = eventSink
) )
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList() fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList() fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()
fun aRecentDirectRoomList(
count: Int = 5
): List<RecentDirectRoom> = aMatrixUserList()
.take(count)
.map {
RecentDirectRoom(RoomId("!aRoom:id"), it)
}

View file

@ -7,7 +7,7 @@
<string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</string> <string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</string>
<string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string> <string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string>
<string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string> <string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string>
<string name="screen_create_room_public_option_title">"Адкрыты пакой (для ўсіх)"</string> <string name="screen_create_room_public_option_title">"Публічны пакой (для ўсіх)"</string>
<string name="screen_create_room_room_name_label">"Назва пакоя"</string> <string name="screen_create_room_room_name_label">"Назва пакоя"</string>
<string name="screen_create_room_title">"Стварыце пакой"</string> <string name="screen_create_room_title">"Стварыце пакой"</string>
<string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string> <string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string>

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.addpeople
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AddPeopleViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.pressBack()
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
@Test
fun `clicking on back during search emits the expected Event`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
rule.setAddPeopleView(
aUserListState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(UserListEvents.OnSearchActiveChanged(false))
}
@Test
fun `clicking on skip invokes the expected callback`() {
val eventsRecorder = EventsRecorder<UserListEvents>()
ensureCalledOnce {
rule.setAddPeopleView(
aUserListState(
eventSink = eventsRecorder,
),
onNextPressed = it
)
rule.clickOn(CommonStrings.action_skip)
}
eventsRecorder.assertSingle(UserListEvents.UpdateSearchQuery(""))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAddPeopleView(
state: UserListState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onNextPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AddPeopleView(
state = state,
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
)
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.userlist.aRecentDirectRoomList
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class CreateRoomRootViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onClosePressed = it
)
rule.pressBack()
}
}
@Test
fun `clicking on New room invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
eventSink = eventsRecorder,
),
onNewRoomClicked = it
)
rule.clickOn(R.string.screen_create_room_action_create_room)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on Invite people invokes the expected callback`() {
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnce {
rule.setCreateRoomRootView(
aCreateRoomRootState(
applicationName = "test",
eventSink = eventsRecorder,
),
onInviteFriendsClicked = it
)
val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test")
rule.onNodeWithText(text).performClick()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on a user suggestion invokes the expected callback`() {
val recentDirectRoomList = aRecentDirectRoomList()
val firstRoom = recentDirectRoomList[0]
val eventsRecorder = EventsRecorder<CreateRoomRootEvents>(expectEvents = false)
ensureCalledOnceWithParam(firstRoom.roomId) {
rule.setCreateRoomRootView(
aCreateRoomRootState(
userListState = aUserListState(
recentDirectRooms = recentDirectRoomList
),
eventSink = eventsRecorder,
),
onOpenDM = it
)
rule.onNodeWithText(firstRoom.matrixUser.getBestName()).performClick()
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCreateRoomRootView(
state: CreateRoomRootState,
onClosePressed: () -> Unit = EnsureNeverCalled(),
onNewRoomClicked: () -> Unit = EnsureNeverCalled(),
onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onInviteFriendsClicked: () -> Unit = EnsureNeverCalled(),
) {
setContent {
CreateRoomRootView(
state = state,
onClosePressed = onClosePressed,
onNewRoomClicked = onNewRoomClicked,
onOpenDM = onOpenDM,
onInviteFriendsClicked = onInviteFriendsClicked,
)
}
}

View file

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
@ -45,6 +46,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -66,6 +68,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Multiple), UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -87,6 +90,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -123,6 +127,7 @@ class DefaultUserListPresenterTests {
), ),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -175,6 +180,7 @@ class DefaultUserListPresenterTests {
), ),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -200,6 +206,7 @@ class DefaultUserListPresenterTests {
UserListPresenterArgs(selectionMode = SelectionMode.Single), UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository, userRepository,
UserListDataStore(), UserListDataStore(),
FakeMatrixClient(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.api.state
import kotlinx.coroutines.flow.StateFlow
/**
* Service to manage the First Time User Experience state (aka Onboarding).
*/
interface FtueService {
/** The current state of the FTUE. */
val state: StateFlow<FtueState>
/** Reset the FTUE state. */
suspend fun reset()
}
/** The state of the FTUE. */
sealed interface FtueState {
/** The FTUE state is unknown, nothing to do for now. */
data object Unknown : FtueState
/** The FTUE state is incomplete. The FTUE flow should be displayed. */
data object Incomplete : FtueState
/** The FTUE state is complete. The FTUE flow should not be displayed anymore. */
data object Complete : FtueState
}

View file

@ -42,6 +42,8 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.securebackup.api)
implementation(projects.features.verifysession.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)

View file

@ -34,7 +34,8 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
@ -55,7 +56,7 @@ import kotlinx.parcelize.Parcelize
class FtueFlowNode @AssistedInject constructor( class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueState, private val ftueState: DefaultFtueService,
private val analyticsEntryPoint: AnalyticsEntryPoint, private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
@ -72,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object Placeholder : NavTarget data object Placeholder : NavTarget
@Parcelize
data object SessionVerification : NavTarget
@Parcelize @Parcelize
data object NotificationsOptIn : NavTarget data object NotificationsOptIn : NavTarget
@ -106,6 +110,14 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) createNode<PlaceholderNode>(buildContext)
} }
NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
@ -133,6 +145,9 @@ class FtueFlowNode @AssistedInject constructor(
private fun moveToNextStep() { private fun moveToNextStep() {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification)
}
FtueStep.NotificationsOptIn -> { FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn) backstack.newRoot(NavTarget.NotificationsOptIn)
} }

View file

@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -40,8 +40,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -62,8 +64,9 @@ fun NotificationsOptInView(
HeaderFooterPage( HeaderFooterPage(
modifier = modifier modifier = modifier
.systemBarsPadding() .statusBarsPadding()
.fillMaxSize(), .fillMaxSize(),
background = { OnboardingBackground() },
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) }, header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
footer = { NotificationsOptInFooter(state) }, footer = { NotificationsOptInFooter(state) },
) { ) {
@ -75,11 +78,11 @@ fun NotificationsOptInView(
private fun NotificationsOptInHeader( private fun NotificationsOptInHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
IconTitleSubtitleMolecule( PageTitle(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title), title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle), subtitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = CompoundIcons.NotificationsSolid(), iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
) )
} }

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.sessionverification
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
) : BaseFlowNode<FtueSessionVerificationFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
}
interface Callback : Plugin {
fun onDone()
}
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() {
lifecycleScope.launch {
// Move to the completed state view in the verification flow
backstack.newRoot(NavTarget.Root)
}
}
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Root -> {
verifySessionEntryPoint.nodeBuilder(this, buildContext)
.callback(object : VerifySessionEntryPoint.Callback {
override fun onEnterRecoveryKey() {
backstack.push(NavTarget.EnterRecoveryKey)
}
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() {
plugins<Callback>().forEach { it.onDone() }
}
})
.build()
}
is NavTarget.EnterRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build()
}
is NavTarget.CreateNewRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build()
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -20,9 +20,11 @@ import android.Manifest
import android.os.Build import android.os.Build
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ -35,14 +37,15 @@ import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor( class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
) : FtueState { private val sessionVerificationService: SessionVerificationService,
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
override suspend fun reset() { override suspend fun reset() {
analyticsService.reset() analyticsService.reset()
@ -52,6 +55,10 @@ class DefaultFtueState @Inject constructor(
} }
init { init {
sessionVerificationService.needsVerificationFlow
.onEach { updateState() }
.launchIn(coroutineScope)
analyticsService.didAskUserConsent() analyticsService.didAskUserConsent()
.onEach { updateState() } .onEach { updateState() }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@ -59,7 +66,12 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? = fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (shouldAskNotificationPermissions()) { null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification
} else {
getNextStep(FtueStep.SessionVerification)
}
FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) {
FtueStep.NotificationsOptIn FtueStep.NotificationsOptIn
} else { } else {
getNextStep(FtueStep.NotificationsOptIn) getNextStep(FtueStep.NotificationsOptIn)
@ -79,12 +91,17 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean { private fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf(
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() }, { shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() }, { needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() }, { shouldDisplayLockscreenSetup() },
).any { it() } ).any { it() }
} }
private fun isSessionNotVerified(): Boolean {
return sessionVerificationService.needsVerificationFlow.value
}
private fun needsAnalyticsOptIn(): Boolean { private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking // We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() } return runBlocking { analyticsService.didAskUserConsent().first().not() }
@ -109,11 +126,15 @@ class DefaultFtueState @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() { internal fun updateState() {
shouldDisplayFlow.value = isAnyStepIncomplete() state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete
}
} }
} }
sealed interface FtueStep { sealed interface FtueStep {
data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep data object AnalyticsOptIn : FtueStep
data object LockscreenSetup : FtueStep data object LockscreenSetup : FtueStep

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string> <string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string> <string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце Element на настольнай прыладзе"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Паказаць QR-код”"</string>
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце Element на іншай прыладзе, каб атрымаць QR-код"</string>
<string name="screen_welcome_bullet_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string> <string name="screen_welcome_bullet_1">"Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе."</string>
<string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string> <string name="screen_welcome_bullet_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string>
<string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string> <string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string>

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string> <string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</string>
<string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string> <string name="screen_notification_optin_title">"Разрешите уведомления и никогда не пропустите сообщение"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте Element на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"\"Показать QR-код\""</string>
<string name="screen_qr_code_login_initial_state_title">"Откройте Element на другом устройстве, чтобы получить QR-код"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string> <string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string> <string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -2,6 +2,13 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string> <string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string> <string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open Element on a desktop device"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Show QR code”"</string>
<string name="screen_qr_code_login_initial_state_title">"Open Element on another device to get the QR code"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string> <string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -17,11 +17,15 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Build import android.os.Build
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.state.DefaultFtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.state.DefaultFtueService
import io.element.android.features.ftue.impl.state.FtueStep import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -32,38 +36,51 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultFtueStateTests { class DefaultFtueServiceTests {
@Test @Test
fun `given any check being false, should display flow is true`() = runTest { fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
}
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope) val state = createState(coroutineScope, sessionVerificationService)
assertThat(state.shouldDisplayFlow.value).isTrue() state.state.test {
// Verification state is unknown, we don't display the flow yet
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
// Verification state is known, we should display the flow if any check is false
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
}
// Cleanup // Cleanup
coroutineScope.cancel() coroutineScope.cancel()
} }
@Test @Test
fun `given all checks being true, should display flow is false`() = runTest { fun `given all checks being true, FtueState is Complete`() = runTest {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val sessionVerificationService = FakeSessionVerificationService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
state.updateState() state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse() assertThat(state.state.value).isEqualTo(FtueState.Complete)
// Cleanup // Cleanup
coroutineScope.cancel() coroutineScope.cancel()
@ -71,6 +88,10 @@ class DefaultFtueStateTests {
@Test @Test
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
}
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
@ -78,12 +99,17 @@ class DefaultFtueStateTests {
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>() val steps = mutableListOf<FtueStep?>()
// Session verification
steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenNeedsVerification(false)
// Notifications opt in // Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
@ -100,6 +126,7 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly( assertThat(steps).containsExactly(
FtueStep.SessionVerification,
FtueStep.NotificationsOptIn, FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup, FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn, FtueStep.AnalyticsOptIn,
@ -114,17 +141,20 @@ class DefaultFtueStateTests {
@Test @Test
fun `if a check for a step is true, start from the next one`() = runTest { fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
// Skip first 2 steps // Skip first 3 steps
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -140,16 +170,19 @@ class DefaultFtueStateTests {
@Test @Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest { fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val sessionVerificationService = FakeSessionVerificationService()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val lockScreenService = FakeLockScreenService() val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
sdkIntVersion = Build.VERSION_CODES.M, sdkIntVersion = Build.VERSION_CODES.M,
sessionVerificationService = sessionVerificationService,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -163,14 +196,16 @@ class DefaultFtueStateTests {
private fun createState( private fun createState(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(), lockScreenService: LockScreenService = FakeLockScreenService(),
// First version where notification permission is required // First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueState( ) = DefaultFtueService(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
sessionVerificationService = sessionVerificationService,
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Вы ўпэўненыя, што жадаеце адхіліць запрашэнне ў %1$s?"</string> <string name="screen_invites_decline_chat_message">"Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Адхіліць запрашэнне"</string> <string name="screen_invites_decline_chat_title">"Адхіліць запрашэнне"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што жадаеце адмовіцца ад прыватных зносін з %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Адхіліць чат"</string> <string name="screen_invites_decline_direct_chat_title">"Адхіліць чат"</string>
<string name="screen_invites_empty_list">"Няма запрашэнняў"</string> <string name="screen_invites_empty_list">"Няма запрашэнняў"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў вас"</string> <string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў вас"</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string> <string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string>
<string name="leave_room_alert_empty_subtitle">"Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."</string> <string name="leave_room_alert_empty_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."</string>
<string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string> <string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што жхочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string>
<string name="leave_room_alert_subtitle">"Вы ўпэўнены, што жадаеце пакінуць пакой?"</string> <string name="leave_room_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць пакой?"</string>
</resources> </resources>

View file

@ -2,6 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Biztos, hogy elhagyja ezt a beszélgetést? Ez a beszélgetés nem nyilvános, és meghívás nélkül nem fog tudni visszacsatlakozni."</string> <string name="leave_conversation_alert_subtitle">"Biztos, hogy elhagyja ezt a beszélgetést? Ez a beszélgetés nem nyilvános, és meghívás nélkül nem fog tudni visszacsatlakozni."</string>
<string name="leave_room_alert_empty_subtitle">"Biztos, hogy elhagyja ezt a szobát? Ön az egyedüli ember itt. Ha kilép, akkor senki sem fog tudni csatlakozni a jövőben, Önt is beleértve."</string> <string name="leave_room_alert_empty_subtitle">"Biztos, hogy elhagyja ezt a szobát? Ön az egyedüli ember itt. Ha kilép, akkor senki sem fog tudni csatlakozni a jövőben, Önt is beleértve."</string>
<string name="leave_room_alert_private_subtitle">"Biztos, hogy elhagyod ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fogsz tudni újra belépni."</string> <string name="leave_room_alert_private_subtitle">"Biztos, hogy elhagyja ezt a szobát? Ez a szoba nem nyilvános, és meghívó nélkül nem fog tudni újra belépni."</string>
<string name="leave_room_alert_subtitle">"Biztos, hogy elhagyod a szobát?"</string> <string name="leave_room_alert_subtitle">"Biztos, hogy elhagyja a szobát?"</string>
</resources> </resources>

View file

@ -7,7 +7,7 @@
<string name="screen_app_lock_settings_change_pin">"Змяніць PIN-код"</string> <string name="screen_app_lock_settings_change_pin">"Змяніць PIN-код"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Дазволіць біяметрычную разблакіроўку"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Дазволіць біяметрычную разблакіроўку"</string>
<string name="screen_app_lock_settings_remove_pin">"Выдаліць PIN-код"</string> <string name="screen_app_lock_settings_remove_pin">"Выдаліць PIN-код"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Вы ўпэўнены, што жадаеце выдаліць PIN-код?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Вы ўпэўнены, што хочаце выдаліць PIN-код?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Выдаліць PIN-код?"</string> <string name="screen_app_lock_settings_remove_pin_alert_title">"Выдаліць PIN-код?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Дазволіць %1$s"</string> <string name="screen_app_lock_setup_biometric_unlock_allow_title">"Дазволіць %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Я хацеў бы выкарыстоўваць PIN-код"</string> <string name="screen_app_lock_setup_biometric_unlock_skip">"Я хацеў бы выкарыстоўваць PIN-код"</string>
@ -25,12 +25,12 @@
<string name="screen_app_lock_signout_alert_title">"Вы выходзіце з сістэмы"</string> <string name="screen_app_lock_signout_alert_title">"Вы выходзіце з сістэмы"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"У вас %1$d спроба разблакіроўкі"</item> <item quantity="one">"У вас %1$d спроба разблакіроўкі"</item>
<item quantity="few">"У вас %1$d спроб разблакіроўкі"</item> <item quantity="few">"У вас %1$d спробы разблакіроўкі"</item>
<item quantity="many">"У вас %1$d спроб разблакіроўкі"</item> <item quantity="many">"У вас %1$d спроб разблакіроўкі"</item>
</plurals> </plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin"> <plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Няправільны PIN-код. У вас застаўся %1$d шанец"</item> <item quantity="one">"Няправільны PIN-код. У вас застаўся %1$d шанец"</item>
<item quantity="few">"Няправільны PIN-код. У вас застаўася %1$d шанцаў"</item> <item quantity="few">"Няправільны PIN-код. У вас застаўася %1$d шанца"</item>
<item quantity="many">"Няправільны PIN-код. У вас застаўася %1$d шанцаў"</item> <item quantity="many">"Няправільны PIN-код. У вас застаўася %1$d шанцаў"</item>
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Выкарыстоўваць біяметрыю"</string> <string name="screen_app_lock_use_biometric_android">"Выкарыстоўваць біяметрыю"</string>

View file

@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_settings_change_pin">"Byt PIN-kod"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Tillåt biometrisk upplåsning"</string>
<string name="screen_app_lock_settings_remove_pin">"Ta bort PIN-kod"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Ta bort PIN-koden?"</string>
<string name="screen_signout_in_progress_dialog_content">"Loggar ut …"</string> <string name="screen_signout_in_progress_dialog_content">"Loggar ut …"</string>
</resources> </resources>

View file

@ -8,7 +8,7 @@
<string name="screen_account_provider_signin_subtitle">"Itt lesznek a beszélgetései ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."</string> <string name="screen_account_provider_signin_subtitle">"Itt lesznek a beszélgetései ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."</string>
<string name="screen_account_provider_signin_title">"Hamarosan bejelentkezik ide: %s"</string> <string name="screen_account_provider_signin_title">"Hamarosan bejelentkezik ide: %s"</string>
<string name="screen_account_provider_signup_subtitle">"Itt lesznek a beszélgetései ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."</string> <string name="screen_account_provider_signup_subtitle">"Itt lesznek a beszélgetései ahogyan egy e-mail-szolgáltatást is használna a levelei kezeléséhez."</string>
<string name="screen_account_provider_signup_title">"Hamarosan létrehozol egy fiókot itt: %s"</string> <string name="screen_account_provider_signup_title">"Hamarosan létrehoz egy fiókot itt: %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"A Matrix.org egy nagy, ingyenes kiszolgáló a nyilvános Matrix-hálózaton, a biztonságos, decentralizált kommunikáció érdekében, amelyet a Matrix.org Alapítvány üzemeltet."</string> <string name="screen_change_account_provider_matrix_org_subtitle">"A Matrix.org egy nagy, ingyenes kiszolgáló a nyilvános Matrix-hálózaton, a biztonságos, decentralizált kommunikáció érdekében, amelyet a Matrix.org Alapítvány üzemeltet."</string>
<string name="screen_change_account_provider_other">"Egyéb"</string> <string name="screen_change_account_provider_other">"Egyéb"</string>
<string name="screen_change_account_provider_subtitle">"Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata."</string> <string name="screen_change_account_provider_subtitle">"Másik fiókszolgáltató, például a saját privát kiszolgáló vagy egy munkahelyi fiók használata."</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Вы ўпэўнены, што жадаеце выйсці?"</string> <string name="screen_signout_confirmation_dialog_content">"Вы ўпэўнены, што хочаце выйсці?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Выйсці"</string> <string name="screen_signout_confirmation_dialog_submit">"Выйсці"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйсці"</string> <string name="screen_signout_confirmation_dialog_title">"Выйсці"</string>
<string name="screen_signout_in_progress_dialog_content">"Выхад…"</string> <string name="screen_signout_in_progress_dialog_content">"Выхад…"</string>

View file

@ -16,9 +16,12 @@
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -31,11 +34,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
presenterFactory: MessagesPresenter.Factory, presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this) private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull() private val callback = plugins<Callback>().firstOrNull()
@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor(
private fun onUserDataClicked(userId: UserId) { private fun onUserDataClicked(userId: UserId) {
callback?.onUserDataClicked(userId) callback?.onUserDataClicked(userId)
} }
private fun onLinkClicked(
context: Context,
url: String,
) {
when (val permalink = permalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
callback?.onUserDataClicked(UserId(permalink.userId))
}
is PermalinkData.RoomLink -> {
// TODO Implement room link handling
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
context.openUrlInExternalApp(url)
}
}
}
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo) callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
} }
@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val context = LocalContext.current
CompositionLocalProvider( CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor(
onEventClicked = this::onEventClicked, onEventClicked = this::onEventClicked,
onPreviewAttachments = this::onPreviewAttachments, onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked, onUserDataClicked = this::onUserDataClicked,
onLinkClicked = { onLinkClicked(context, it) },
onSendLocationClicked = this::onSendLocationClicked, onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked, onCreatePollClicked = this::onCreatePollClicked,
onJoinCallClicked = this::onJoinCallClicked, onJoinCallClicked = this::onJoinCallClicked,

View file

@ -89,6 +89,7 @@ import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -273,6 +274,7 @@ class MessagesPresenter @AssistedInject constructor(
) = launch { ) = launch {
when (action) { when (action) {
TimelineItemAction.Copy -> handleCopyContents(targetEvent) TimelineItemAction.Copy -> handleCopyContents(targetEvent)
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
TimelineItemAction.Redact -> handleActionRedact(targetEvent) TimelineItemAction.Redact -> handleActionRedact(targetEvent)
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting) TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.Reply, TimelineItemAction.Reply,
@ -435,6 +437,20 @@ class MessagesPresenter @AssistedInject constructor(
event.eventId?.let { timelineState.eventSink(TimelineEvents.PollEndClicked(it)) } event.eventId?.let { timelineState.eventSink(TimelineEvents.PollEndClicked(it)) }
} }
private suspend fun handleCopyLink(event: TimelineItem.Event) {
event.eventId ?: return
room.getPermalinkFor(event.eventId).fold(
onSuccess = { permalink ->
clipboardHelper.copyPlainText(permalink)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
},
onFailure = {
Timber.e(it, "Failed to get permalink for event ${event.eventId}")
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
}
)
}
private suspend fun handleCopyContents(event: TimelineItem.Event) { private suspend fun handleCopyContents(event: TimelineItem.Event) {
val content = when (event.content) { val content = when (event.content) {
is TimelineItemTextBasedContent -> event.content.body is TimelineItemTextBasedContent -> event.content.body

View file

@ -119,6 +119,7 @@ fun MessagesView(
onRoomDetailsClicked: () -> Unit, onRoomDetailsClicked: () -> Unit,
onEventClicked: (event: TimelineItem.Event) -> Boolean, onEventClicked: (event: TimelineItem.Event) -> Boolean,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit, onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit, onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit, onCreatePollClicked: () -> Unit,
@ -213,6 +214,7 @@ fun MessagesView(
onMessageClicked = ::onMessageClicked, onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked, onMessageLongClicked = ::onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onTimestampClicked = { event -> onTimestampClicked = { event ->
if (event.localSendState is LocalEventSendState.SendingFailed) { if (event.localSendState is LocalEventSendState.SendingFailed) {
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
@ -313,6 +315,7 @@ private fun MessagesViewContent(
state: MessagesState, state: MessagesState,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onReactionClicked: (key: String, TimelineItem.Event) -> Unit, onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
onMoreReactionsClicked: (TimelineItem.Event) -> Unit, onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
@ -386,6 +389,7 @@ private fun MessagesViewContent(
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onMessageLongClicked = onMessageLongClicked, onMessageLongClicked = onMessageLongClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClicked = onReactionClicked, onReactionClicked = onReactionClicked,
onReactionLongClicked = onReactionLongClicked, onReactionLongClicked = onReactionLongClicked,
@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onEventClicked = { false }, onEventClicked = { false },
onPreviewAttachments = {}, onPreviewAttachments = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
onJoinCallClicked = {}, onJoinCallClicked = {},

View file

@ -96,6 +96,7 @@ class ActionListPresenter @Inject constructor(
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
buildList { buildList {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -119,6 +120,7 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
} }
add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -136,6 +138,7 @@ class ActionListPresenter @Inject constructor(
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
} }
add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -176,6 +179,7 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
} }
add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }

View file

@ -135,6 +135,7 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Redact, TimelineItemAction.Redact,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
@ -146,6 +147,7 @@ fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, TimelineItemAction.Redact,

View file

@ -31,6 +31,7 @@ sealed class TimelineItemAction(
) { ) {
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward) data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy) data object Copy : TimelineItemAction(CommonStrings.action_copy, CompoundDrawables.ic_compound_copy)
data object CopyLink : TimelineItemAction(CommonStrings.action_copy_link_to_message, CompoundDrawables.ic_compound_link)
data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true) data object Redact : TimelineItemAction(CommonStrings.action_remove, CompoundDrawables.ic_compound_delete, destructive = true)
data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply) data object Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply) data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)

View file

@ -49,6 +49,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor(
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder, private val currentSessionIdHolder: CurrentSessionIdHolder,
private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor(
} }
is MentionSuggestion.Member -> { is MentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value
val link = PermalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
} }
@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor(
return MessageComposerState( return MessageComposerState(
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
permalinkParser = permalinkParser,
isFullScreen = isFullScreen.value, isFullScreen = isFullScreen.value,
mode = messageComposerContext.composerMode, mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker, showAttachmentSourcePicker = showAttachmentSourcePicker,

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
@Stable @Stable
data class MessageComposerState( data class MessageComposerState(
val richTextEditorState: RichTextEditorState, val richTextEditorState: RichTextEditorState,
val permalinkParser: PermalinkParser,
val isFullScreen: Boolean, val isFullScreen: Boolean,
val mode: MessageComposerMode, val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean, val showAttachmentSourcePicker: Boolean,

View file

@ -16,9 +16,12 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestion
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -43,6 +46,10 @@ fun aMessageComposerState(
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(), memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
) = MessageComposerState( ) = MessageComposerState(
richTextEditorState = richTextEditorState, richTextEditorState = richTextEditorState,
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData = TODO()
override fun parse(uri: Uri): PermalinkData = TODO()
},
isFullScreen = isFullScreen, isFullScreen = isFullScreen,
mode = mode, mode = mode,
showTextFormatting = showTextFormatting, showTextFormatting = showTextFormatting,

View file

@ -109,6 +109,7 @@ internal fun MessageComposerView(
modifier = modifier, modifier = modifier,
state = state.richTextEditorState, state = state.richTextEditorState,
voiceMessageState = voiceMessageState.voiceMessageState, voiceMessageState = voiceMessageState.voiceMessageState,
permalinkParser = state.permalinkParser,
subcomposing = subcomposing, subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus, onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage, onSendMessage = ::sendMessage,

View file

@ -28,6 +28,7 @@ import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter import io.element.android.wysiwyg.compose.StyledHtmlConverter
@ -39,7 +40,9 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider { class DefaultHtmlConverterProvider @Inject constructor(
private val permalinkParser: PermalinkParser,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId) val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val context = LocalContext.current val context = LocalContext.current

View file

@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
@ -41,15 +40,11 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -68,8 +63,6 @@ class TimelinePresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope, private val appScope: CoroutineScope,
@Assisted private val navigator: MessagesNavigator, @Assisted private val navigator: MessagesNavigator,
private val verificationService: SessionVerificationService,
private val encryptionService: EncryptionService,
private val redactedVoiceMessageManager: RedactedVoiceMessageManager, private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
private val sendPollResponseAction: SendPollResponseAction, private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction, private val endPollAction: EndPollAction,
@ -101,21 +94,9 @@ class TimelinePresenter @AssistedInject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) } val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val newItemState = remember { mutableStateOf(NewEventState.None) } val newItemState = remember { mutableStateOf(NewEventState.None) }
val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true) val isSendPublicReadReceiptsEnabled by sessionPreferencesStore.isSendPublicReadReceiptsEnabled().collectAsState(initial = true)
val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true) val renderReadReceipts by sessionPreferencesStore.isRenderReadReceiptsEnabled().collectAsState(initial = true)
val sessionState by remember {
derivedStateOf {
SessionState(
isSessionVerified = sessionVerifiedStatus == SessionVerifiedStatus.Verified,
isKeyBackupEnabled = keyBackupState == BackupState.ENABLED
)
}
}
fun handleEvents(event: TimelineEvents) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
@ -184,7 +165,6 @@ class TimelinePresenter @AssistedInject constructor(
timelineItems = timelineItems, timelineItems = timelineItems,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
newEventState = newItemState.value, newEventState = newItemState.value,
sessionState = sessionState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -32,7 +31,6 @@ data class TimelineState(
val highlightedEventId: EventId?, val highlightedEventId: EventId?,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val newEventState: NewEventState, val newEventState: NewEventState,
val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit
) )

View file

@ -29,7 +29,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -58,10 +57,6 @@ fun aTimelineState(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
highlightedEventId = null, highlightedEventId = null,
newEventState = NewEventState.None, newEventState = NewEventState.None,
sessionState = aSessionState(
isSessionVerified = true,
isKeyBackupEnabled = true,
),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -81,6 +81,7 @@ fun TimelineView(
typingNotificationState: TypingNotificationState, typingNotificationState: TypingNotificationState,
roomName: String?, roomName: String?,
onUserDataClicked: (UserId) -> Unit, onUserDataClicked: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onMessageClicked: (TimelineItem.Event) -> Unit, onMessageClicked: (TimelineItem.Event) -> Unit,
onMessageLongClicked: (TimelineItem.Event) -> Unit, onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
@ -140,13 +141,13 @@ fun TimelineView(
onClick = onMessageClicked, onClick = onMessageClicked,
onLongClick = onMessageLongClicked, onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked, onUserDataClick = onUserDataClicked,
onLinkClicked = onLinkClicked,
inReplyToClick = ::inReplyToClicked, inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked, onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked, onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked, onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick, onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink, eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply, onSwipeToReply = onSwipeToReply,
) )
@ -276,6 +277,7 @@ internal fun TimelineViewPreview(
onMessageClicked = {}, onMessageClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onMessageLongClicked = {}, onMessageLongClicked = {},
onReactionClicked = { _, _ -> }, onReactionClicked = { _, _ -> },
onReactionLongClicked = { _, _ -> }, onReactionLongClicked = { _, _ -> },

View file

@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow(
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
inReplyToClick = {}, inReplyToClick = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -50,7 +49,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -94,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.metadata import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -109,9 +106,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -128,6 +122,7 @@ fun TimelineItemEventRow(
isHighlighted: Boolean, isHighlighted: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onLinkClicked: (String) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
@ -151,13 +146,6 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId) inReplyToClick(inReplyToEventId)
} }
fun onMentionClicked(mention: Mention) {
when (mention) {
is Mention.User -> onUserDataClick(mention.userId)
else -> Unit // TODO implement actions for other mentions being clicked
}
}
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) { if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -203,7 +191,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -222,7 +210,7 @@ fun TimelineItemEventRow(
onReactionClicked = { emoji -> onReactionClick(emoji, event) }, onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) }, onMoreReactionsClicked = { onMoreReactionsClick(event) },
onMentionClicked = ::onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent(
onReactionClicked: (emoji: String) -> Unit, onReactionClicked: (emoji: String) -> Unit,
onReactionLongClicked: (emoji: String) -> Unit, onReactionLongClicked: (emoji: String) -> Unit,
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
onMentionClicked: (Mention) -> Unit, onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent(
onTimestampClicked = { onTimestampClicked = {
onTimestampClicked(event) onTimestampClicked(event)
}, },
onMentionClicked = onMentionClicked, onLinkClicked = onLinkClicked,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@ -429,7 +417,7 @@ private fun MessageEventBubbleContent(
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit, onLinkClicked: (String) -> Unit,
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
@SuppressLint("ModifierParameter") @SuppressLint("ModifierParameter")
// need to rename this modifier to prevent linter false positives // need to rename this modifier to prevent linter false positives
@ -530,7 +518,6 @@ private fun MessageEventBubbleContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canShrinkContent: Boolean = false, canShrinkContent: Boolean = false,
) { ) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier val timestampLayoutModifier: Modifier
val contentModifier: Modifier val contentModifier: Modifier
when { when {
@ -566,20 +553,7 @@ private fun MessageEventBubbleContent(
) { onContentLayoutChanged -> ) { onContentLayoutChanged ->
TimelineItemEventContentView( TimelineItemEventContentView(
content = event.content, content = event.content,
onLinkClicked = { url -> onLinkClicked = onLinkClicked,
when (val permalink = PermalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
onMentionClicked(Mention.User(UserId(permalink.userId)))
}
is PermalinkData.RoomLink -> {
onMentionClicked(Mention.Room(permalink.getRoomId(), permalink.getRoomAlias()))
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
context.openUrlInExternalApp(url)
}
}
},
eventSink = eventSink, eventSink = eventSink,
onContentLayoutChanged = onContentLayoutChanged, onContentLayoutChanged = onContentLayoutChanged,
modifier = contentModifier modifier = contentModifier

View file

@ -32,8 +32,6 @@ import io.element.android.features.messages.impl.timeline.components.group.Group
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -46,11 +44,11 @@ fun TimelineItemGroupedEventsRow(
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -73,11 +71,11 @@ fun TimelineItemGroupedEventsRow(
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -97,11 +95,11 @@ private fun TimelineItemGroupedEventsRowContent(
highlightedItem: String?, highlightedItem: String?,
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
sessionState: SessionState,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit, onTimestampClicked: (TimelineItem.Event) -> Unit,
onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit,
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
@ -130,11 +128,11 @@ private fun TimelineItemGroupedEventsRowContent(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -170,11 +168,11 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },
@ -195,11 +193,11 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
highlightedItem = null, highlightedItem = null,
renderReadReceipts = true, renderReadReceipts = true,
isLastOutgoingMessage = false, isLastOutgoingMessage = false,
sessionState = aSessionState(),
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
inReplyToClick = {}, inReplyToClick = {},
onUserDataClick = {}, onUserDataClick = {},
onLinkClicked = {},
onTimestampClicked = {}, onTimestampClicked = {},
onReactionClick = { _, _ -> }, onReactionClick = { _, _ -> },
onReactionLongClick = { _, _ -> }, onReactionLongClick = { _, _ -> },

View file

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -166,6 +167,7 @@ internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
) )
} }
@ExcludeFromCoverage
@Composable @Composable
private fun ContentToPreview( private fun ContentToPreview(
reactions: ImmutableList<AggregatedReaction>, reactions: ImmutableList<AggregatedReaction>,

View file

@ -23,7 +23,6 @@ import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -34,8 +33,8 @@ internal fun TimelineItemRow(
renderReadReceipts: Boolean, renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean, isLastOutgoingMessage: Boolean,
highlightedItem: String?, highlightedItem: String?,
sessionState: SessionState,
onUserDataClick: (UserId) -> Unit, onUserDataClick: (UserId) -> Unit,
onLinkClicked: (String) -> Unit,
onClick: (TimelineItem.Event) -> Unit, onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
inReplyToClick: (EventId) -> Unit, inReplyToClick: (EventId) -> Unit,
@ -52,7 +51,6 @@ internal fun TimelineItemRow(
is TimelineItem.Virtual -> { is TimelineItem.Virtual -> {
TimelineItemVirtualRow( TimelineItemVirtualRow(
virtual = timelineItem, virtual = timelineItem,
sessionState = sessionState,
modifier = modifier, modifier = modifier,
) )
} }
@ -79,6 +77,7 @@ internal fun TimelineItemRow(
onClick = { onClick(timelineItem) }, onClick = { onClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) },
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,
@ -98,11 +97,11 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts, renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage, isLastOutgoingMessage = isLastOutgoingMessage,
highlightedItem = highlightedItem, highlightedItem = highlightedItem,
sessionState = sessionState,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
inReplyToClick = inReplyToClick, inReplyToClick = inReplyToClick,
onUserDataClick = onUserDataClick, onUserDataClick = onUserDataClick,
onLinkClicked = onLinkClicked,
onTimestampClicked = onTimestampClicked, onTimestampClicked = onTimestampClicked,
onReactionClick = onReactionClick, onReactionClick = onReactionClick,
onReactionLongClick = onReactionLongClick, onReactionLongClick = onReactionLongClick,

View file

@ -25,17 +25,15 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.session.SessionState
@Composable @Composable
fun TimelineItemVirtualRow( fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual, virtual: TimelineItem.Virtual,
sessionState: SessionState,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
when (virtual.model) { when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier) is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView() TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier) is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
} }
} }

View file

@ -1,98 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
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.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.ceil
// Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case.
data class ExtraPadding(val extraWidth: Dp)
val noExtraPadding = ExtraPadding(0.dp)
/**
* See [io.element.android.features.messages.impl.timeline.components.TimelineEventTimestampView] for the related View.
* And https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?node-id=1819%253A99506 for the design.
*/
@Composable
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
val formattedTime = sentTime
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
val textMeasurer = rememberTextMeasurer(cacheSize = 128)
val density = LocalDensity.current
var strLen = 2.dp // Extra space char
if (isMessageEdited) {
val editedText = stringResource(id = CommonStrings.common_edited_suffix)
val extraLen = remember(editedText, density) { textMeasurer.getExtraPadding(editedText, density) } + 10.dp // Text + spacing
strLen += extraLen
}
strLen += remember(formattedTime, density) { textMeasurer.getExtraPadding(formattedTime, density) }
if (hasMessageSendingFailed) {
strLen += 19.dp // Image + spacing
// I do not know why, but adding extra widths avoid overlapping when the
// message is edited and in error.
if (isMessageEdited) {
strLen += 2.dp
}
}
return ExtraPadding(strLen)
}
private fun TextMeasurer.getExtraPadding(text: String, density: Density): Dp {
val timestampTextStyle = ElementTheme.typography.fontBodyXsRegular
val textWidth = measure(text = text, style = timestampTextStyle).size.width
return (textWidth / density.density).dp
}
/**
* Get a string to add to the content of the message to avoid overlapping the timestamp.
*/
@Composable
fun ExtraPadding.getStr(textStyle: TextStyle = LocalTextStyle.current): String {
if (extraWidth == 0.dp) return ""
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer(128)
val charWidth = remember(textStyle) { textMeasurer.measure(text = "\u00A0", style = textStyle).size.width }
val widthPx = remember(density, extraWidth) { with(density) { extraWidth.toPx() } }
// A space and some unbreakable spaces, always rounding the result to the next value if not a integer
return " " + "\u00A0".repeat(ceil(widthPx / charWidth).toInt())
}
@Composable
fun ExtraPadding.getDpSize(): Dp {
return extraWidth
}

View file

@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
sealed interface RetrySendMenuEvents { sealed interface RetrySendMenuEvents {
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
data object RetrySend : RetrySendMenuEvents data object Retry : RetrySendMenuEvents
data object RemoveFailed : RetrySendMenuEvents data object Remove : RetrySendMenuEvents
data object Dismiss : RetrySendMenuEvents data object Dismiss : RetrySendMenuEvents
} }

View file

@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
is RetrySendMenuEvents.EventSelected -> { is RetrySendMenuEvents.EventSelected -> {
selectedEvent = event.event selectedEvent = event.event
} }
RetrySendMenuEvents.RetrySend -> { RetrySendMenuEvents.Retry -> {
coroutineScope.launch { coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId -> selectedEvent?.transactionId?.let { transactionId ->
room.retrySendMessage(transactionId) room.retrySendMessage(transactionId)
@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
selectedEvent = null selectedEvent = null
} }
} }
RetrySendMenuEvents.RemoveFailed -> { RetrySendMenuEvents.Remove -> {
coroutineScope.launch { coroutineScope.launch {
selectedEvent?.transactionId?.let { transactionId -> selectedEvent?.transactionId?.let { transactionId ->
room.cancelSend(transactionId) room.cancelSend(transactionId)

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components.retrysendmenu package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
} }
fun onRetry() { fun onRetry() {
state.eventSink(RetrySendMenuEvents.RetrySend) state.eventSink(RetrySendMenuEvents.Retry)
} }
fun onRemoveFailed() { fun onRemove() {
state.eventSink(RetrySendMenuEvents.RemoveFailed) state.eventSink(RetrySendMenuEvents.Remove)
} }
RetrySendMessageMenuBottomSheet( RetrySendMessageMenuBottomSheet(
modifier = modifier, modifier = modifier,
isVisible = isVisible, isVisible = isVisible,
onRetry = ::onRetry, onRetry = ::onRetry,
onRemoveFailed = ::onRemoveFailed, onRemove = ::onRemove,
onDismiss = ::onDismiss onDismiss = ::onDismiss
) )
} }
@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
private fun RetrySendMessageMenuBottomSheet( private fun RetrySendMessageMenuBottomSheet(
isVisible: Boolean, isVisible: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onRemoveFailed: () -> Unit, onRemove: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -95,7 +94,10 @@ private fun RetrySendMessageMenuBottomSheet(
} }
} }
) { ) {
RetrySendMenuContents(onRetry = onRetry, onRemoveFailed = onRemoveFailed) RetrySendMenuContents(
onRetry = onRetry,
onRemove = onRemove,
)
// FIXME remove after https://issuetracker.google.com/issues/275849044 // FIXME remove after https://issuetracker.google.com/issues/275849044
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
@Composable @Composable
private fun ColumnScope.RetrySendMenuContents( private fun ColumnScope.RetrySendMenuContents(
onRetry: () -> Unit, onRetry: () -> Unit,
onRemoveFailed: () -> Unit, onRemove: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(), sheetState: SheetState = rememberModalBottomSheetState(),
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
modifier = Modifier.clickable { modifier = Modifier.clickable {
coroutineScope.launch { coroutineScope.launch {
sheetState.hide() sheetState.hide()
onRemoveFailed() onRemove()
} }
} }
) )
} }
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview { internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed RetrySendMessageMenu(
Column { state = state,
RetrySendMenuContents( )
onRetry = {},
onRemoveFailed = {},
)
}
} }

View file

@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.virtual package io.element.android.features.messages.impl.timeline.components.virtual
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -29,20 +28,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.session.SessionStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@Composable @Composable
fun TimelineEncryptedHistoryBannerView( fun TimelineEncryptedHistoryBannerView(
sessionState: SessionState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -61,26 +56,15 @@ fun TimelineEncryptedHistoryBannerView(
tint = ElementTheme.colors.iconInfoPrimary tint = ElementTheme.colors.iconInfoPrimary
) )
Text( Text(
text = stringResource(sessionState.toStringResId()), text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary color = ElementTheme.colors.textInfoPrimary
) )
} }
} }
@StringRes
private fun SessionState.toStringResId(): Int {
return when {
isSessionVerified.not() -> R.string.screen_room_encrypted_history_banner_unverified
isKeyBackupEnabled.not() -> R.string.screen_room_encrypted_history_banner
else -> R.string.screen_room_encrypted_history_banner // TODO strings need to be updated
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun EncryptedHistoryBannerViewPreview( internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState, TimelineEncryptedHistoryBannerView()
) = ElementPreview {
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
} }

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -67,6 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
private val fileExtensionExtractor: FileExtensionExtractor, private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider, private val htmlConverterProvider: HtmlConverterProvider,
private val permalinkParser: PermalinkParser,
) { ) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent { suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) { return when (val messageType = content.type) {
@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor(
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}" val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
TimelineItemEmoteContent( TimelineItemEmoteContent(
body = emoteBody, body = emoteBody,
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"), htmlDocument = messageType.formatted?.toHtmlDocument(
permalinkParser = permalinkParser,
prefix = "* $senderDisplayName",
),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(), formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemNoticeContent( TimelineItemNoticeContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )
@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
val body = messageType.body.trimEnd() val body = messageType.body.trimEnd()
TimelineItemTextContent( TimelineItemTextContent(
body = body, body = body,
htmlDocument = messageType.formatted?.toHtmlDocument(), htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(), formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
isEdited = content.isEdited, isEdited = content.isEdited,
) )

View file

@ -30,6 +30,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@ -43,6 +44,7 @@ class TimelineItemEventFactory @Inject constructor(
private val contentFactory: TimelineItemContentFactory, private val contentFactory: TimelineItemContentFactory,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val permalinkParser: PermalinkParser,
) { ) {
suspend fun create( suspend fun create(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
reactionsState = currentTimelineItem.computeReactionsState(), reactionsState = currentTimelineItem.computeReactionsState(),
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
localSendState = currentTimelineItem.event.localSendState, localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(), inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
isThreaded = currentTimelineItem.event.isThreaded(), isThreaded = currentTimelineItem.event.isThreaded(),
debugInfo = currentTimelineItem.event.debugInfo, debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin, origin = currentTimelineItem.event.origin,

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.model
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@ -34,7 +35,9 @@ data class InReplyToDetails(
val textContent: String?, val textContent: String?,
) )
fun InReplyTo.map() = when (this) { fun InReplyTo.map(
permalinkParser: PermalinkParser,
) = when (this) {
is InReplyTo.Ready -> InReplyToDetails( is InReplyTo.Ready -> InReplyToDetails(
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) {
textContent = when (content) { textContent = when (content) {
is MessageContent -> { is MessageContent -> {
val messageContent = content as MessageContent val messageContent = content as MessageContent
(messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body (messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
} }
is StickerContent -> { is StickerContent -> {
val stickerContent = content as StickerContent val stickerContent = content as StickerContent

View file

@ -1,36 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.session
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class SessionStateProvider : PreviewParameterProvider<SessionState> {
override val values: Sequence<SessionState>
get() = sequenceOf(
aSessionState(isSessionVerified = false, isKeyBackupEnabled = false),
aSessionState(isSessionVerified = true, isKeyBackupEnabled = false),
aSessionState(isSessionVerified = true, isKeyBackupEnabled = true),
)
}
internal fun aSessionState(
isSessionVerified: Boolean = false,
isKeyBackupEnabled: Boolean = false,
) = SessionState(
isSessionVerified = isSessionVerified,
isKeyBackupEnabled = isKeyBackupEnabled,
)

View file

@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview(
onEventClicked = { false }, onEventClicked = { false },
onPreviewAttachments = {}, onPreviewAttachments = {},
onUserDataClicked = {}, onUserDataClicked = {},
onLinkClicked = {},
onSendLocationClicked = {}, onSendLocationClicked = {},
onCreatePollClicked = {}, onCreatePollClicked = {},
onJoinCallClicked = {}, onJoinCallClicked = {},

View file

@ -5,24 +5,24 @@
<string name="emoji_picker_category_foods">"Ежа &amp; Напоі"</string> <string name="emoji_picker_category_foods">"Ежа &amp; Напоі"</string>
<string name="emoji_picker_category_nature">"Жывёлы &amp; Прырода"</string> <string name="emoji_picker_category_nature">"Жывёлы &amp; Прырода"</string>
<string name="emoji_picker_category_objects">"Аб\'екты"</string> <string name="emoji_picker_category_objects">"Аб\'екты"</string>
<string name="emoji_picker_category_people">"Усмешкі &amp; Людзі"</string> <string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string>
<string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string> <string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</string>
<string name="emoji_picker_category_symbols">"Сімвалы"</string> <string name="emoji_picker_category_symbols">"Сімвалы"</string>
<string name="screen_report_content_block_user">"Заблакіраваць карыстальніка"</string> <string name="screen_report_content_block_user">"Заблакіраваць карыстальніка"</string>
<string name="screen_report_content_block_user_hint">"Адзначце, ці жадаеце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string> <string name="screen_report_content_block_user_hint">"Адзначце, ці хочаце вы схаваць усе бягучыя і будучыя паведамленні ад гэтага карыстальніка"</string>
<string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</string> <string name="screen_report_content_explanation">"Гэтае паведамленне будзе перададзена адміністратару вашага хатняга сервера. Яны не змогуць прачытаць зашыфраваныя паведамленні."</string>
<string name="screen_report_content_hint">"Прычына, па якой вы паскардзіліся на гэты змест"</string> <string name="screen_report_content_hint">"Прычына, па якой вы паскардзіліся на гэты змест"</string>
<string name="screen_room_attachment_source_camera">"Камера"</string> <string name="screen_room_attachment_source_camera">"Камера"</string>
<string name="screen_room_attachment_source_camera_photo">"Зрабіць фота"</string> <string name="screen_room_attachment_source_camera_photo">"Зрабіць фота"</string>
<string name="screen_room_attachment_source_camera_video">"Запісаць відэа"</string> <string name="screen_room_attachment_source_camera_video">"Запісаць відэа"</string>
<string name="screen_room_attachment_source_files">"Далучэнне"</string> <string name="screen_room_attachment_source_files">"Далучэнне"</string>
<string name="screen_room_attachment_source_gallery">"Фота &amp; Відэа Бібліятэка"</string> <string name="screen_room_attachment_source_gallery">"Бібліятэка фота &amp; відэа"</string>
<string name="screen_room_attachment_source_location">"Месцазнаходжанне"</string> <string name="screen_room_attachment_source_location">"Месцазнаходжанне"</string>
<string name="screen_room_attachment_source_poll">"Апытанне"</string> <string name="screen_room_attachment_source_poll">"Апытанне"</string>
<string name="screen_room_attachment_text_formatting">"Фармаціраванне тэксту"</string> <string name="screen_room_attachment_text_formatting">"Фармаціраванне тэксту"</string>
<string name="screen_room_encrypted_history_banner">"Гісторыя паведамленняў зараз недаступна."</string> <string name="screen_room_encrypted_history_banner">"Гісторыя паведамленняў зараз недаступна."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Гісторыя паведамленняў у гэтым пакоі недаступная. Праверце гэтую прыладу, каб убачыць гісторыю паведамленняў."</string> <string name="screen_room_encrypted_history_banner_unverified">"Гісторыя паведамленняў у гэтым пакоі недаступная. Праверце гэтую прыладу, каб убачыць гісторыю паведамленняў."</string>
<string name="screen_room_invite_again_alert_message">"Вы жадаеце запрасіць іх назад?"</string> <string name="screen_room_invite_again_alert_message">"Вы хочаце запрасіць іх назад?"</string>
<string name="screen_room_invite_again_alert_title">"Вы адзін у гэтым чаце"</string> <string name="screen_room_invite_again_alert_title">"Вы адзін у гэтым чаце"</string>
<string name="screen_room_mentions_at_room_subtitle">"Апавясціць увесь пакой"</string> <string name="screen_room_mentions_at_room_subtitle">"Апавясціць увесь пакой"</string>
<string name="screen_room_mentions_at_room_title">"Усе"</string> <string name="screen_room_mentions_at_room_title">"Усе"</string>
@ -39,7 +39,7 @@
<string name="screen_room_timeline_read_marker_title">"Новы"</string> <string name="screen_room_timeline_read_marker_title">"Новы"</string>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d змена ў пакоі"</item> <item quantity="one">"%1$d змена ў пакоі"</item>
<item quantity="few">"%1$d змен у пакоі"</item> <item quantity="few">"%1$d змены ў пакоі"</item>
<item quantity="many">"%1$d змен у пакоі"</item> <item quantity="many">"%1$d змен у пакоі"</item>
</plurals> </plurals>
<plurals name="screen_room_typing_many_members"> <plurals name="screen_room_typing_many_members">

View file

@ -76,11 +76,11 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -231,6 +231,27 @@ class MessagesPresenterTest {
} }
} }
@Test
fun `present - handle action copy link`() = runTest {
val clipboardHelper = FakeClipboardHelper()
val event = aMessageEvent()
val matrixRoom = FakeMatrixRoom(
permalinkResult = { Result.success("a link") },
)
val presenter = createMessagesPresenter(
clipboardHelper = clipboardHelper,
matrixRoom = matrixRoom,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.CopyLink, event))
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
assertThat(clipboardHelper.clipboardContents).isEqualTo("a link")
}
}
@Test @Test
fun `present - handle action reply`() = runTest { fun `present - handle action reply`() = runTest {
val presenter = createMessagesPresenter() val presenter = createMessagesPresenter()
@ -725,6 +746,8 @@ class MessagesPresenterTest {
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory, permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(),
) )
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this, this,
@ -741,8 +764,6 @@ class MessagesPresenterTest {
dispatchers = coroutineDispatchers, dispatchers = coroutineDispatchers,
appScope = this, appScope = this,
navigator = navigator, navigator = navigator,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
endPollAction = endPollAction, endPollAction = endPollAction,
sendPollResponseAction = FakeSendPollResponseAction(), sendPollResponseAction = FakeSendPollResponseAction(),

View file

@ -478,6 +478,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(), onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
onSendLocationClicked: () -> Unit = EnsureNeverCalled(), onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
onCreatePollClicked: () -> Unit = EnsureNeverCalled(), onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
@ -492,6 +493,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onRoomDetailsClicked = onRoomDetailsClicked, onRoomDetailsClicked = onRoomDetailsClicked,
onEventClicked = onEventClicked, onEventClicked = onEventClicked,
onUserDataClicked = onUserDataClicked, onUserDataClicked = onUserDataClicked,
onLinkClicked = onLinkClicked,
onPreviewAttachments = onPreviewAttachments, onPreviewAttachments = onPreviewAttachments,
onSendLocationClicked = onSendLocationClicked, onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked, onCreatePollClicked = onCreatePollClicked,

View file

@ -153,6 +153,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) )
@ -193,6 +194,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) )
@ -232,6 +234,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, TimelineItemAction.Redact,
@ -272,6 +275,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, TimelineItemAction.Redact,
@ -315,6 +319,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -357,6 +362,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) )
) )
@ -396,6 +402,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
@ -435,6 +442,7 @@ class ActionListPresenterTest {
displayEmojiReactions = false, displayEmojiReactions = false,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) )
) )
@ -473,6 +481,7 @@ class ActionListPresenterTest {
displayEmojiReactions = false, displayEmojiReactions = false,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
) )
) )
) )
@ -513,6 +522,7 @@ class ActionListPresenterTest {
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -595,6 +605,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -632,6 +643,7 @@ class ActionListPresenterTest {
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -668,6 +680,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.EndPoll, TimelineItemAction.EndPoll,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -703,6 +716,7 @@ class ActionListPresenterTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )
@ -738,6 +752,7 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Forward, TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
permalinkParser = FakePermalinkParser(),
), ),
redactedMessageFactory = TimelineItemContentRedactedFactory(), redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory( stickerFactory = TimelineItemContentStickerFactory(
@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
), ),
matrixClient = matrixClient, matrixClient = matrixClient,
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(), lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
permalinkParser = FakePermalinkParser(),
), ),
virtualItemFactory = TimelineItemVirtualFactory( virtualItemFactory = TimelineItemVirtualFactory(
daySeparatorFactory = TimelineItemDaySeparatorFactory( daySeparatorFactory = TimelineItemDaySeparatorFactory(

View file

@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
@ -60,6 +61,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -805,7 +808,14 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - insertMention`() = runTest { fun `present - insertMention`() = runTest {
val presenter = createPresenter(this) val presenter = createPresenter(
coroutineScope = this,
permalinkBuilder = FakePermalinkBuilder(
result = {
Result.success("https://matrix.to/#/${A_USER_ID_2.value}")
}
)
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -941,6 +951,7 @@ class MessageComposerPresenterTest {
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, coroutineScope,
room, room,
@ -955,6 +966,8 @@ class MessageComposerPresenterTest {
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder,
) )
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider() val provider = DefaultHtmlConverterProvider(
permalinkParser = FakePermalinkParser(),
)
val exception = runCatching { provider.provide() }.exceptionOrNull() val exception = runCatching { provider.provide() }.exceptionOrNull()
@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider() val provider = DefaultHtmlConverterProvider(
permalinkParser = FakePermalinkParser(),
)
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID) provider.Update(currentUserId = A_USER_ID)

View file

@ -26,7 +26,6 @@ import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline import io.element.android.features.messages.impl.voicemessages.timeline.aRedactedMatrixTimeline
@ -47,13 +46,11 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
@ -89,7 +86,6 @@ class TimelinePresenterTest {
assertThat(initialState.timelineItems).isEmpty() assertThat(initialState.timelineItems).isEmpty()
val loadedNoTimelineState = awaitItem() val loadedNoTimelineState = awaitItem()
assertThat(loadedNoTimelineState.timelineItems).isEmpty() assertThat(loadedNoTimelineState.timelineItems).isEmpty()
assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
} }
} }
@ -512,8 +508,6 @@ class TimelinePresenterTest {
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
appScope = this, appScope = this,
navigator = messagesNavigator, navigator = messagesNavigator,
encryptionService = FakeEncryptionService(),
verificationService = FakeSessionVerificationService(),
redactedVoiceMessageManager = redactedVoiceMessageManager, redactedVoiceMessageManager = redactedVoiceMessageManager,
endPollAction = endPollAction, endPollAction = endPollAction,
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,

View file

@ -45,6 +45,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
roomName = null, roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(), onUserDataClicked = EnsureNeverCalledWithParam(),
onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(),
@ -72,6 +73,7 @@ class TimelineViewTest {
typingNotificationState = aTypingNotificationState(), typingNotificationState = aTypingNotificationState(),
roomName = null, roomName = null,
onUserDataClicked = EnsureNeverCalledWithParam(), onUserDataClicked = EnsureNeverCalledWithParam(),
onLinkClicked = EnsureNeverCalledWithParam(),
onMessageClicked = EnsureNeverCalledWithParam(), onMessageClicked = EnsureNeverCalledWithParam(),
onMessageLongClicked = EnsureNeverCalledWithParam(), onMessageLongClicked = EnsureNeverCalledWithParam(),
onTimestampClicked = EnsureNeverCalledWithParam(), onTimestampClicked = EnsureNeverCalledWithParam(),

View file

@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
val selectedEvent = aTimelineItemEvent() val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent) assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
} }
} }
@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent() val selectedEvent = aTimelineItemEvent()
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Dismiss) initialState.eventSink(RetrySendMenuEvents.Dismiss)
assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1) assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null) val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0) assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Retry)
initialState.eventSink(RetrySendMenuEvents.RetrySend) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(1) assertThat(room.retrySendMessageCount).isEqualTo(1)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1) assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = null) val selectedEvent = aTimelineItemEvent(transactionId = null)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(0) assertThat(room.cancelSendCount).isEqualTo(0)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }
@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID) val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent)) initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
skipItems(1) skipItems(1)
initialState.eventSink(RetrySendMenuEvents.Remove)
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
assertThat(room.cancelSendCount).isEqualTo(1) assertThat(room.cancelSendCount).isEqualTo(1)
assertThat(room.retrySendMessageCount).isEqualTo(0)
assertThat(awaitItem().selectedEvent).isNull() assertThat(awaitItem().selectedEvent).isNull()
} }
} }

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RetrySendMessageMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `dismiss the bottom sheet emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.pressBackKey()
// Cannot test this for now.
// eventsRecorder.assertSingle(RetrySendMenuEvents.Dismiss)
}
@Config(qualifiers = "h1024dp")
@Test
fun `retry to send the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_room_retry_send_menu_send_again_action)
eventsRecorder.assertSingle(RetrySendMenuEvents.Retry)
}
@Config(qualifiers = "h1024dp")
@Test
fun `remove the event emits the expected event`() {
val eventsRecorder = EventsRecorder<RetrySendMenuEvents>()
rule.setRetrySendMessageMenu(
aRetrySendMenuState(
event = aTimelineItemEvent(),
eventSink = eventsRecorder
),
)
rule.clickOn(CommonStrings.action_remove)
eventsRecorder.assertSingle(RetrySendMenuEvents.Remove)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRetrySendMessageMenu(
state: RetrySendMenuState,
) {
setContent {
RetrySendMessageMenu(
state = state,
)
}
}

View file

@ -63,6 +63,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest {
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
permalinkParser = FakePermalinkParser(),
) )
private fun createStickerContent( private fun createStickerContent(

View file

@ -26,14 +26,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershi
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Test import org.junit.Test
class InReplyToDetailTest { class InReplyToDetailTest {
@Test @Test
fun `map - with a not ready InReplyTo does not work`() { fun `map - with a not ready InReplyTo does not work`() {
assertThat(InReplyTo.Pending.map()).isNull() assertThat(
assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull() InReplyTo.Pending.map(
assertThat(InReplyTo.Error.map()).isNull() permalinkParser = FakePermalinkParser()
)
).isNull()
assertThat(
InReplyTo.NotLoaded(AN_EVENT_ID).map(
permalinkParser = FakePermalinkParser()
)
).isNull()
assertThat(
InReplyTo.Error.map(
permalinkParser = FakePermalinkParser()
)
).isNull()
} }
@Test @Test
@ -48,7 +61,9 @@ class InReplyToDetailTest {
change = MembershipChange.INVITED, change = MembershipChange.INVITED,
) )
) )
val inReplyToDetails = inReplyTo.map() val inReplyToDetails = inReplyTo.map(
permalinkParser = FakePermalinkParser()
)
assertThat(inReplyToDetails).isNotNull() assertThat(inReplyToDetails).isNotNull()
assertThat(inReplyToDetails?.textContent).isNull() assertThat(inReplyToDetails?.textContent).isNull()
} }
@ -74,7 +89,11 @@ class InReplyToDetailTest {
) )
) )
) )
assertThat(inReplyTo.map()?.textContent).isEqualTo("Hello!") assertThat(
inReplyTo.map(
permalinkParser = FakePermalinkParser()
)?.textContent
).isEqualTo("Hello!")
} }
@Test @Test
@ -95,6 +114,10 @@ class InReplyToDetailTest {
) )
) )
) )
assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**") assertThat(
inReplyTo.map(
permalinkParser = FakePermalinkParser()
)?.textContent
).isEqualTo("**Hello!**")
} }
} }

View file

@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.poll.PollAnswer
* @property isSelected whether the user has selected this answer. * @property isSelected whether the user has selected this answer.
* @property isEnabled whether the answer can be voted. * @property isEnabled whether the answer can be voted.
* @property isWinner whether this is the winner answer in the poll. * @property isWinner whether this is the winner answer in the poll.
* @property isDisclosed whether the votes for this answer should be disclosed. * @property showVotes whether the votes for this answer should be displayed.
* @property votesCount the number of votes for this answer. * @property votesCount the number of votes for this answer.
* @property percentage the percentage of votes for this answer. * @property percentage the percentage of votes for this answer.
*/ */
@ -34,7 +34,7 @@ data class PollAnswerItem(
val isSelected: Boolean, val isSelected: Boolean,
val isEnabled: Boolean, val isEnabled: Boolean,
val isWinner: Boolean, val isWinner: Boolean,
val isDisclosed: Boolean, val showVotes: Boolean,
val votesCount: Int, val votesCount: Int,
val percentage: Float, val percentage: Float,
) )

View file

@ -41,6 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonPlurals
@Composable @Composable
@ -79,17 +80,36 @@ internal fun PollAnswerView(
text = answerItem.answer.text, text = answerItem.answer.text,
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular, style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
) )
if (answerItem.isDisclosed) { if (answerItem.showVotes) {
Text( val text = pluralStringResource(
modifier = Modifier.align(Alignment.Bottom), id = CommonPlurals.common_poll_votes_count,
text = pluralStringResource( count = answerItem.votesCount,
id = CommonPlurals.common_poll_votes_count, answerItem.votesCount
count = answerItem.votesCount,
answerItem.votesCount
),
style = if (answerItem.isWinner) ElementTheme.typography.fontBodySmMedium else ElementTheme.typography.fontBodySmRegular,
color = if (answerItem.isWinner) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
) )
Row(
modifier = Modifier.align(Alignment.Bottom),
verticalAlignment = Alignment.CenterVertically,
) {
if (answerItem.isWinner) {
Icon(
resourceId = CommonDrawables.ic_winner,
contentDescription = null,
tint = ElementTheme.colors.iconAccentTertiary,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = text,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
Text(
text = text,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
} }
} }
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
@ -98,7 +118,7 @@ internal fun PollAnswerView(
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(), color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
progress = { progress = {
when { when {
answerItem.isDisclosed -> answerItem.percentage answerItem.showVotes -> answerItem.percentage
answerItem.isSelected -> 1f answerItem.isSelected -> 1f
else -> 0f else -> 0f
} }
@ -114,7 +134,7 @@ internal fun PollAnswerView(
@Composable @Composable
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview { internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false), answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
) )
} }
@ -122,7 +142,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview { internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true), answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
) )
} }
@ -130,7 +150,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview { internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false), answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
) )
} }
@ -138,7 +158,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview { internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true), answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
) )
} }
@ -146,7 +166,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview { internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false, isEnabled = false, isWinner = true), answerItem = aPollAnswerItem(showVotes = true, isSelected = false, isEnabled = false, isWinner = true),
) )
} }
@ -154,7 +174,7 @@ internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview { internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = true), answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = true),
) )
} }
@ -162,6 +182,6 @@ internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
@Composable @Composable
internal fun PollAnswerEndedSelectedPreview() = ElementPreview { internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
PollAnswerView( PollAnswerView(
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false), answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
) )
} }

View file

@ -27,11 +27,11 @@ fun aPollQuestion() = "What type of food should we have at the party?"
fun aPollAnswerItemList( fun aPollAnswerItemList(
hasVotes: Boolean = true, hasVotes: Boolean = true,
isEnded: Boolean = false, isEnded: Boolean = false,
isDisclosed: Boolean = true, showVotes: Boolean = true,
) = persistentListOf( ) = persistentListOf(
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"), answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"),
isDisclosed = isDisclosed, showVotes = showVotes,
isEnabled = !isEnded, isEnabled = !isEnded,
isWinner = isEnded, isWinner = isEnded,
votesCount = if (hasVotes) 5 else 0, votesCount = if (hasVotes) 5 else 0,
@ -39,7 +39,7 @@ fun aPollAnswerItemList(
), ),
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"), answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
isDisclosed = isDisclosed, showVotes = showVotes,
isEnabled = !isEnded, isEnabled = !isEnded,
isWinner = false, isWinner = false,
votesCount = 0, votesCount = 0,
@ -47,7 +47,7 @@ fun aPollAnswerItemList(
), ),
aPollAnswerItem( aPollAnswerItem(
answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"), answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"),
isDisclosed = isDisclosed, showVotes = showVotes,
isEnabled = !isEnded, isEnabled = !isEnded,
isWinner = false, isWinner = false,
isSelected = true, isSelected = true,
@ -55,7 +55,7 @@ fun aPollAnswerItemList(
percentage = if (hasVotes) 0.1f else 0f percentage = if (hasVotes) 0.1f else 0f
), ),
aPollAnswerItem( aPollAnswerItem(
isDisclosed = isDisclosed, showVotes = showVotes,
isEnabled = !isEnded, isEnabled = !isEnded,
votesCount = if (hasVotes) 4 else 0, votesCount = if (hasVotes) 4 else 0,
percentage = if (hasVotes) 0.4f else 0f, percentage = if (hasVotes) 0.4f else 0f,
@ -70,7 +70,7 @@ fun aPollAnswerItem(
isSelected: Boolean = false, isSelected: Boolean = false,
isEnabled: Boolean = true, isEnabled: Boolean = true,
isWinner: Boolean = false, isWinner: Boolean = false,
isDisclosed: Boolean = true, showVotes: Boolean = true,
votesCount: Int = 4, votesCount: Int = 4,
percentage: Float = 0.4f, percentage: Float = 0.4f,
) = PollAnswerItem( ) = PollAnswerItem(
@ -78,7 +78,7 @@ fun aPollAnswerItem(
isSelected = isSelected, isSelected = isSelected,
isEnabled = isEnabled, isEnabled = isEnabled,
isWinner = isWinner, isWinner = isWinner,
isDisclosed = isDisclosed, showVotes = showVotes,
votesCount = votesCount, votesCount = votesCount,
percentage = percentage percentage = percentage
) )
@ -87,14 +87,14 @@ fun aPollContentState(
eventId: EventId? = null, eventId: EventId? = null,
isMine: Boolean = false, isMine: Boolean = false,
isEnded: Boolean = false, isEnded: Boolean = false,
isDisclosed: Boolean = true, showVotes: Boolean = true,
isPollEditable: Boolean = true, isPollEditable: Boolean = true,
hasVotes: Boolean = true, hasVotes: Boolean = true,
question: String = aPollQuestion(), question: String = aPollQuestion(),
pollKind: PollKind = PollKind.Disclosed, pollKind: PollKind = PollKind.Disclosed,
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList( answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
isEnded = isEnded, isEnded = isEnded,
isDisclosed = isDisclosed, showVotes = showVotes,
hasVotes = hasVotes hasVotes = hasVotes
), ),
) = PollContentState( ) = PollContentState(

View file

@ -245,7 +245,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
PollContentView( PollContentView(
eventId = EventId("\$anEventId"), eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?", question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(isDisclosed = false), answerItems = aPollAnswerItemList(showVotes = false),
pollKind = PollKind.Undisclosed, pollKind = PollKind.Undisclosed,
isPollEnded = false, isPollEnded = false,
isPollEditable = false, isPollEditable = false,

Some files were not shown because too many files have changed in this diff Show more