Merge branch 'release/0.4.8' into main
This commit is contained in:
commit
08d33c4acc
1015 changed files with 12210 additions and 2845 deletions
11
.github/ISSUE_TEMPLATE/task-that-belongs-to-a-story-epic.md
vendored
Normal file
11
.github/ISSUE_TEMPLATE/task-that-belongs-to-a-story-epic.md
vendored
Normal 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.
|
||||
2
.github/workflows/generate_github_pages.yml
vendored
2
.github/workflows/generate_github_pages.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
mkdir -p screenshots/en
|
||||
cp tests/uitests/src/test/snapshots/images/* screenshots/en
|
||||
- name: Deploy GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./screenshots
|
||||
|
|
|
|||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.22" />
|
||||
<option name="version" value="1.9.23" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -23,7 +23,8 @@ appId: ${MAESTRO_APP_ID}
|
|||
- inputText: ${MAESTRO_PASSWORD}
|
||||
- pressKey: Enter
|
||||
- tapOn: "Continue"
|
||||
- runFlow: ../assertions/assertSessionVerificationDisplayed.yaml
|
||||
- runFlow: ./verifySession.yaml
|
||||
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
|
||||
- tapOn: "Not now"
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
- runFlow: ./verifySession.yaml
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- tapOn: "Continue"
|
||||
- takeScreenshot: build/maestro/150-Verify
|
||||
- tapOn: "Enter recovery key"
|
||||
- tapOn:
|
||||
id: "verification-recovery_key"
|
||||
- inputText: ${MAESTRO_RECOVERY_KEY}
|
||||
- hideKeyboard
|
||||
- tapOn: "Confirm"
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Device verified"
|
||||
timeout: 10000
|
||||
- tapOn: "Continue"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
appId: ${MAESTRO_APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Confirm that it's you"
|
||||
timeout: 20000
|
||||
|
|
@ -7,8 +7,4 @@ appId: ${MAESTRO_APP_ID}
|
|||
- tapOn: ${MAESTRO_ROOM_NAME}
|
||||
# Back from timeline
|
||||
- back
|
||||
- assertVisible: "MyR"
|
||||
- hideKeyboard
|
||||
# Back from search
|
||||
- back
|
||||
- runFlow: ../assertions/assertHomeDisplayed.yaml
|
||||
|
|
|
|||
27
CHANGES.md
27
CHANGES.md
|
|
@ -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)
|
||||
========================================
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.x
|
|||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -31,7 +32,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.bumble.appyx.core.integration.NodeHost
|
||||
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||
|
|
@ -60,7 +60,7 @@ class MainActivity : NodeActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
appBindings = bindings()
|
||||
appBindings.lockScreenService().handleSecureFlag(this)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MainContent(appBindings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("java-library")
|
||||
id("com.android.lint")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
id("io.element.android-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
android {
|
||||
namespace = "io.element.android.appconfig"
|
||||
}
|
||||
|
||||
anvil {
|
||||
|
|
@ -33,4 +30,5 @@ anvil {
|
|||
dependencies {
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,29 @@
|
|||
|
||||
package io.element.android.appconfig
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
|
||||
object TimelineConfig {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.features.login.impl)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
|
|
|||
|
|
@ -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.SnackbarMessage
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -34,16 +32,12 @@ import javax.inject.Inject
|
|||
class LoggedInEventProcessor @Inject constructor(
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
roomMembershipObserver: RoomMembershipObserver,
|
||||
sessionVerificationService: SessionVerificationService,
|
||||
) {
|
||||
private var observingJob: Job? = null
|
||||
|
||||
private val displayLeftRoomMessage = roomMembershipObserver.updates
|
||||
.map { !it.isUserInRoom }
|
||||
|
||||
private val displayVerificationSuccessfulMessage = sessionVerificationService.verificationFlowState
|
||||
.map { it == VerificationFlowState.Finished }
|
||||
|
||||
fun observeEvents(coroutineScope: CoroutineScope) {
|
||||
observingJob = coroutineScope.launch {
|
||||
displayLeftRoomMessage
|
||||
|
|
@ -52,13 +46,6 @@ class LoggedInEventProcessor @Inject constructor(
|
|||
displayMessage(CommonStrings.common_current_user_left_room)
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
displayVerificationSuccessfulMessage
|
||||
.filter { it }
|
||||
.onEach {
|
||||
displayMessage(CommonStrings.common_verification_complete)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import io.element.android.appnav.room.RoomFlowNode
|
|||
import io.element.android.appnav.room.RoomLoadedFlowNode
|
||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
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.invitelist.api.InviteListEntryPoint
|
||||
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.NetworkStatus
|
||||
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.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.architecture.createNode
|
||||
import io.element.android.libraries.architecture.waitForChildAttached
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
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.matrix.api.MatrixClient
|
||||
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.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
|
@ -87,21 +91,21 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val preferencesEntryPoint: PreferencesEntryPoint,
|
||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||
private val inviteListEntryPoint: InviteListEntryPoint,
|
||||
private val ftueEntryPoint: FtueEntryPoint,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val ftueState: FtueState,
|
||||
private val ftueService: FtueService,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
private val lockScreenStateService: LockScreenService,
|
||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||
private val matrixClient: MatrixClient,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.RoomList,
|
||||
initialElement = NavTarget.Placeholder,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
permanentNavModel = PermanentNavModel(
|
||||
|
|
@ -119,7 +123,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val loggedInFlowProcessor = LoggedInEventProcessor(
|
||||
snackbarDispatcher,
|
||||
matrixClient.roomMembershipObserver(),
|
||||
matrixClient.sessionVerificationService(),
|
||||
)
|
||||
|
||||
override fun onBuilt() {
|
||||
|
|
@ -131,9 +134,15 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||
|
||||
if (ftueState.shouldDisplayFlow.value) {
|
||||
backstack.push(NavTarget.Ftue)
|
||||
}
|
||||
ftueService.state
|
||||
.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 = {
|
||||
coroutineScope.launch {
|
||||
|
|
@ -189,6 +198,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Placeholder : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LoggedInPermanent : NavTarget
|
||||
|
||||
|
|
@ -212,9 +224,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data object CreateRoom : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object VerifySession : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class SecureBackup(
|
||||
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
|
||||
|
|
@ -225,10 +234,14 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object Ftue : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomDirectorySearch : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Placeholder -> createNode<PlaceholderNode>(buildContext)
|
||||
NavTarget.LoggedInPermanent -> {
|
||||
createNode<LoggedInNode>(buildContext)
|
||||
}
|
||||
|
|
@ -251,10 +264,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
backstack.push(NavTarget.CreateRoom)
|
||||
}
|
||||
|
||||
override fun onSessionVerificationClicked() {
|
||||
backstack.push(NavTarget.VerifySession)
|
||||
}
|
||||
|
||||
override fun onSessionConfirmRecoveryKeyClicked() {
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
|
||||
}
|
||||
|
|
@ -270,6 +279,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
override fun onReportBugClicked() {
|
||||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
||||
override fun onRoomDirectorySearchClicked() {
|
||||
backstack.push(NavTarget.RoomDirectorySearch)
|
||||
}
|
||||
}
|
||||
roomListEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
|
|
@ -299,10 +312,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
plugins<Callback>().forEach { it.onOpenBugReport() }
|
||||
}
|
||||
|
||||
override fun onVerifyClicked() {
|
||||
backstack.push(NavTarget.VerifySession)
|
||||
}
|
||||
|
||||
override fun onSecureBackupClicked() {
|
||||
backstack.push(NavTarget.SecureBackup())
|
||||
}
|
||||
|
|
@ -329,25 +338,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
.callback(callback)
|
||||
.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 -> {
|
||||
secureBackupEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
|
||||
|
|
@ -372,7 +362,16 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
ftueEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(object : FtueEntryPoint.Callback {
|
||||
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()
|
||||
|
|
@ -380,20 +379,23 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun attachRoot(): Node {
|
||||
return attachChild {
|
||||
suspend fun attachRoomList() {
|
||||
if (!canShowRoomList()) return
|
||||
attachChild<Node> {
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun attachRoom(roomId: RoomId): RoomFlowNode {
|
||||
return attachChild {
|
||||
suspend fun attachRoom(roomId: RoomId) {
|
||||
if (!canShowRoomList()) return
|
||||
attachChild<RoomFlowNode> {
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
|
||||
if (!canShowRoomList()) return@withContext
|
||||
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
backstack.push(NavTarget.InviteList)
|
||||
|
|
@ -402,13 +404,17 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun canShowRoomList(): Boolean {
|
||||
return ftueService.state.value is FtueState.Complete
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
val lockScreenState by lockScreenStateService.lockState.collectAsState()
|
||||
val isFtueDisplayed by ftueService.state.collectAsState()
|
||||
BackstackView()
|
||||
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
|
||||
if (!isFtueDisplayed) {
|
||||
if (isFtueDisplayed is FtueState.Complete) {
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ class RootFlowNode @AssistedInject constructor(
|
|||
.attachSession()
|
||||
.apply {
|
||||
when (deeplinkData) {
|
||||
is DeeplinkData.Root -> attachRoot()
|
||||
is DeeplinkData.Root -> attachRoomList()
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
|
||||
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,22 +27,32 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
|
|||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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 kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class LoggedInPresenter @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val pushService: PushService,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : Presenter<LoggedInState> {
|
||||
@Composable
|
||||
override fun present(): LoggedInState {
|
||||
LaunchedEffect(Unit) {
|
||||
// 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 isVerified by remember {
|
||||
sessionVerificationService.sessionVerifiedStatus.map { it == SessionVerifiedStatus.Verified }
|
||||
}.collectAsState(initial = false)
|
||||
|
||||
LaunchedEffect(isVerified) {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import io.element.android.libraries.architecture.NodeInputs
|
|||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DaggerComponentOwner
|
||||
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.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
|
|
@ -61,6 +62,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
private val matrixClient: MatrixClient,
|
||||
roomComponentFactory: RoomComponentFactory,
|
||||
roomMembershipObserver: RoomMembershipObserver,
|
||||
) : BaseFlowNode<RoomLoadedFlowNode.NavTarget>(
|
||||
|
|
@ -92,6 +94,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
Timber.v("OnCreate => ${inputs.room.roomId}")
|
||||
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
|
||||
fetchRoomMembers()
|
||||
trackVisitedRoom()
|
||||
},
|
||||
onResume = {
|
||||
appCoroutineScope.launch {
|
||||
|
|
@ -117,6 +120,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
|
|||
inputs<Inputs>()
|
||||
}
|
||||
|
||||
private fun trackVisitedRoom() = lifecycleScope.launch {
|
||||
matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
|
||||
}
|
||||
|
||||
private fun fetchRoomMembers() = lifecycleScope.launch {
|
||||
inputs.room.updateMembers()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
|||
import io.element.android.libraries.architecture.childNode
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
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.services.appnavstate.test.FakeAppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -101,6 +102,7 @@ class RoomFlowNodeTest {
|
|||
roomMembershipObserver = RoomMembershipObserver(),
|
||||
appCoroutineScope = coroutineScope,
|
||||
roomComponentFactory = FakeRoomComponentFactory(),
|
||||
matrixClient = FakeMatrixClient(),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -22,13 +22,11 @@ import app.cash.turbine.test
|
|||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
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.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.push.api.PushService
|
||||
import io.element.android.libraries.pushproviders.api.Distributor
|
||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.push.test.FakePushService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -73,20 +71,8 @@ class LoggedInPresenterTest {
|
|||
return LoggedInPresenter(
|
||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||
networkMonitor = FakeNetworkMonitor(networkStatus),
|
||||
pushService = object : PushService {
|
||||
override fun notificationStyleChanged() {
|
||||
}
|
||||
|
||||
override fun getAvailablePushProviders(): List<PushProvider> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
|
||||
}
|
||||
|
||||
override suspend fun testPush() {
|
||||
}
|
||||
}
|
||||
pushService = FakePushService(),
|
||||
sessionVerificationService = FakeSessionVerificationService(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
fastlane/metadata/android/en-US/changelogs/40004080.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40004080.txt
Normal 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
|
||||
|
|
@ -17,19 +17,14 @@
|
|||
package io.element.android.features.analytics.impl
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.runtime.Composable
|
||||
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.features.analytics.api.AnalyticsOptInEvents
|
||||
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.InfoListOrganism
|
||||
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.PreviewsDayNight
|
||||
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.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.temporaryColorBgSpecial
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -82,6 +77,7 @@ fun AnalyticsOptInView(
|
|||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
background = { OnboardingBackground() },
|
||||
header = { AnalyticsOptInHeader(state, onClickTerms) },
|
||||
content = { AnalyticsOptInContent() },
|
||||
footer = {
|
||||
|
|
@ -103,11 +99,11 @@ private fun AnalyticsOptInHeader(
|
|||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
PageTitle(
|
||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
|
||||
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
|
||||
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconImageVector = Icons.Filled.Poll
|
||||
subtitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Chart())
|
||||
)
|
||||
val text = buildAnnotatedStringWithStyledPart(
|
||||
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
|
||||
private fun AnalyticsOptInContent() {
|
||||
Box(
|
||||
|
|
@ -162,20 +145,20 @@ private fun AnalyticsOptInContent() {
|
|||
items = persistentListOf(
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||
iconComposable = { CheckIcon() },
|
||||
iconVector = CompoundIcons.CheckCircle(),
|
||||
),
|
||||
),
|
||||
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||
iconTint = ElementTheme.colors.textPrimary,
|
||||
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
|
||||
textStyle = ElementTheme.typography.fontBodyLgMedium,
|
||||
iconTint = ElementTheme.colors.iconSuccessPrimary,
|
||||
backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ dependencies {
|
|||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.features.createroom.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.aRecentDirectRoomList
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
|
|
@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
|||
override val values: Sequence<UserListState>
|
||||
get() = sequenceOf(
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = false,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
searchResults = SearchBarResultState.Results(
|
||||
aMatrixUserList()
|
||||
.mapIndexed { index, matrixUser ->
|
||||
|
|
@ -46,6 +47,9 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
|||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = true,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
)
|
||||
),
|
||||
aUserListState(
|
||||
recentDirectRooms = aRecentDirectRoomList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,8 @@
|
|||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -64,21 +62,16 @@ fun AddPeopleView(
|
|||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
UserListView(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding),
|
||||
) {
|
||||
UserListView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
state = state,
|
||||
showBackButton = false,
|
||||
onUserSelected = { },
|
||||
onUserDeselected = {},
|
||||
)
|
||||
}
|
||||
state = state,
|
||||
showBackButton = false,
|
||||
onUserSelected = {},
|
||||
onUserDeselected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,17 +19,27 @@ package io.element.android.features.createroom.impl.components
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.createroom.impl.userlist.UserListEvents
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
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.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.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.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
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.libraries.architecture.AsyncAction
|
||||
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.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -28,7 +31,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
override val values: Sequence<CreateRoomRootState>
|
||||
get() = sequenceOf(
|
||||
aCreateRoomRootState(),
|
||||
aCreateRoomRootState().copy(
|
||||
aCreateRoomRootState(
|
||||
startDmAction = AsyncAction.Loading,
|
||||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
|
|
@ -39,7 +42,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
)
|
||||
}
|
||||
),
|
||||
aCreateRoomRootState().copy(
|
||||
aCreateRoomRootState(
|
||||
startDmAction = AsyncAction.Failure(Throwable("error")),
|
||||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
|
|
@ -50,12 +53,22 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
)
|
||||
}
|
||||
),
|
||||
aCreateRoomRootState(
|
||||
userListState = aUserListState(
|
||||
recentDirectRooms = aRecentDirectRoomList()
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aCreateRoomRootState() = CreateRoomRootState(
|
||||
eventSink = {},
|
||||
applicationName = "Element X Preview",
|
||||
startDmAction = AsyncAction.Uninitialized,
|
||||
userListState = aUserListState(),
|
||||
fun aCreateRoomRootState(
|
||||
applicationName: String = "Element X Preview",
|
||||
userListState: UserListState = aUserListState(),
|
||||
startDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
eventSink: (CreateRoomRootEvents) -> Unit = {},
|
||||
) = CreateRoomRootState(
|
||||
applicationName = applicationName,
|
||||
userListState = userListState,
|
||||
startDmAction = startDmAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.theme.aliasScreenTitle
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
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 kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun CreateRoomRootView(
|
||||
|
|
@ -77,7 +81,11 @@ fun CreateRoomRootView(
|
|||
) {
|
||||
UserListView(
|
||||
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 = {
|
||||
state.eventSink(CreateRoomRootEvents.StartDM(it))
|
||||
},
|
||||
|
|
@ -89,6 +97,7 @@ fun CreateRoomRootView(
|
|||
state = state,
|
||||
onNewRoomClicked = onNewRoomClicked,
|
||||
onInvitePeopleClicked = onInviteFriendsClicked,
|
||||
onDmClicked = onOpenDM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +115,7 @@ fun CreateRoomRootView(
|
|||
onRetry = {
|
||||
state.userListState.selectedUsers.firstOrNull()
|
||||
?.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)
|
||||
},
|
||||
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
||||
|
|
@ -139,18 +148,43 @@ private fun CreateRoomActionButtonsList(
|
|||
state: CreateRoomRootState,
|
||||
onNewRoomClicked: () -> Unit,
|
||||
onInvitePeopleClicked: () -> Unit,
|
||||
onDmClicked: (RoomId) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_plus,
|
||||
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),
|
||||
onClick = onInvitePeopleClicked,
|
||||
)
|
||||
LazyColumn {
|
||||
item {
|
||||
CreateRoomActionButton(
|
||||
iconRes = CompoundDrawables.ic_compound_plus,
|
||||
text = stringResource(id = R.string.screen_create_room_action_create_room),
|
||||
onClick = onNewRoomClicked,
|
||||
)
|
||||
}
|
||||
item {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import dagger.assisted.AssistedFactory
|
|||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
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.UserSearchResult
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -41,6 +44,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
@Assisted val args: UserListPresenterArgs,
|
||||
@Assisted val userRepository: UserRepository,
|
||||
@Assisted val userListDataStore: UserListDataStore,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : UserListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(SessionScope::class)
|
||||
|
|
@ -54,6 +58,10 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun present(): UserListState {
|
||||
var recentDirectRooms by remember { mutableStateOf(emptyList<RecentDirectRoom>()) }
|
||||
LaunchedEffect(Unit) {
|
||||
recentDirectRooms = matrixClient.getRecentDirectRooms()
|
||||
}
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
|
@ -82,6 +90,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
isSearchActive = isSearchActive,
|
||||
showSearchLoader = showSearchLoader,
|
||||
selectionMode = args.selectionMode,
|
||||
recentDirectRooms = recentDirectRooms.toImmutableList(),
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.createroom.impl.userlist
|
||||
|
||||
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.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -28,6 +29,7 @@ data class UserListState(
|
|||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val isSearchActive: Boolean,
|
||||
val selectionMode: SelectionMode,
|
||||
val recentDirectRooms: ImmutableList<RecentDirectRoom>,
|
||||
val eventSink: (UserListEvents) -> Unit,
|
||||
) {
|
||||
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
|
||||
|
|
|
|||
|
|
@ -18,54 +18,82 @@ package io.element.android.features.createroom.impl.userlist
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
||||
override val values: Sequence<UserListState>
|
||||
get() = sequenceOf(
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = false,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aUserListState().copy(isSearchActive = true),
|
||||
aUserListState().copy(isSearchActive = true, searchQuery = "someone"),
|
||||
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
|
||||
aUserListState().copy(
|
||||
aUserListState(isSearchActive = true),
|
||||
aUserListState(isSearchActive = true, searchQuery = "someone"),
|
||||
aUserListState(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Multiple),
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
aUserListState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "something-with-no-results",
|
||||
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(
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchResults = SearchBarResultState.Initial(),
|
||||
selectedUsers = persistentListOf(),
|
||||
selectionMode = SelectionMode.Single,
|
||||
showSearchLoader = false,
|
||||
eventSink = {}
|
||||
fun aUserListState(
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> = SearchBarResultState.Initial(),
|
||||
selectedUsers: List<MatrixUser> = emptyList(),
|
||||
showSearchLoader: Boolean = false,
|
||||
selectionMode: SelectionMode = SelectionMode.Single,
|
||||
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 aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()
|
||||
|
||||
fun aRecentDirectRoomList(
|
||||
count: Int = 5
|
||||
): List<RecentDirectRoom> = aMatrixUserList()
|
||||
.take(count)
|
||||
.map {
|
||||
RecentDirectRoom(RoomId("!aRoom:id"), it)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<string name="screen_create_room_private_option_description">"Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць."</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_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_title">"Стварыце пакой"</string>
|
||||
<string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
|
|||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.aMatrixUserList
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
|
|
@ -45,6 +46,7 @@ class DefaultUserListPresenterTests {
|
|||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -66,6 +68,7 @@ class DefaultUserListPresenterTests {
|
|||
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -87,6 +90,7 @@ class DefaultUserListPresenterTests {
|
|||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -123,6 +127,7 @@ class DefaultUserListPresenterTests {
|
|||
),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -175,6 +180,7 @@ class DefaultUserListPresenterTests {
|
|||
),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -200,6 +206,7 @@ class DefaultUserListPresenterTests {
|
|||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
userRepository,
|
||||
UserListDataStore(),
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -42,6 +42,8 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.features.securebackup.api)
|
||||
implementation(projects.features.verifysession.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.features.lockscreen.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ import io.element.android.anvilannotations.ContributesNode
|
|||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
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.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
|
|
@ -55,7 +56,7 @@ import kotlinx.parcelize.Parcelize
|
|||
class FtueFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val ftueState: DefaultFtueState,
|
||||
private val ftueState: DefaultFtueService,
|
||||
private val analyticsEntryPoint: AnalyticsEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
|
|
@ -72,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
@Parcelize
|
||||
data object Placeholder : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object SessionVerification : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object NotificationsOptIn : NavTarget
|
||||
|
||||
|
|
@ -106,6 +110,14 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
NavTarget.Placeholder -> {
|
||||
createNode<PlaceholderNode>(buildContext)
|
||||
}
|
||||
NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.NotificationsOptIn -> {
|
||||
val callback = object : NotificationsOptInNode.Callback {
|
||||
override fun onNotificationsOptInFinished() {
|
||||
|
|
@ -133,6 +145,9 @@ class FtueFlowNode @AssistedInject constructor(
|
|||
|
||||
private fun moveToNextStep() {
|
||||
when (ftueState.getNextStep()) {
|
||||
FtueStep.SessionVerification -> {
|
||||
backstack.newRoot(NavTarget.SessionVerification)
|
||||
}
|
||||
FtueStep.NotificationsOptIn -> {
|
||||
backstack.newRoot(NavTarget.NotificationsOptIn)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.features.ftue.impl.R
|
||||
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.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.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
|
|
@ -62,8 +64,9 @@ fun NotificationsOptInView(
|
|||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.systemBarsPadding()
|
||||
.statusBarsPadding()
|
||||
.fillMaxSize(),
|
||||
background = { OnboardingBackground() },
|
||||
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp)) },
|
||||
footer = { NotificationsOptInFooter(state) },
|
||||
) {
|
||||
|
|
@ -75,11 +78,11 @@ fun NotificationsOptInView(
|
|||
private fun NotificationsOptInHeader(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
IconTitleSubtitleMolecule(
|
||||
PageTitle(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.screen_notification_optin_title),
|
||||
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
|
||||
iconImageVector = CompoundIcons.NotificationsSolid(),
|
||||
subtitle = stringResource(R.string.screen_notification_optin_subtitle),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.NotificationsSolid()),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,11 @@ import android.Manifest
|
|||
import android.os.Build
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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.lockscreen.api.LockScreenService
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
|
|
@ -35,14 +37,15 @@ import kotlinx.coroutines.runBlocking
|
|||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultFtueState @Inject constructor(
|
||||
class DefaultFtueService @Inject constructor(
|
||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||
coroutineScope: CoroutineScope,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val permissionStateProvider: PermissionStateProvider,
|
||||
private val lockScreenService: LockScreenService,
|
||||
) : FtueState {
|
||||
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : FtueService {
|
||||
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
|
||||
|
||||
override suspend fun reset() {
|
||||
analyticsService.reset()
|
||||
|
|
@ -52,6 +55,10 @@ class DefaultFtueState @Inject constructor(
|
|||
}
|
||||
|
||||
init {
|
||||
sessionVerificationService.needsVerificationFlow
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
.onEach { updateState() }
|
||||
.launchIn(coroutineScope)
|
||||
|
|
@ -59,7 +66,12 @@ class DefaultFtueState @Inject constructor(
|
|||
|
||||
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (shouldAskNotificationPermissions()) {
|
||||
null -> if (isSessionNotVerified()) {
|
||||
FtueStep.SessionVerification
|
||||
} else {
|
||||
getNextStep(FtueStep.SessionVerification)
|
||||
}
|
||||
FtueStep.SessionVerification -> if (shouldAskNotificationPermissions()) {
|
||||
FtueStep.NotificationsOptIn
|
||||
} else {
|
||||
getNextStep(FtueStep.NotificationsOptIn)
|
||||
|
|
@ -79,12 +91,17 @@ class DefaultFtueState @Inject constructor(
|
|||
|
||||
private fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf(
|
||||
{ isSessionNotVerified() },
|
||||
{ shouldAskNotificationPermissions() },
|
||||
{ needsAnalyticsOptIn() },
|
||||
{ shouldDisplayLockscreenSetup() },
|
||||
).any { it() }
|
||||
}
|
||||
|
||||
private fun isSessionNotVerified(): Boolean {
|
||||
return sessionVerificationService.needsVerificationFlow.value
|
||||
}
|
||||
|
||||
private fun needsAnalyticsOptIn(): Boolean {
|
||||
// We need this function to not be suspend, so we need to load the value through runBlocking
|
||||
return runBlocking { analyticsService.didAskUserConsent().first().not() }
|
||||
|
|
@ -109,11 +126,15 @@ class DefaultFtueState @Inject constructor(
|
|||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal fun updateState() {
|
||||
shouldDisplayFlow.value = isAnyStepIncomplete()
|
||||
state.value = when {
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
data object SessionVerification : FtueStep
|
||||
data object NotificationsOptIn : FtueStep
|
||||
data object AnalyticsOptIn : FtueStep
|
||||
data object LockscreenSetup : FtueStep
|
||||
|
|
@ -2,6 +2,13 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</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_2">"Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад."</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_notification_optin_subtitle">"Вы можете изменить настройки позже."</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_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
|
||||
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
<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_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_2">"Message history for encrypted rooms isn’t available yet."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,15 @@
|
|||
package io.element.android.features.ftue.impl
|
||||
|
||||
import android.os.Build
|
||||
import app.cash.turbine.test
|
||||
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.lockscreen.api.LockScreenService
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
|
|
@ -32,38 +36,51 @@ import kotlinx.coroutines.cancel
|
|||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultFtueStateTests {
|
||||
class DefaultFtueServiceTests {
|
||||
@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 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
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
@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 sessionVerificationService = FakeSessionVerificationService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
state.updateState()
|
||||
|
||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||
assertThat(state.state.value).isEqualTo(FtueState.Complete)
|
||||
|
||||
// Cleanup
|
||||
coroutineScope.cancel()
|
||||
|
|
@ -71,6 +88,10 @@ class DefaultFtueStateTests {
|
|||
|
||||
@Test
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
givenNeedsVerification(true)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
|
|
@ -78,12 +99,17 @@ class DefaultFtueStateTests {
|
|||
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
val steps = mutableListOf<FtueStep?>()
|
||||
|
||||
// Session verification
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenNeedsVerification(false)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
|
|
@ -100,6 +126,7 @@ class DefaultFtueStateTests {
|
|||
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||
|
||||
assertThat(steps).containsExactly(
|
||||
FtueStep.SessionVerification,
|
||||
FtueStep.NotificationsOptIn,
|
||||
FtueStep.LockscreenSetup,
|
||||
FtueStep.AnalyticsOptIn,
|
||||
|
|
@ -114,17 +141,20 @@ class DefaultFtueStateTests {
|
|||
@Test
|
||||
fun `if a check for a step is true, start from the next one`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
val state = createState(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
// Skip first 2 steps
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
|
|
@ -140,16 +170,19 @@ class DefaultFtueStateTests {
|
|||
@Test
|
||||
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
|
||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val sessionVerificationService = FakeSessionVerificationService()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val lockScreenService = FakeLockScreenService()
|
||||
|
||||
val state = createState(
|
||||
sdkIntVersion = Build.VERSION_CODES.M,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
coroutineScope = coroutineScope,
|
||||
analyticsService = analyticsService,
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
|
@ -163,14 +196,16 @@ class DefaultFtueStateTests {
|
|||
|
||||
private fun createState(
|
||||
coroutineScope: CoroutineScope,
|
||||
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
||||
lockScreenService: LockScreenService = FakeLockScreenService(),
|
||||
// First version where notification permission is required
|
||||
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
|
||||
) = DefaultFtueState(
|
||||
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||
) = DefaultFtueService(
|
||||
coroutineScope = coroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||
analyticsService = analyticsService,
|
||||
permissionStateProvider = permissionStateProvider,
|
||||
lockScreenService = lockScreenService,
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_empty_list">"Няма запрашэнняў"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў вас"</string>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="leave_conversation_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэту размову? Гэта размова не з\'яўляецца публічнай, і вы не зможаце далучыцца зноў без запрашэння."</string>
|
||||
<string name="leave_room_alert_empty_subtitle">"Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."</string>
|
||||
<string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што жадаеце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string>
|
||||
<string name="leave_room_alert_subtitle">"Вы ўпэўнены, што жадаеце пакінуць пакой?"</string>
|
||||
<string name="leave_room_alert_empty_subtitle">"Вы ўпэўнены, што хочаце пакінуць гэты пакой? Вы тут адзіны карыстальнік. Калі вы выйдзеце, ніхто не зможа далучыцца ў будучыні, у тым ліку і вы."</string>
|
||||
<string name="leave_room_alert_private_subtitle">"Вы ўпэўнены, што жхочаце пакінуць гэты пакой? Гэты пакой не агульнадаступны, і вы не зможаце далучыцца да яго зноў без запрашэння."</string>
|
||||
<string name="leave_room_alert_subtitle">"Вы ўпэўнены, што хочаце пакінуць пакой?"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
<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_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_subtitle">"Biztos, hogy elhagyod a szobát?"</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 elhagyja a szobát?"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<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_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_setup_biometric_unlock_allow_title">"Дазволіць %1$s"</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>
|
||||
<plurals name="screen_app_lock_subtitle">
|
||||
<item quantity="one">"У вас %1$d спроба разблакіроўкі"</item>
|
||||
<item quantity="few">"У вас %1$d спроб разблакіроўкі"</item>
|
||||
<item quantity="few">"У вас %1$d спробы разблакіроўкі"</item>
|
||||
<item quantity="many">"У вас %1$d спроб разблакіроўкі"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||
<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>
|
||||
</plurals>
|
||||
<string name="screen_app_lock_use_biometric_android">"Выкарыстоўваць біяметрыю"</string>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_title">"Выйсці"</string>
|
||||
<string name="screen_signout_in_progress_dialog_content">"Выхад…"</string>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@
|
|||
|
||||
package io.element.android.features.messages.impl
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
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.TimelineItemPresenterFactories
|
||||
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.di.RoomScope
|
||||
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.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.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
|
|
@ -52,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
presenterFactory: MessagesPresenter.Factory,
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callback = plugins<Callback>().firstOrNull()
|
||||
|
|
@ -96,6 +103,25 @@ class MessagesNode @AssistedInject constructor(
|
|||
private fun onUserDataClicked(userId: 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) {
|
||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||
}
|
||||
|
|
@ -126,6 +152,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
CompositionLocalProvider(
|
||||
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
|
||||
) {
|
||||
|
|
@ -137,6 +164,7 @@ class MessagesNode @AssistedInject constructor(
|
|||
onEventClicked = this::onEventClicked,
|
||||
onPreviewAttachments = this::onPreviewAttachments,
|
||||
onUserDataClicked = this::onUserDataClicked,
|
||||
onLinkClicked = { onLinkClicked(context, it) },
|
||||
onSendLocationClicked = this::onSendLocationClicked,
|
||||
onCreatePollClicked = this::onCreatePollClicked,
|
||||
onJoinCallClicked = this::onJoinCallClicked,
|
||||
|
|
|
|||
|
|
@ -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.canSendMessageAsState
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -273,6 +274,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
) = launch {
|
||||
when (action) {
|
||||
TimelineItemAction.Copy -> handleCopyContents(targetEvent)
|
||||
TimelineItemAction.CopyLink -> handleCopyLink(targetEvent)
|
||||
TimelineItemAction.Redact -> handleActionRedact(targetEvent)
|
||||
TimelineItemAction.Edit -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
|
||||
TimelineItemAction.Reply,
|
||||
|
|
@ -435,6 +437,20 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
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) {
|
||||
val content = when (event.content) {
|
||||
is TimelineItemTextBasedContent -> event.content.body
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ fun MessagesView(
|
|||
onRoomDetailsClicked: () -> Unit,
|
||||
onEventClicked: (event: TimelineItem.Event) -> Boolean,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
|
||||
onSendLocationClicked: () -> Unit,
|
||||
onCreatePollClicked: () -> Unit,
|
||||
|
|
@ -213,6 +214,7 @@ fun MessagesView(
|
|||
onMessageClicked = ::onMessageClicked,
|
||||
onMessageLongClicked = ::onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = { event ->
|
||||
if (event.localSendState is LocalEventSendState.SendingFailed) {
|
||||
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
|
||||
|
|
@ -313,6 +315,7 @@ private fun MessagesViewContent(
|
|||
state: MessagesState,
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -386,6 +389,7 @@ private fun MessagesViewContent(
|
|||
onMessageClicked = onMessageClicked,
|
||||
onMessageLongClicked = onMessageLongClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClicked = onReactionClicked,
|
||||
onReactionLongClicked = onReactionLongClicked,
|
||||
|
|
@ -570,6 +574,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
|||
onEventClicked = { false },
|
||||
onPreviewAttachments = {},
|
||||
onUserDataClicked = {},
|
||||
onLinkClicked = {},
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
onJoinCallClicked = {},
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ class ActionListPresenter @Inject constructor(
|
|||
is TimelineItemStateContent -> {
|
||||
buildList {
|
||||
add(TimelineItemAction.Copy)
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -119,6 +120,7 @@ class ActionListPresenter @Inject constructor(
|
|||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -136,6 +138,7 @@ class ActionListPresenter @Inject constructor(
|
|||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -176,6 +179,7 @@ class ActionListPresenter @Inject constructor(
|
|||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ fun aTimelineItemActionList(): ImmutableList<TimelineItemAction> {
|
|||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Redact,
|
||||
TimelineItemAction.ReportContent,
|
||||
|
|
@ -146,6 +147,7 @@ fun aTimelineItemPollActionList(): ImmutableList<TimelineItemAction> {
|
|||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ sealed class TimelineItemAction(
|
|||
) {
|
||||
data object Forward : TimelineItemAction(CommonStrings.action_forward, CompoundDrawables.ic_compound_forward)
|
||||
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 Reply : TimelineItemAction(CommonStrings.action_reply, CompoundDrawables.ic_compound_reply)
|
||||
data object ReplyInThread : TimelineItemAction(CommonStrings.action_reply_in_thread, CompoundDrawables.ic_compound_reply)
|
||||
|
|
|
|||
|
|
@ -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.UserId
|
||||
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.Mention
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
|
|
@ -96,6 +97,8 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val messageComposerContext: MessageComposerContextImpl,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<MessageComposerState> {
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
|
||||
|
|
@ -334,7 +337,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
is MentionSuggestion.Member -> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -345,6 +348,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
|
||||
return MessageComposerState(
|
||||
richTextEditorState = richTextEditorState,
|
||||
permalinkParser = permalinkParser,
|
||||
isFullScreen = isFullScreen.value,
|
||||
mode = messageComposerContext.composerMode,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
|
|
|
|||
|
|
@ -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.mentions.MentionSuggestion
|
||||
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.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -28,6 +29,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
@Stable
|
||||
data class MessageComposerState(
|
||||
val richTextEditorState: RichTextEditorState,
|
||||
val permalinkParser: PermalinkParser,
|
||||
val isFullScreen: Boolean,
|
||||
val mode: MessageComposerMode,
|
||||
val showAttachmentSourcePicker: Boolean,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,12 @@
|
|||
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.wysiwyg.compose.RichTextEditorState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -43,6 +46,10 @@ fun aMessageComposerState(
|
|||
memberSuggestions: ImmutableList<MentionSuggestion> = persistentListOf(),
|
||||
) = MessageComposerState(
|
||||
richTextEditorState = richTextEditorState,
|
||||
permalinkParser = object : PermalinkParser {
|
||||
override fun parse(uriString: String): PermalinkData = TODO()
|
||||
override fun parse(uri: Uri): PermalinkData = TODO()
|
||||
},
|
||||
isFullScreen = isFullScreen,
|
||||
mode = mode,
|
||||
showTextFormatting = showTextFormatting,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ internal fun MessageComposerView(
|
|||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
voiceMessageState = voiceMessageState.voiceMessageState,
|
||||
permalinkParser = state.permalinkParser,
|
||||
subcomposing = subcomposing,
|
||||
onRequestFocus = ::onRequestFocus,
|
||||
onSendMessage = ::sendMessage,
|
||||
|
|
|
|||
|
|
@ -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.SingleIn
|
||||
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.mentions.rememberMentionSpanProvider
|
||||
import io.element.android.wysiwyg.compose.StyledHtmlConverter
|
||||
|
|
@ -39,7 +40,9 @@ import javax.inject.Inject
|
|||
|
||||
@ContributesBinding(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)
|
||||
|
||||
@Composable
|
||||
|
|
@ -50,7 +53,10 @@ class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider
|
|||
}
|
||||
|
||||
val editorStyle = ElementRichTextEditorStyle.textStyle()
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(
|
||||
currentUserId = currentUserId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
|
|
|
|||
|
|
@ -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.model.NewEventState
|
||||
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.poll.api.actions.EndPollAction
|
||||
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.core.coroutine.CoroutineDispatchers
|
||||
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.MessageEventType
|
||||
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.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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -68,8 +63,6 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
private val appScope: CoroutineScope,
|
||||
@Assisted private val navigator: MessagesNavigator,
|
||||
private val verificationService: SessionVerificationService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val redactedVoiceMessageManager: RedactedVoiceMessageManager,
|
||||
private val sendPollResponseAction: SendPollResponseAction,
|
||||
private val endPollAction: EndPollAction,
|
||||
|
|
@ -101,21 +94,9 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
|
||||
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 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) {
|
||||
when (event) {
|
||||
TimelineEvents.LoadMore -> localScope.paginateBackwards()
|
||||
|
|
@ -184,7 +165,6 @@ class TimelinePresenter @AssistedInject constructor(
|
|||
timelineItems = timelineItems,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
newEventState = newItemState.value,
|
||||
sessionState = sessionState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline
|
|||
import androidx.compose.runtime.Immutable
|
||||
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.session.SessionState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -32,7 +31,6 @@ data class TimelineState(
|
|||
val highlightedEventId: EventId?,
|
||||
val paginationState: MatrixTimeline.PaginationState,
|
||||
val newEventState: NewEventState,
|
||||
val sessionState: SessionState,
|
||||
val eventSink: (TimelineEvents) -> Unit
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.aTimelineItemTextContent
|
||||
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.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -58,10 +57,6 @@ fun aTimelineState(
|
|||
renderReadReceipts = renderReadReceipts,
|
||||
highlightedEventId = null,
|
||||
newEventState = NewEventState.None,
|
||||
sessionState = aSessionState(
|
||||
isSessionVerified = true,
|
||||
isKeyBackupEnabled = true,
|
||||
),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fun TimelineView(
|
|||
typingNotificationState: TypingNotificationState,
|
||||
roomName: String?,
|
||||
onUserDataClicked: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -140,13 +141,13 @@ fun TimelineView(
|
|||
onClick = onMessageClicked,
|
||||
onLongClick = onMessageLongClicked,
|
||||
onUserDataClick = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = ::inReplyToClicked,
|
||||
onReactionClick = onReactionClicked,
|
||||
onReactionLongClick = onReactionLongClicked,
|
||||
onMoreReactionsClick = onMoreReactionsClicked,
|
||||
onReadReceiptClick = onReadReceiptClick,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
sessionState = state.sessionState,
|
||||
eventSink = state.eventSink,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
)
|
||||
|
|
@ -276,6 +277,7 @@ internal fun TimelineViewPreview(
|
|||
onMessageClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onUserDataClicked = {},
|
||||
onLinkClicked = {},
|
||||
onMessageLongClicked = {},
|
||||
onReactionClicked = { _, _ -> },
|
||||
onReactionLongClicked = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ internal fun ATimelineItemEventRow(
|
|||
onClick = {},
|
||||
onLongClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClicked = {},
|
||||
inReplyToClick = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
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.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
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.canBeRepliedTo
|
||||
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.components.EqualWidthColumn
|
||||
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.matrix.api.core.EventId
|
||||
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.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -128,6 +122,7 @@ fun TimelineItemEventRow(
|
|||
isHighlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -151,13 +146,6 @@ fun TimelineItemEventRow(
|
|||
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()) {
|
||||
if (event.groupPosition.isNew()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -203,7 +191,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onMentionClicked = ::onMentionClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -222,7 +210,7 @@ fun TimelineItemEventRow(
|
|||
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||
onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) },
|
||||
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||
onMentionClicked = ::onMentionClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -278,7 +266,7 @@ private fun TimelineItemEventRowContent(
|
|||
onReactionClicked: (emoji: String) -> Unit,
|
||||
onReactionLongClicked: (emoji: String) -> Unit,
|
||||
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||
onMentionClicked: (Mention) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -346,7 +334,7 @@ private fun TimelineItemEventRowContent(
|
|||
onTimestampClicked = {
|
||||
onTimestampClicked(event)
|
||||
},
|
||||
onMentionClicked = onMentionClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
|
@ -429,7 +417,7 @@ private fun MessageEventBubbleContent(
|
|||
@Suppress("UNUSED_PARAMETER")
|
||||
inReplyToClick: () -> Unit,
|
||||
onTimestampClicked: () -> Unit,
|
||||
onMentionClicked: (Mention) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit,
|
||||
@SuppressLint("ModifierParameter")
|
||||
// need to rename this modifier to prevent linter false positives
|
||||
|
|
@ -530,7 +518,6 @@ private fun MessageEventBubbleContent(
|
|||
modifier: Modifier = Modifier,
|
||||
canShrinkContent: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val timestampLayoutModifier: Modifier
|
||||
val contentModifier: Modifier
|
||||
when {
|
||||
|
|
@ -566,20 +553,7 @@ private fun MessageEventBubbleContent(
|
|||
) { onContentLayoutChanged ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClicked = { url ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLinkClicked = onLinkClicked,
|
||||
eventSink = eventSink,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = contentModifier
|
||||
|
|
|
|||
|
|
@ -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.TimelineItemReadReceiptView
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -46,11 +44,11 @@ fun TimelineItemGroupedEventsRow(
|
|||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
sessionState: SessionState,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
|
|
@ -73,11 +71,11 @@ fun TimelineItemGroupedEventsRow(
|
|||
highlightedItem = highlightedItem,
|
||||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
|
|
@ -97,11 +95,11 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
highlightedItem: String?,
|
||||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
sessionState: SessionState,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
onReactionLongClick: (key: String, TimelineItem.Event) -> Unit,
|
||||
|
|
@ -130,11 +128,11 @@ private fun TimelineItemGroupedEventsRowContent(
|
|||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
|
|
@ -170,11 +168,11 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi
|
|||
highlightedItem = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
sessionState = aSessionState(),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
inReplyToClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
|
|
@ -195,11 +193,11 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi
|
|||
highlightedItem = null,
|
||||
renderReadReceipts = true,
|
||||
isLastOutgoingMessage = false,
|
||||
sessionState = aSessionState(),
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
inReplyToClick = {},
|
||||
onUserDataClick = {},
|
||||
onLinkClicked = {},
|
||||
onTimestampClicked = {},
|
||||
onReactionClick = { _, _ -> },
|
||||
onReactionLongClick = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -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.model.AggregatedReaction
|
||||
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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -166,6 +167,7 @@ internal fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview {
|
|||
)
|
||||
}
|
||||
|
||||
@ExcludeFromCoverage
|
||||
@Composable
|
||||
private fun ContentToPreview(
|
||||
reactions: ImmutableList<AggregatedReaction>,
|
||||
|
|
|
|||
|
|
@ -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.event.TimelineItemLegacyCallInviteContent
|
||||
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.UserId
|
||||
|
||||
|
|
@ -34,8 +33,8 @@ internal fun TimelineItemRow(
|
|||
renderReadReceipts: Boolean,
|
||||
isLastOutgoingMessage: Boolean,
|
||||
highlightedItem: String?,
|
||||
sessionState: SessionState,
|
||||
onUserDataClick: (UserId) -> Unit,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
onClick: (TimelineItem.Event) -> Unit,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
inReplyToClick: (EventId) -> Unit,
|
||||
|
|
@ -52,7 +51,6 @@ internal fun TimelineItemRow(
|
|||
is TimelineItem.Virtual -> {
|
||||
TimelineItemVirtualRow(
|
||||
virtual = timelineItem,
|
||||
sessionState = sessionState,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -79,6 +77,7 @@ internal fun TimelineItemRow(
|
|||
onClick = { onClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
|
|
@ -98,11 +97,11 @@ internal fun TimelineItemRow(
|
|||
renderReadReceipts = renderReadReceipts,
|
||||
isLastOutgoingMessage = isLastOutgoingMessage,
|
||||
highlightedItem = highlightedItem,
|
||||
sessionState = sessionState,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
inReplyToClick = inReplyToClick,
|
||||
onUserDataClick = onUserDataClick,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onTimestampClicked = onTimestampClicked,
|
||||
onReactionClick = onReactionClick,
|
||||
onReactionLongClick = onReactionLongClick,
|
||||
|
|
|
|||
|
|
@ -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.TimelineItemEncryptedHistoryBannerVirtualModel
|
||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
|
||||
import io.element.android.features.messages.impl.timeline.session.SessionState
|
||||
|
||||
@Composable
|
||||
fun TimelineItemVirtualRow(
|
||||
virtual: TimelineItem.Virtual,
|
||||
sessionState: SessionState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when (virtual.model) {
|
||||
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
|
||||
TimelineItemReadMarkerModel -> TimelineItemReadMarkerView()
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier)
|
||||
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
|||
|
||||
sealed interface RetrySendMenuEvents {
|
||||
data class EventSelected(val event: TimelineItem.Event) : RetrySendMenuEvents
|
||||
data object RetrySend : RetrySendMenuEvents
|
||||
data object RemoveFailed : RetrySendMenuEvents
|
||||
data object Retry : RetrySendMenuEvents
|
||||
data object Remove : RetrySendMenuEvents
|
||||
data object Dismiss : RetrySendMenuEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
|||
is RetrySendMenuEvents.EventSelected -> {
|
||||
selectedEvent = event.event
|
||||
}
|
||||
RetrySendMenuEvents.RetrySend -> {
|
||||
RetrySendMenuEvents.Retry -> {
|
||||
coroutineScope.launch {
|
||||
selectedEvent?.transactionId?.let { transactionId ->
|
||||
room.retrySendMessage(transactionId)
|
||||
|
|
@ -49,7 +49,7 @@ class RetrySendMenuPresenter @Inject constructor(
|
|||
selectedEvent = null
|
||||
}
|
||||
}
|
||||
RetrySendMenuEvents.RemoveFailed -> {
|
||||
RetrySendMenuEvents.Remove -> {
|
||||
coroutineScope.launch {
|
||||
selectedEvent?.transactionId?.let { transactionId ->
|
||||
room.cancelSend(transactionId)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.retrysendmenu
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -54,18 +53,18 @@ internal fun RetrySendMessageMenu(
|
|||
}
|
||||
|
||||
fun onRetry() {
|
||||
state.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
state.eventSink(RetrySendMenuEvents.Retry)
|
||||
}
|
||||
|
||||
fun onRemoveFailed() {
|
||||
state.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
fun onRemove() {
|
||||
state.eventSink(RetrySendMenuEvents.Remove)
|
||||
}
|
||||
|
||||
RetrySendMessageMenuBottomSheet(
|
||||
modifier = modifier,
|
||||
isVisible = isVisible,
|
||||
onRetry = ::onRetry,
|
||||
onRemoveFailed = ::onRemoveFailed,
|
||||
onRemove = ::onRemove,
|
||||
onDismiss = ::onDismiss
|
||||
)
|
||||
}
|
||||
|
|
@ -75,7 +74,7 @@ internal fun RetrySendMessageMenu(
|
|||
private fun RetrySendMessageMenuBottomSheet(
|
||||
isVisible: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onRemoveFailed: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
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
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
|
|
@ -106,7 +108,7 @@ private fun RetrySendMessageMenuBottomSheet(
|
|||
@Composable
|
||||
private fun ColumnScope.RetrySendMenuContents(
|
||||
onRetry: () -> Unit,
|
||||
onRemoveFailed: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -142,22 +144,16 @@ private fun ColumnScope.RetrySendMenuContents(
|
|||
modifier = Modifier.clickable {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
onRemoveFailed()
|
||||
onRemove()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RetrySendMessageMenuPreview(@PreviewParameter(RetrySendMenuStateProvider::class) state: RetrySendMenuState) = ElementPreview {
|
||||
// TODO restore RetrySendMessageMenuBottomSheet once the issue with bottom sheet not being previewable is fixed
|
||||
Column {
|
||||
RetrySendMenuContents(
|
||||
onRetry = {},
|
||||
onRemoveFailed = {},
|
||||
)
|
||||
}
|
||||
RetrySendMessageMenu(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.virtual
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -29,20 +28,16 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
|
||||
@Composable
|
||||
fun TimelineEncryptedHistoryBannerView(
|
||||
sessionState: SessionState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -61,26 +56,15 @@ fun TimelineEncryptedHistoryBannerView(
|
|||
tint = ElementTheme.colors.iconInfoPrimary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(sessionState.toStringResId()),
|
||||
text = stringResource(R.string.screen_room_encrypted_history_banner),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
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
|
||||
@Composable
|
||||
internal fun EncryptedHistoryBannerViewPreview(
|
||||
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
|
||||
) = ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
|
||||
internal fun EncryptedHistoryBannerViewPreview() = ElementPreview {
|
||||
TimelineEncryptedHistoryBannerView()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.FeatureFlags
|
||||
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.EmoteMessageType
|
||||
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 featureFlagService: FeatureFlagService,
|
||||
private val htmlConverterProvider: HtmlConverterProvider,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) {
|
||||
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
|
||||
return when (val messageType = content.type) {
|
||||
|
|
@ -74,7 +76,10 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
val emoteBody = "* $senderDisplayName ${messageType.body.trimEnd()}"
|
||||
TimelineItemEmoteContent(
|
||||
body = emoteBody,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(
|
||||
permalinkParser = permalinkParser,
|
||||
prefix = "* $senderDisplayName",
|
||||
),
|
||||
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName") ?: emoteBody.withLinks(),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
|
|
@ -197,7 +202,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
val body = messageType.body.trimEnd()
|
||||
TimelineItemNoticeContent(
|
||||
body = body,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
|
||||
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
|
|
@ -206,7 +211,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
|||
val body = messageType.body.trimEnd()
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(),
|
||||
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
|
||||
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.AvatarSize
|
||||
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.timeline.MatrixTimelineItem
|
||||
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 matrixClient: MatrixClient,
|
||||
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) {
|
||||
suspend fun create(
|
||||
currentTimelineItem: MatrixTimelineItem.Event,
|
||||
|
|
@ -80,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
|
|||
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||
readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers),
|
||||
localSendState = currentTimelineItem.event.localSendState,
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(),
|
||||
inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser),
|
||||
isThreaded = currentTimelineItem.event.isThreaded(),
|
||||
debugInfo = currentTimelineItem.event.debugInfo,
|
||||
origin = currentTimelineItem.event.origin,
|
||||
|
|
|
|||
|
|
@ -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.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.InReplyTo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
|
||||
|
|
@ -34,7 +35,9 @@ data class InReplyToDetails(
|
|||
val textContent: String?,
|
||||
)
|
||||
|
||||
fun InReplyTo.map() = when (this) {
|
||||
fun InReplyTo.map(
|
||||
permalinkParser: PermalinkParser,
|
||||
) = when (this) {
|
||||
is InReplyTo.Ready -> InReplyToDetails(
|
||||
eventId = eventId,
|
||||
senderId = senderId,
|
||||
|
|
@ -44,7 +47,7 @@ fun InReplyTo.map() = when (this) {
|
|||
textContent = when (content) {
|
||||
is MessageContent -> {
|
||||
val messageContent = content as MessageContent
|
||||
(messageContent.type as? TextMessageType)?.toPlainText() ?: messageContent.body
|
||||
(messageContent.type as? TextMessageType)?.toPlainText(permalinkParser = permalinkParser) ?: messageContent.body
|
||||
}
|
||||
is StickerContent -> {
|
||||
val stickerContent = content as StickerContent
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -35,6 +35,7 @@ internal fun MessagesViewWithTypingPreview(
|
|||
onEventClicked = { false },
|
||||
onPreviewAttachments = {},
|
||||
onUserDataClicked = {},
|
||||
onLinkClicked = {},
|
||||
onSendLocationClicked = {},
|
||||
onCreatePollClicked = {},
|
||||
onJoinCallClicked = {},
|
||||
|
|
|
|||
|
|
@ -5,24 +5,24 @@
|
|||
<string name="emoji_picker_category_foods">"Ежа & Напоі"</string>
|
||||
<string name="emoji_picker_category_nature">"Жывёлы & Прырода"</string>
|
||||
<string name="emoji_picker_category_objects">"Аб\'екты"</string>
|
||||
<string name="emoji_picker_category_people">"Усмешкі & Людзі"</string>
|
||||
<string name="emoji_picker_category_people">"Усмешкі & Удзельнікі"</string>
|
||||
<string name="emoji_picker_category_places">"Падарожжы & Месцы"</string>
|
||||
<string name="emoji_picker_category_symbols">"Сімвалы"</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_hint">"Прычына, па якой вы паскардзіліся на гэты змест"</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_video">"Запісаць відэа"</string>
|
||||
<string name="screen_room_attachment_source_files">"Далучэнне"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Фота & Відэа Бібліятэка"</string>
|
||||
<string name="screen_room_attachment_source_gallery">"Бібліятэка фота & відэа"</string>
|
||||
<string name="screen_room_attachment_source_location">"Месцазнаходжанне"</string>
|
||||
<string name="screen_room_attachment_source_poll">"Апытанне"</string>
|
||||
<string name="screen_room_attachment_text_formatting">"Фармаціраванне тэксту"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Гісторыя паведамленняў зараз недаступна."</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_mentions_at_room_subtitle">"Апавясціць увесь пакой"</string>
|
||||
<string name="screen_room_mentions_at_room_title">"Усе"</string>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
<string name="screen_room_timeline_read_marker_title">"Новы"</string>
|
||||
<plurals name="screen_room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d змена ў пакоі"</item>
|
||||
<item quantity="few">"%1$d змен у пакоі"</item>
|
||||
<item quantity="few">"%1$d змены ў пакоі"</item>
|
||||
<item quantity="many">"%1$d змен у пакоі"</item>
|
||||
</plurals>
|
||||
<plurals name="screen_room_typing_many_members">
|
||||
|
|
|
|||
|
|
@ -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.FakeMatrixClient
|
||||
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.aRoomInfo
|
||||
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.mediaplayer.test.FakeMediaPlayer
|
||||
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
|
||||
fun `present - handle action reply`() = runTest {
|
||||
val presenter = createMessagesPresenter()
|
||||
|
|
@ -725,6 +746,8 @@ class MessagesPresenterTest {
|
|||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
)
|
||||
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
|
||||
this,
|
||||
|
|
@ -741,8 +764,6 @@ class MessagesPresenterTest {
|
|||
dispatchers = coroutineDispatchers,
|
||||
appScope = this,
|
||||
navigator = navigator,
|
||||
encryptionService = FakeEncryptionService(),
|
||||
verificationService = FakeSessionVerificationService(),
|
||||
redactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = FakeSendPollResponseAction(),
|
||||
|
|
|
|||
|
|
@ -478,6 +478,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(),
|
||||
onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onSendLocationClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onCreatePollClicked: () -> Unit = EnsureNeverCalled(),
|
||||
|
|
@ -492,6 +493,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
onRoomDetailsClicked = onRoomDetailsClicked,
|
||||
onEventClicked = onEventClicked,
|
||||
onUserDataClicked = onUserDataClicked,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onPreviewAttachments = onPreviewAttachments,
|
||||
onSendLocationClicked = onSendLocationClicked,
|
||||
onCreatePollClicked = onCreatePollClicked,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
|
|
@ -193,6 +194,7 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
|
|
@ -232,6 +234,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -272,6 +275,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
|
|
@ -315,6 +319,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -357,6 +362,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
)
|
||||
|
|
@ -396,6 +402,7 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
|
|
@ -435,6 +442,7 @@ class ActionListPresenterTest {
|
|||
displayEmojiReactions = false,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
)
|
||||
|
|
@ -473,6 +481,7 @@ class ActionListPresenterTest {
|
|||
displayEmojiReactions = false,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -513,6 +522,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -595,6 +605,7 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -632,6 +643,7 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -668,6 +680,7 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.EndPoll,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -703,6 +716,7 @@ class ActionListPresenterTest {
|
|||
displayEmojiReactions = true,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
@ -738,6 +752,7 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Forward,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.matrix.api.timeline.item.event.EventTimelineItem
|
||||
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.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
|
@ -57,6 +58,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
|||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
),
|
||||
redactedMessageFactory = TimelineItemContentRedactedFactory(),
|
||||
stickerFactory = TimelineItemContentStickerFactory(
|
||||
|
|
@ -73,6 +75,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
|
|||
),
|
||||
matrixClient = matrixClient,
|
||||
lastMessageTimestampFormatter = FakeLastMessageTimestampFormatter(),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
),
|
||||
virtualItemFactory = TimelineItemVirtualFactory(
|
||||
daySeparatorFactory = TimelineItemDaySeparatorFactory(
|
||||
|
|
|
|||
|
|
@ -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.media.ImageInfo
|
||||
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.MatrixRoomMembersState
|
||||
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_NAME
|
||||
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.aRoomMember
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
|
|
@ -805,7 +808,14 @@ class MessageComposerPresenterTest {
|
|||
|
||||
@Test
|
||||
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) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
|
|
@ -941,6 +951,7 @@ class MessageComposerPresenterTest {
|
|||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
|
||||
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder()
|
||||
) = MessageComposerPresenter(
|
||||
coroutineScope,
|
||||
room,
|
||||
|
|
@ -955,6 +966,8 @@ class MessageComposerPresenterTest {
|
|||
TestRichTextEditorStateFactory(),
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
)
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
|||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -32,7 +33,9 @@ class DefaultHtmlConverterProviderTest {
|
|||
|
||||
@Test
|
||||
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()
|
||||
|
||||
|
|
@ -41,7 +44,9 @@ class DefaultHtmlConverterProviderTest {
|
|||
|
||||
@Test
|
||||
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||
val provider = DefaultHtmlConverterProvider()
|
||||
val provider = DefaultHtmlConverterProvider(
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
)
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||
provider.Update(currentUserId = A_USER_ID)
|
||||
|
|
|
|||
|
|
@ -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.model.NewEventState
|
||||
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.RedactedVoiceMessageManager
|
||||
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_2
|
||||
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.aRoomMember
|
||||
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.anEventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||
|
|
@ -89,7 +86,6 @@ class TimelinePresenterTest {
|
|||
assertThat(initialState.timelineItems).isEmpty()
|
||||
val loadedNoTimelineState = awaitItem()
|
||||
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
|
||||
assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,8 +508,6 @@ class TimelinePresenterTest {
|
|||
dispatchers = testCoroutineDispatchers(),
|
||||
appScope = this,
|
||||
navigator = messagesNavigator,
|
||||
encryptionService = FakeEncryptionService(),
|
||||
verificationService = FakeSessionVerificationService(),
|
||||
redactedVoiceMessageManager = redactedVoiceMessageManager,
|
||||
endPollAction = endPollAction,
|
||||
sendPollResponseAction = sendPollResponseAction,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TimelineViewTest {
|
|||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
|
|
@ -72,6 +73,7 @@ class TimelineViewTest {
|
|||
typingNotificationState = aTypingNotificationState(),
|
||||
roomName = null,
|
||||
onUserDataClicked = EnsureNeverCalledWithParam(),
|
||||
onLinkClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageClicked = EnsureNeverCalledWithParam(),
|
||||
onMessageLongClicked = EnsureNeverCalledWithParam(),
|
||||
onTimestampClicked = EnsureNeverCalledWithParam(),
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ class RetrySendMenuPresenterTests {
|
|||
val initialState = awaitItem()
|
||||
val selectedEvent = aTimelineItemEvent()
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
|
||||
assertThat(awaitItem().selectedEvent).isSameInstanceAs(selectedEvent)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,8 +56,9 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent()
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.Dismiss)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -72,8 +72,8 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(1)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
|
|
@ -88,8 +88,8 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = null)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
|
|
@ -105,8 +105,8 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RetrySend)
|
||||
initialState.eventSink(RetrySendMenuEvents.Retry)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(1)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
|
|
@ -121,9 +121,9 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(1)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -137,9 +137,9 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = null)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(0)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -154,9 +154,9 @@ class RetrySendMenuPresenterTests {
|
|||
val selectedEvent = aTimelineItemEvent(transactionId = A_TRANSACTION_ID)
|
||||
initialState.eventSink(RetrySendMenuEvents.EventSelected(selectedEvent))
|
||||
skipItems(1)
|
||||
|
||||
initialState.eventSink(RetrySendMenuEvents.RemoveFailed)
|
||||
initialState.eventSink(RetrySendMenuEvents.Remove)
|
||||
assertThat(room.cancelSendCount).isEqualTo(1)
|
||||
assertThat(room.retrySendMessageCount).isEqualTo(0)
|
||||
assertThat(awaitItem().selectedEvent).isNull()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.VoiceMessageType
|
||||
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.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -664,6 +665,7 @@ class TimelineItemContentMessageFactoryTest {
|
|||
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
|
||||
featureFlagService = featureFlagService,
|
||||
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
)
|
||||
|
||||
private fun createStickerContent(
|
||||
|
|
|
|||
|
|
@ -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.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import org.junit.Test
|
||||
|
||||
class InReplyToDetailTest {
|
||||
@Test
|
||||
fun `map - with a not ready InReplyTo does not work`() {
|
||||
assertThat(InReplyTo.Pending.map()).isNull()
|
||||
assertThat(InReplyTo.NotLoaded(AN_EVENT_ID).map()).isNull()
|
||||
assertThat(InReplyTo.Error.map()).isNull()
|
||||
assertThat(
|
||||
InReplyTo.Pending.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
assertThat(
|
||||
InReplyTo.NotLoaded(AN_EVENT_ID).map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
assertThat(
|
||||
InReplyTo.Error.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -48,7 +61,9 @@ class InReplyToDetailTest {
|
|||
change = MembershipChange.INVITED,
|
||||
)
|
||||
)
|
||||
val inReplyToDetails = inReplyTo.map()
|
||||
val inReplyToDetails = inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)
|
||||
assertThat(inReplyToDetails).isNotNull()
|
||||
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
|
||||
|
|
@ -95,6 +114,10 @@ class InReplyToDetailTest {
|
|||
)
|
||||
)
|
||||
)
|
||||
assertThat(inReplyTo.map()?.textContent).isEqualTo("**Hello!**")
|
||||
assertThat(
|
||||
inReplyTo.map(
|
||||
permalinkParser = FakePermalinkParser()
|
||||
)?.textContent
|
||||
).isEqualTo("**Hello!**")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.poll.PollAnswer
|
|||
* @property isSelected whether the user has selected this answer.
|
||||
* @property isEnabled whether the answer can be voted.
|
||||
* @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 percentage the percentage of votes for this answer.
|
||||
*/
|
||||
|
|
@ -34,7 +34,7 @@ data class PollAnswerItem(
|
|||
val isSelected: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
val isWinner: Boolean,
|
||||
val isDisclosed: Boolean,
|
||||
val showVotes: Boolean,
|
||||
val votesCount: Int,
|
||||
val percentage: Float,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.progressIndicatorTrackColor
|
||||
import io.element.android.libraries.designsystem.toEnabledColor
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
|
||||
@Composable
|
||||
|
|
@ -79,17 +80,36 @@ internal fun PollAnswerView(
|
|||
text = answerItem.answer.text,
|
||||
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
if (answerItem.isDisclosed) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Bottom),
|
||||
text = pluralStringResource(
|
||||
id = CommonPlurals.common_poll_votes_count,
|
||||
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,
|
||||
if (answerItem.showVotes) {
|
||||
val text = pluralStringResource(
|
||||
id = CommonPlurals.common_poll_votes_count,
|
||||
count = answerItem.votesCount,
|
||||
answerItem.votesCount
|
||||
)
|
||||
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))
|
||||
|
|
@ -98,7 +118,7 @@ internal fun PollAnswerView(
|
|||
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
|
||||
progress = {
|
||||
when {
|
||||
answerItem.isDisclosed -> answerItem.percentage
|
||||
answerItem.showVotes -> answerItem.percentage
|
||||
answerItem.isSelected -> 1f
|
||||
else -> 0f
|
||||
}
|
||||
|
|
@ -114,7 +134,7 @@ internal fun PollAnswerView(
|
|||
@Composable
|
||||
internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = false),
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +142,7 @@ internal fun PollAnswerDisclosedNotSelectedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true),
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +150,7 @@ internal fun PollAnswerDisclosedSelectedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = false),
|
||||
answerItem = aPollAnswerItem(showVotes = false, isSelected = false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +158,7 @@ internal fun PollAnswerUndisclosedNotSelectedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(isDisclosed = false, isSelected = true),
|
||||
answerItem = aPollAnswerItem(showVotes = false, isSelected = true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -146,7 +166,7 @@ internal fun PollAnswerUndisclosedSelectedPreview() = ElementPreview {
|
|||
@Composable
|
||||
internal fun PollAnswerEndedWinnerNotSelectedPreview() = ElementPreview {
|
||||
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
|
||||
internal fun PollAnswerEndedWinnerSelectedPreview() = ElementPreview {
|
||||
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
|
||||
internal fun PollAnswerEndedSelectedPreview() = ElementPreview {
|
||||
PollAnswerView(
|
||||
answerItem = aPollAnswerItem(isDisclosed = true, isSelected = true, isEnabled = false, isWinner = false),
|
||||
answerItem = aPollAnswerItem(showVotes = true, isSelected = true, isEnabled = false, isWinner = false),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ fun aPollQuestion() = "What type of food should we have at the party?"
|
|||
fun aPollAnswerItemList(
|
||||
hasVotes: Boolean = true,
|
||||
isEnded: Boolean = false,
|
||||
isDisclosed: Boolean = true,
|
||||
showVotes: Boolean = true,
|
||||
) = persistentListOf(
|
||||
aPollAnswerItem(
|
||||
answer = PollAnswer("option_1", "Italian \uD83C\uDDEE\uD83C\uDDF9"),
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
isEnabled = !isEnded,
|
||||
isWinner = isEnded,
|
||||
votesCount = if (hasVotes) 5 else 0,
|
||||
|
|
@ -39,7 +39,7 @@ fun aPollAnswerItemList(
|
|||
),
|
||||
aPollAnswerItem(
|
||||
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
isEnabled = !isEnded,
|
||||
isWinner = false,
|
||||
votesCount = 0,
|
||||
|
|
@ -47,7 +47,7 @@ fun aPollAnswerItemList(
|
|||
),
|
||||
aPollAnswerItem(
|
||||
answer = PollAnswer("option_3", "Brazilian \uD83C\uDDE7\uD83C\uDDF7"),
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
isEnabled = !isEnded,
|
||||
isWinner = false,
|
||||
isSelected = true,
|
||||
|
|
@ -55,7 +55,7 @@ fun aPollAnswerItemList(
|
|||
percentage = if (hasVotes) 0.1f else 0f
|
||||
),
|
||||
aPollAnswerItem(
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
isEnabled = !isEnded,
|
||||
votesCount = if (hasVotes) 4 else 0,
|
||||
percentage = if (hasVotes) 0.4f else 0f,
|
||||
|
|
@ -70,7 +70,7 @@ fun aPollAnswerItem(
|
|||
isSelected: Boolean = false,
|
||||
isEnabled: Boolean = true,
|
||||
isWinner: Boolean = false,
|
||||
isDisclosed: Boolean = true,
|
||||
showVotes: Boolean = true,
|
||||
votesCount: Int = 4,
|
||||
percentage: Float = 0.4f,
|
||||
) = PollAnswerItem(
|
||||
|
|
@ -78,7 +78,7 @@ fun aPollAnswerItem(
|
|||
isSelected = isSelected,
|
||||
isEnabled = isEnabled,
|
||||
isWinner = isWinner,
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
votesCount = votesCount,
|
||||
percentage = percentage
|
||||
)
|
||||
|
|
@ -87,14 +87,14 @@ fun aPollContentState(
|
|||
eventId: EventId? = null,
|
||||
isMine: Boolean = false,
|
||||
isEnded: Boolean = false,
|
||||
isDisclosed: Boolean = true,
|
||||
showVotes: Boolean = true,
|
||||
isPollEditable: Boolean = true,
|
||||
hasVotes: Boolean = true,
|
||||
question: String = aPollQuestion(),
|
||||
pollKind: PollKind = PollKind.Disclosed,
|
||||
answerItems: ImmutableList<PollAnswerItem> = aPollAnswerItemList(
|
||||
isEnded = isEnded,
|
||||
isDisclosed = isDisclosed,
|
||||
showVotes = showVotes,
|
||||
hasVotes = hasVotes
|
||||
),
|
||||
) = PollContentState(
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
|
|||
PollContentView(
|
||||
eventId = EventId("\$anEventId"),
|
||||
question = "What type of food should we have at the party?",
|
||||
answerItems = aPollAnswerItemList(isDisclosed = false),
|
||||
answerItems = aPollAnswerItemList(showVotes = false),
|
||||
pollKind = PollKind.Undisclosed,
|
||||
isPollEnded = false,
|
||||
isPollEditable = false,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue