Merge branch 'release/0.7.3' into main

This commit is contained in:
Benoit Marty 2024-11-08 17:02:09 +01:00
commit b2c1dd6bd4
987 changed files with 8422 additions and 4257 deletions

View file

@ -79,7 +79,7 @@ jobs:
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.9.2 - uses: mobile-dev-inc/action-maestro-cloud@v1.9.4
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}

2
.idea/kotlinc.xml generated
View file

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

View file

@ -1,3 +1,19 @@
Changes in Element X v0.7.2 (2024-10-29)
========================================
## What's Changed
### 🙌 Improvements
* Add setting to compress image and video by @bmarty in https://github.com/element-hq/element-x-android/pull/3744
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/3743
### 🧱 Build
* Release script improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/3741
### Dependency upgrades
* Update dependency org.maplibre.gl:android-sdk to v11.5.2 by @renovate in https://github.com/element-hq/element-x-android/pull/3720
* Update dependency io.sentry:sentry-android to v7.16.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3726
* Update dependencyAnalysis to v2.3.0 by @renovate in https://github.com/element-hq/element-x-android/pull/3740
* Update dependency org.matrix.rustcomponents:sdk-android to v0.2.58 by @renovate in https://github.com/element-hq/element-x-android/pull/3749
Changes in Element X v0.7.1 (2024-10-25) Changes in Element X v0.7.1 (2024-10-25)
======================================== ========================================

View file

@ -8,7 +8,7 @@
# Element X Android # Element X Android
Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/). This app is currently in a pre-alpha release stage with only basic functionalities. Element X Android is a [Matrix](https://matrix.org/) Android Client provided by [element.io](https://element.io/).
The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx). The application is a total rewrite of [Element-Android](https://github.com/element-hq/element-android) using the [Matrix Rust SDK](https://github.com/matrix-org/matrix-rust-sdk) underneath and targeting devices running Android 7+. The UI layer is written using [Jetpack Compose](https://developer.android.com/jetpack/compose), and the navigation is managed using [Appyx](https://github.com/bumble-tech/appyx).
@ -71,7 +71,7 @@ We're doing this as a way to share code between platforms and while we've seen p
## Status ## Status
This project is in work in progress. The app does not cover yet all functionalities we expect. The list of supported features can be found in [this issue](https://github.com/element-hq/element-x-android/issues/911). This project is in an early rollout and migration phase.
## Contributing ## Contributing

View file

@ -48,9 +48,9 @@ android {
} else { } else {
"io.element.android.x" "io.element.android.x"
} }
targetSdk = Versions.targetSdk targetSdk = Versions.TARGET_SDK
versionCode = Versions.versionCode versionCode = Versions.VERSION_CODE
versionName = Versions.versionName versionName = Versions.VERSION_NAME
// Keep abiFilter for the universalApk // Keep abiFilter for the universalApk
ndk { ndk {

View file

@ -10,12 +10,11 @@
<!-- To be able to install APK from the application --> <!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Do not enable enableOnBackInvokedCallback until https://issuetracker.google.com/issues/271303558 is fixed -->
<application <application
android:name=".ElementXApplication" android:name=".ElementXApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="false" android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"

View file

@ -25,8 +25,10 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
@ -50,6 +52,7 @@ import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -66,6 +69,8 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase import io.element.android.libraries.preferences.api.store.EnableNativeSlidingSyncUseCase
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues, private val sendingQueue: SendQueues,
private val logoutEntryPoint: LogoutEntryPoint, private val logoutEntryPoint: LogoutEntryPoint,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase, private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>( ) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
) )
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
}
}
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
// TODO We do not support Space yet, so directly navigate to main space // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(coroutineScope)
matrixClient.sessionVerificationService().setListener(verificationListener)
ftueService.state ftueService.state
.onEach { ftueState -> .onEach { ftueState ->
@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService().setListener(null)
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
@Parcelize
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -260,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onSetUpRecoveryClick() { override fun onSetUpRecoveryClick() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
} }
override fun onSessionConfirmRecoveryKeyClick() { override fun onSessionConfirmRecoveryKeyClick() {
@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
.callback(object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
})
.build()
}
} }
} }

View file

@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider { class MatrixClientsHolder @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>() private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
private val restoreMutex = Mutex() private val restoreMutex = Mutex()
init {
authenticationService.listenToNewMatrixClients { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
}
fun removeAll() { fun removeAll() {
sessionIdsToMatrixClient.clear() sessionIdsToMatrixClient.clear()
} }

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Uitloggen &amp; Upgraden"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Je homeserver ondersteunt het oude protocol niet meer. Log uit en log opnieuw in om de app te blijven gebruiken."</string>
</resources>

View file

@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
matrixClientsHolder.restoreWithSavedState(savedStateMap) matrixClientsHolder.restoreWithSavedState(savedStateMap)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
} }
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
}
} }

View file

@ -49,7 +49,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.4.16") detektPlugins("io.nlopez.compose.rules:detekt:0.4.17")
} }
// KtLint // KtLint

View file

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

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.api
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Value for the local current call.
*/
sealed interface CurrentCall {
data object None : CurrentCall
data class RoomCall(
val roomId: RoomId,
) : CurrentCall
data class ExternalUrl(
val url: String,
) : CurrentCall
}

View file

@ -0,0 +1,19 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.api
import kotlinx.coroutines.flow.StateFlow
interface CurrentCallService {
/**
* The current call state flow, which will be updated when the active call changes.
* This value reflect the local state of the call. It is not updated if the user answers
* a call from another session.
*/
val currentCall: StateFlow<CurrentCall>
}

View file

@ -183,6 +183,7 @@ private fun WebView.setup(
allowFileAccess = true allowFileAccess = true
domStorageEnabled = true domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false mediaPlaybackRequiresUserGesture = false
@Suppress("DEPRECATION")
databaseEnabled = true databaseEnabled = true
loadsImagesAutomatically = true loadsImagesAutomatically = true
userAgentString = userAgent userAgentString = userAgent

View file

@ -35,6 +35,7 @@ import androidx.core.content.IntentCompat
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvents
@ -44,11 +45,14 @@ import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("ElementCallActivity")
class ElementCallActivity : class ElementCallActivity :
AppCompatActivity(), AppCompatActivity(),
CallScreenNavigator, CallScreenNavigator,
@ -132,7 +136,7 @@ class ElementCallActivity :
DisposableEffect(Unit) { DisposableEffect(Unit) {
val listener = Runnable { val listener = Runnable {
if (requestPermissionCallback != null) { if (requestPermissionCallback != null) {
Timber.w("Ignoring onUserLeaveHint event because user is asked to grant permissions") Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else { } else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture) pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
} }
@ -146,7 +150,7 @@ class ElementCallActivity :
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode)) pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.d("Exiting PiP mode: Hangup the call") Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup) eventSink?.invoke(CallScreenEvents.Hangup)
} }
} }
@ -185,23 +189,23 @@ class ElementCallActivity :
private fun setCallType(intent: Intent?) { private fun setCallType(intent: Intent?) {
val callType = intent?.let { val callType = intent?.let {
IntentCompat.getParcelableExtra(it, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java) IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
} }
val intentUrl = intent?.dataString?.let(::parseUrl) val currentCallType = webViewTarget.value
when { if (currentCallType == null && callType == null) {
// Re-opened the activity but we have no url to load or a cached one, finish the activity Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
intent?.dataString == null && callType == null && webViewTarget.value == null -> finish() finish()
callType != null -> { } else if (currentCallType == null) {
webViewTarget.value = callType Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
presenter = presenterFactory.create(callType, this) webViewTarget.value = callType
} presenter = presenterFactory.create(callType!!, this)
intentUrl != null -> { } else if (callType != currentCallType) {
val fallbackInputs = CallType.ExternalUrl(intentUrl) Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
webViewTarget.value = fallbackInputs setIntent(intent)
presenter = presenterFactory.create(fallbackInputs, this) recreate()
} } else {
// Coming back from notification, do nothing Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
else -> return
} }
} }

View file

@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -82,6 +83,7 @@ class DefaultActiveCallManager @Inject constructor(
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat, private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider, private val matrixClientProvider: MatrixClientProvider,
private val defaultCurrentCallService: DefaultCurrentCallService,
) : ActiveCallManager { ) : ActiveCallManager {
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@ -89,6 +91,7 @@ class DefaultActiveCallManager @Inject constructor(
init { init {
observeRingingCall() observeRingingCall()
observeCurrentCall()
} }
override fun registerIncomingCall(notificationData: CallNotificationData) { override fun registerIncomingCall(notificationData: CallNotificationData) {
@ -209,6 +212,28 @@ class DefaultActiveCallManager @Inject constructor(
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
private fun observeCurrentCall() {
activeCall
.onEach { value ->
if (value == null) {
defaultCurrentCallService.onCallEnded()
} else {
when (value.callState) {
is CallState.Ringing -> {
// Nothing to do
}
is CallState.InCall -> {
when (val callType = value.callType) {
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
}
}
}
}
.launchIn(coroutineScope)
}
} }
/** /**

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultCurrentCallService @Inject constructor() : CurrentCallService {
override val currentCall = MutableStateFlow<CurrentCall>(CurrentCall.None)
fun onCallStarted(call: CurrentCall) {
currentCall.value = call
}
fun onCallEnded() {
currentCall.value = CurrentCall.None
}
}

View file

@ -15,6 +15,7 @@ import io.element.android.features.call.impl.notifications.RingingCallNotificati
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.CallState import io.element.android.features.call.impl.utils.CallState
import io.element.android.features.call.impl.utils.DefaultActiveCallManager import io.element.android.features.call.impl.utils.DefaultActiveCallManager
import io.element.android.features.call.impl.utils.DefaultCurrentCallService
import io.element.android.features.call.test.aCallNotificationData import io.element.android.features.call.test.aCallNotificationData
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -299,5 +300,6 @@ class DefaultActiveCallManagerTest {
), ),
notificationManagerCompat = notificationManagerCompat, notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider, matrixClientProvider = matrixClientProvider,
defaultCurrentCallService = DefaultCurrentCallService(),
) )
} }

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.call.test
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.api.CurrentCallService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeCurrentCallService(
override val currentCall: MutableStateFlow<CurrentCall> = MutableStateFlow(CurrentCall.None),
) : CurrentCallService

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
api(projects.features.createroom.api) api(projects.features.createroom.api)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -56,6 +57,7 @@ dependencies {
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.features.createroom.test) testImplementation(projects.features.createroom.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testReleaseImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -8,7 +8,7 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -18,5 +18,7 @@ data class CreateRoomConfig(
val topic: String? = null, val topic: String? = null,
val avatarUri: Uri? = null, val avatarUri: Uri? = null,
val invites: ImmutableList<MatrixUser> = persistentListOf(), val invites: ImmutableList<MatrixUser> = persistentListOf(),
val privacy: RoomPrivacy = RoomPrivacy.Private, val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
) ) {
val isValid = roomName.isNullOrEmpty().not() && roomVisibility.isValid()
}

View file

@ -8,7 +8,12 @@
package io.element.android.features.createroom.impl package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomAccess
import io.element.android.features.createroom.impl.configureroom.RoomAccessItem
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomAddressErrorState
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityItem
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.androidutils.file.safeDelete
@ -17,6 +22,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.getAndUpdate
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -31,28 +37,89 @@ class CreateRoomDataStore @Inject constructor(
field = value field = value
} }
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine( val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
selectedUserListDataStore.selectedUsers(), selectedUserListDataStore.selectedUsers(),
createRoomConfigFlow, createRoomConfigFlow,
) { selectedUsers, config -> ) { selectedUsers, config ->
config.copy(invites = selectedUsers.toImmutableList()) config.copy(invites = selectedUsers.toImmutableList())
} }
fun setRoomName(roomName: String?) { fun setRoomName(roomName: String) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() })) createRoomConfigFlow.getAndUpdate { config ->
/*
val newVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
val roomAddress = config.roomVisibility.roomAddress
if (roomAddress is RoomAddress.AutoFilled || roomName.isEmpty()) {
config.roomVisibility.copy(
roomAddress = RoomAddress.AutoFilled(roomName),
)
} else {
config.roomVisibility
}
}
else -> config.roomVisibility
}
*/
config.copy(
roomName = roomName.takeIf { it.isNotEmpty() },
)
}
} }
fun setTopic(topic: String?) { fun setTopic(topic: String) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) createRoomConfigFlow.getAndUpdate { config ->
config.copy(topic = topic.takeIf { it.isNotEmpty() })
}
} }
fun setAvatarUri(uri: Uri?, cached: Boolean = false) { fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
cachedAvatarUri = uri.takeIf { cached } cachedAvatarUri = uri.takeIf { cached }
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri)) createRoomConfigFlow.getAndUpdate { config ->
config.copy(avatarUri = uri)
}
} }
fun setPrivacy(privacy: RoomPrivacy) { fun setRoomVisibility(visibility: RoomVisibilityItem) {
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (visibility) {
RoomVisibilityItem.Private -> RoomVisibilityState.Private
RoomVisibilityItem.Public -> RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(config.roomName.orEmpty()),
roomAddressErrorState = RoomAddressErrorState.None,
roomAccess = RoomAccess.Anyone,
)
}
)
}
}
fun setRoomAddress(address: String) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> config.roomVisibility.copy(roomAddress = RoomAddress.Edited(address))
else -> config.roomVisibility
}
)
}
}
fun setRoomAccess(access: RoomAccessItem) {
createRoomConfigFlow.getAndUpdate { config ->
config.copy(
roomVisibility = when (config.roomVisibility) {
is RoomVisibilityState.Public -> {
when (access) {
RoomAccessItem.Anyone -> config.roomVisibility.copy(roomAccess = RoomAccess.Anyone)
RoomAccessItem.AskToJoin -> config.roomVisibility.copy(roomAccess = RoomAccess.Knocking)
}
}
else -> config.roomVisibility
}
)
}
} }
fun clearCachedData() { fun clearCachedData() {

View file

@ -1,101 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem
import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems
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
import io.element.android.libraries.designsystem.theme.components.RadioButton
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPrivacyOption(
roomPrivacyItem: RoomPrivacyItem,
onOptionClick: (RoomPrivacyItem) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
) {
Row(
modifier
.fillMaxWidth()
.selectable(
selected = isSelected,
onClick = { onOptionClick(roomPrivacyItem) },
role = Role.RadioButton,
)
.padding(8.dp),
) {
Icon(
modifier = Modifier.padding(horizontal = 8.dp),
resourceId = roomPrivacyItem.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
)
Column(
Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
Text(
text = roomPrivacyItem.title,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.size(3.dp))
Text(
text = roomPrivacyItem.description,
style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.tertiary,
)
}
RadioButton(
modifier = Modifier
.align(Alignment.CenterVertically)
.size(48.dp),
selected = isSelected,
// null recommended for accessibility with screenreaders
onClick = null
)
}
}
@PreviewsDayNight
@Composable
internal fun RoomPrivacyOptionPreview() = ElementPreview {
val aRoomPrivacyItem = roomPrivacyItems().first()
Column {
RoomPrivacyOption(
roomPrivacyItem = aRoomPrivacyItem,
onOptionClick = {},
isSelected = true,
)
RoomPrivacyOption(
roomPrivacyItem = aRoomPrivacyItem,
onOptionClick = {},
isSelected = false,
)
}
}

View file

@ -7,16 +7,17 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface ConfigureRoomEvents { sealed interface ConfigureRoomEvents {
data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class RoomNameChanged(val name: String) : ConfigureRoomEvents
data class TopicChanged(val topic: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents
data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents data class RoomAccessChanged(val roomAccess: RoomAccessItem) : ConfigureRoomEvents
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents data class RoomAddressChanged(val roomAddress: String) : ConfigureRoomEvents
data class RemoveUserFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data object CreateRoom : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
data object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvents
} }

View file

@ -24,6 +24,8 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes 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.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
@ -38,6 +40,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class ConfigureRoomPresenter @Inject constructor( class ConfigureRoomPresenter @Inject constructor(
@ -47,6 +50,7 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
private val featureFlagService: FeatureFlagService,
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false private var pendingPermissionRequest = false
@ -54,7 +58,9 @@ class ConfigureRoomPresenter @Inject constructor(
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present() val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val createRoomConfig = dataStore.createRoomConfigWithInvites.collectAsState(CreateRoomConfig())
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock).collectAsState(initial = false)
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) }, onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
@ -92,9 +98,11 @@ class ConfigureRoomPresenter @Inject constructor(
when (event) { when (event) {
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config) is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig.value)
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
@ -113,10 +121,12 @@ class ConfigureRoomPresenter @Inject constructor(
} }
return ConfigureRoomState( return ConfigureRoomState(
isKnockFeatureEnabled = isKnockFeatureEnabled,
config = createRoomConfig.value, config = createRoomConfig.value,
avatarActions = avatarActions, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState, cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -127,26 +137,50 @@ class ConfigureRoomPresenter @Inject constructor(
) = launch { ) = launch {
suspend { suspend {
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) } val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
val params = CreateRoomParameters( val params = if (config.roomVisibility is RoomVisibilityState.Public) {
name = config.roomName, CreateRoomParameters(
topic = config.topic, name = config.roomName,
isEncrypted = config.privacy == RoomPrivacy.Private, topic = config.topic,
isDirect = false, isEncrypted = false,
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, isDirect = false,
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, visibility = RoomVisibility.PUBLIC,
invite = config.invites.map { it.userId }, joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
avatar = avatarUrl, preset = RoomPreset.PUBLIC_CHAT,
) invite = config.invites.map { it.userId },
matrixClient.createRoom(params).getOrThrow() avatar = avatarUrl,
.also { canonicalAlias = config.roomVisibility.roomAddress()
)
} else {
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = config.roomVisibility is RoomVisibilityState.Private,
isDirect = false,
visibility = RoomVisibility.PRIVATE,
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
)
}
matrixClient.createRoom(params)
.onFailure { failure ->
Timber.e(failure, "Failed to create room")
}
.onSuccess {
dataStore.clearCachedData() dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
.getOrThrow()
}.runCatchingUpdatingState(createRoomAction) }.runCatchingUpdatingState(createRoomAction)
} }
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val preprocessed = mediaPreProcessor.process(
uri = avatarUri,
mimeType = MimeTypes.Jpeg,
deleteOriginal = false,
compressIfPossible = false,
).getOrThrow()
val byteArray = preprocessed.file.readBytes() val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow() return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
} }

View file

@ -15,11 +15,11 @@ import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val isKnockFeatureEnabled: Boolean,
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: AsyncAction<RoomId>, val createRoomAction: AsyncAction<RoomId>,
val cameraPermissionState: PermissionsState, val cameraPermissionState: PermissionsState,
val homeserverName: String,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { )
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
}

View file

@ -10,30 +10,59 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState> override val values: Sequence<ConfigureRoomState>
get() = sequenceOf( get() = sequenceOf(
aConfigureRoomState(), aConfigureRoomState(),
aConfigureRoomState().copy( aConfigureRoomState(
isKnockFeatureEnabled = false,
config = CreateRoomConfig( config = CreateRoomConfig(
roomName = "Room 101", roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines", topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(), invites = aMatrixUserList().toImmutableList(),
privacy = RoomPrivacy.Public, roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room 101"),
roomAccess = RoomAccess.Knocking,
roomAddressErrorState = RoomAddressErrorState.None,
),
),
),
aConfigureRoomState(
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aMatrixUserList().toImmutableList(),
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled("Room 101"),
roomAccess = RoomAccess.Knocking,
roomAddressErrorState = RoomAddressErrorState.None,
),
), ),
), ),
) )
} }
fun aConfigureRoomState() = ConfigureRoomState( fun aConfigureRoomState(
config = CreateRoomConfig(), config: CreateRoomConfig = CreateRoomConfig(),
avatarActions = persistentListOf(), isKnockFeatureEnabled: Boolean = true,
createRoomAction = AsyncAction.Uninitialized, avatarActions: List<AvatarAction> = emptyList(),
cameraPermissionState = aPermissionsState(showDialog = false), createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink = { }, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
homeserverName: String = "matrix.org",
eventSink: (ConfigureRoomEvents) -> Unit = { },
) = ConfigureRoomState(
config = config,
isKnockFeatureEnabled = isKnockFeatureEnabled,
avatarActions = avatarActions.toImmutableList(),
createRoomAction = createRoomAction,
cameraPermissionState = cameraPermissionState,
homeserverName = homeserverName,
eventSink = eventSink,
) )

View file

@ -11,9 +11,11 @@ import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -21,6 +23,7 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -33,18 +36,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.RoomPrivacyOption import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.components.LabelledTextField import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
@ -72,11 +79,11 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
ConfigureRoomToolbar( ConfigureRoomToolbar(
isNextActionEnabled = state.isCreateButtonEnabled, isNextActionEnabled = state.config.isValid,
onBackClick = onBackClick, onBackClick = onBackClick,
onNextClick = { onNextClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) state.eventSink(ConfigureRoomEvents.CreateRoom)
}, },
) )
} }
@ -103,23 +110,42 @@ fun ConfigureRoomView(
) )
if (state.config.invites.isNotEmpty()) { if (state.config.invites.isNotEmpty()) {
SelectedUsersRowList( SelectedUsersRowList(
modifier = Modifier.padding(bottom = 16.dp),
contentPadding = PaddingValues(horizontal = 24.dp), contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites, selectedUsers = state.config.invites,
onUserRemove = { onUserRemove = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
}, },
) )
} }
RoomPrivacyOptions( RoomVisibilityOptions(
modifier = Modifier.padding(bottom = 40.dp), selected = when (state.config.roomVisibility) {
selected = state.config.privacy, is RoomVisibilityState.Private -> RoomVisibilityItem.Private
is RoomVisibilityState.Public -> RoomVisibilityItem.Public
},
onOptionClick = { onOptionClick = {
focusManager.clearFocus() focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
}, },
) )
if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) {
RoomAccessOptions(
selected = when (state.config.roomVisibility.roomAccess) {
RoomAccess.Anyone -> RoomAccessItem.Anyone
RoomAccess.Knocking -> RoomAccessItem.AskToJoin
},
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it))
},
)
RoomAddressField(
modifier = Modifier.padding(horizontal = 16.dp),
address = state.config.roomVisibility.roomAddress,
homeserverName = state.homeserverName,
onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
)
}
} }
} }
@ -139,7 +165,7 @@ fun ConfigureRoomView(
}, },
onSuccess = { onCreateRoomSuccess(it) }, onSuccess = { onCreateRoomSuccess(it) },
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
) )
@ -221,23 +247,123 @@ private fun RoomTopic(
} }
@Composable @Composable
private fun RoomPrivacyOptions( private fun ConfigureRoomOptions(
selected: RoomPrivacy?, title: String,
onOptionClick: (RoomPrivacyItem) -> Unit, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.selectableGroup()
) {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
@Composable
private fun RoomVisibilityOptions(
selected: RoomVisibilityItem,
onOptionClick: (RoomVisibilityItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val items = roomPrivacyItems() ConfigureRoomOptions(
Column(modifier = modifier.selectableGroup()) { title = stringResource(R.string.screen_create_room_room_visibility_section_title),
items.forEach { item -> modifier = modifier,
RoomPrivacyOption( ) {
roomPrivacyItem = item, RoomVisibilityItem.entries.forEach { item ->
isSelected = selected == item.privacy, val isSelected = item == selected
onOptionClick = onOptionClick, ListItem(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
resourceId = item.icon,
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
)
},
headlineContent = { Text(text = stringResource(item.title)) },
supportingContent = { Text(text = stringResource(item.description)) },
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onOptionClick(item) },
) )
} }
} }
} }
@Composable
private fun RoomAccessOptions(
selected: RoomAccessItem,
onOptionClick: (RoomAccessItem) -> Unit,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
title = stringResource(R.string.screen_create_room_room_access_section_header),
modifier = modifier,
) {
RoomAccessItem.entries.forEach { item ->
ListItem(
headlineContent = { Text(text = stringResource(item.title)) },
supportingContent = { Text(text = stringResource(item.description)) },
trailingContent = ListItemContent.RadioButton(selected = item == selected),
onClick = { onOptionClick(item) },
)
}
}
}
@Composable
private fun RoomAddressField(
address: RoomAddress,
homeserverName: String,
onAddressChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.primary,
text = stringResource(R.string.screen_create_room_room_address_section_title),
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = address.value,
leadingIcon = {
Text(
text = "#",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
)
},
trailingIcon = {
Text(
text = homeserverName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(end = 16.dp)
)
},
supportingText = {
Text(
text = stringResource(R.string.screen_create_room_room_address_section_footer),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
},
onValueChange = onAddressChange,
singleLine = true,
)
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import io.element.android.libraries.matrix.api.createroom.JoinRuleOverride
enum class RoomAccess {
Anyone,
Knocking
}
fun RoomAccess.toJoinRule(): JoinRuleOverride {
return when (this) {
RoomAccess.Anyone -> JoinRuleOverride.None
RoomAccess.Knocking -> JoinRuleOverride.Knock
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.StringRes
import io.element.android.features.createroom.impl.R
enum class RoomAccessItem(
@StringRes val title: Int,
@StringRes val description: Int
) {
Anyone(
title = R.string.screen_create_room_room_access_section_anyone_option_title,
description = R.string.screen_create_room_room_access_section_anyone_option_description,
),
AskToJoin(
title = R.string.screen_create_room_room_access_section_knocking_option_title,
description = R.string.screen_create_room_room_access_section_knocking_option_description,
),
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
sealed class RoomAddress(open val value: String) {
data class AutoFilled(override val value: String) : RoomAddress(value)
data class Edited(override val value: String) : RoomAddress(value)
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
/**
* Represents the error state of a room address.
*/
sealed interface RoomAddressErrorState {
data object InvalidCharacters : RoomAddressErrorState
data object AlreadyExists : RoomAddressErrorState
data object None : RoomAddressErrorState
}

View file

@ -1,13 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
enum class RoomPrivacy {
Private,
Public,
}

View file

@ -1,45 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RoomPrivacyItem(
val privacy: RoomPrivacy,
@DrawableRes val icon: Int,
val title: String,
val description: String,
)
@Composable
fun roomPrivacyItems(): ImmutableList<RoomPrivacyItem> {
return RoomPrivacy.entries
.map {
when (it) {
RoomPrivacy.Private -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_compound_lock_solid,
title = stringResource(R.string.screen_create_room_private_option_title),
description = stringResource(R.string.screen_create_room_private_option_description),
)
RoomPrivacy.Public -> RoomPrivacyItem(
privacy = it,
icon = CompoundDrawables.ic_compound_public,
title = stringResource(R.string.screen_create_room_public_option_title),
description = stringResource(R.string.screen_create_room_public_option_description),
)
}
}
.toImmutableList()
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import io.element.android.features.createroom.impl.R
import io.element.android.libraries.designsystem.icons.CompoundDrawables
enum class RoomVisibilityItem(
@DrawableRes val icon: Int,
@StringRes val title: Int,
@StringRes val description: Int
) {
Private(
icon = CompoundDrawables.ic_compound_lock,
title = R.string.screen_create_room_private_option_title,
description = R.string.screen_create_room_private_option_description,
),
Public(
icon = CompoundDrawables.ic_compound_public,
title = R.string.screen_create_room_public_option_title,
description = R.string.screen_create_room_public_option_description,
)
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.createroom.impl.configureroom
import java.util.Optional
sealed interface RoomVisibilityState {
data object Private : RoomVisibilityState
data class Public(
val roomAddress: RoomAddress,
val roomAddressErrorState: RoomAddressErrorState,
val roomAccess: RoomAccess,
) : RoomVisibilityState
fun roomAddress(): Optional<String> {
return when (this) {
is Private -> Optional.empty()
is Public -> Optional.of(roomAddress.value)
}
}
fun isValid(): Boolean {
return when (this) {
is Private -> true
is Public -> roomAddressErrorState is RoomAddressErrorState.None && roomAddress.value.isNotEmpty()
}
}
}

View file

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

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli. <string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."</string> To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_public_option_title">"Veřejná místnost"</string> <string name="screen_create_room_public_option_title">"Veřejná místnost"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Do této místnosti může vstoupit kdokoli"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Kdokoliv"</string>
<string name="screen_create_room_room_access_section_header">"Přístup do místnosti"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Požádat o připojení"</string>
<string name="screen_create_room_room_address_section_footer">"Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."</string>
<string name="screen_create_room_room_address_section_title">"Adresa místnosti"</string>
<string name="screen_create_room_room_name_label">"Název místnosti"</string> <string name="screen_create_room_room_name_label">"Název místnosti"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditelnost místnosti"</string>
<string name="screen_create_room_title">"Vytvořit místnost"</string> <string name="screen_create_room_title">"Vytvořit místnost"</string>
<string name="screen_create_room_topic_label">"Téma (nepovinné)"</string> <string name="screen_create_room_topic_label">"Téma (nepovinné)"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string> <string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>

View file

@ -8,6 +8,11 @@
<string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτό το δωμάτιο. <string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτό το δωμάτιο.
Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."</string> Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."</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_access_section_anyone_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτό το δωμάτιο"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Οποιοσδήποτε"</string>
<string name="screen_create_room_room_access_section_header">"Πρόσβαση Δωματίου"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στο δωμάτιο, αλλά ένας διαχειριστής ή συντονιστής θα πρέπει να αποδεχθεί το αίτημα"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Αίτημα συμμετοχής"</string>
<string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string> <string name="screen_create_room_room_name_label">"Όνομα δωματίου"</string>
<string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string> <string name="screen_create_room_title">"Δημιούργησε ένα δωμάτιο"</string>
<string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string> <string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida. <string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."</string> Sa võid seda jututoa seadistustest alati muuta."</string>
<string name="screen_create_room_public_option_title">"Avalik jututuba"</string> <string name="screen_create_room_public_option_title">"Avalik jututuba"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Kõik võivad selle jututoaga liituda"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Kõik"</string>
<string name="screen_create_room_room_access_section_header">"Ligipääs jututoale"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Küsi võimalust liitumiseks"</string>
<string name="screen_create_room_room_address_section_footer">"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."</string>
<string name="screen_create_room_room_address_section_title">"Jututoa aadress"</string>
<string name="screen_create_room_room_name_label">"Jututoa nimi"</string> <string name="screen_create_room_room_name_label">"Jututoa nimi"</string>
<string name="screen_create_room_room_visibility_section_title">"Jututoa nähtavus"</string>
<string name="screen_create_room_title">"Loo jututuba"</string> <string name="screen_create_room_title">"Loo jututuba"</string>
<string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string> <string name="screen_create_room_topic_label">"Teema (kui soovid lisada)"</string>
<string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string> <string name="screen_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string>

View file

@ -3,11 +3,20 @@
<string name="screen_create_room_action_create_room">"Nouveau salon"</string> <string name="screen_create_room_action_create_room">"Nouveau salon"</string>
<string name="screen_create_room_add_people_title">"Inviter des amis"</string> <string name="screen_create_room_add_people_title">"Inviter des amis"</string>
<string name="screen_create_room_error_creating_room">"Une erreur sest produite lors de la création du salon"</string> <string name="screen_create_room_error_creating_room">"Une erreur sest produite lors de la création du salon"</string>
<string name="screen_create_room_private_option_description">"Les messages dans ce salon sont chiffrés. Le chiffrement ne pourra pas être désactivé par la suite."</string> <string name="screen_create_room_private_option_description">"Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout."</string>
<string name="screen_create_room_private_option_title">"Salon privé (sur invitation seulement)"</string> <string name="screen_create_room_private_option_title">"Salon privé"</string>
<string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et nimporte qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string> <string name="screen_create_room_public_option_description">"Nimporte qui peut trouver ce salon.
<string name="screen_create_room_public_option_title">"Salon public (tout le monde)"</string> Vous pouvez modifier cela à tout moment dans les paramètres du salon."</string>
<string name="screen_create_room_public_option_title">"Salon public"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Tout le monde peut rejoindre ce salon"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Tout le monde"</string>
<string name="screen_create_room_room_access_section_header">"Accès au salon"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Demander à rejoindre"</string>
<string name="screen_create_room_room_address_section_footer">"Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin dune adresse de salon."</string>
<string name="screen_create_room_room_address_section_title">"Adresse du salon"</string>
<string name="screen_create_room_room_name_label">"Nom du salon"</string> <string name="screen_create_room_room_name_label">"Nom du salon"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilité du salon"</string>
<string name="screen_create_room_title">"Créer un salon"</string> <string name="screen_create_room_title">"Créer un salon"</string>
<string name="screen_create_room_topic_label">"Sujet (facultatif)"</string> <string name="screen_create_room_topic_label">"Sujet (facultatif)"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string> <string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string>

View file

@ -3,11 +3,20 @@
<string name="screen_create_room_action_create_room">"Új szoba"</string> <string name="screen_create_room_action_create_room">"Új szoba"</string>
<string name="screen_create_room_add_people_title">"Ismerősök meghívása"</string> <string name="screen_create_room_add_people_title">"Ismerősök meghívása"</string>
<string name="screen_create_room_error_creating_room">"Hiba történt a szoba létrehozásakor"</string> <string name="screen_create_room_error_creating_room">"Hiba történt a szoba létrehozásakor"</string>
<string name="screen_create_room_private_option_description">"A szobában lévő üzenetek titkosítottak. A titkosítást utólag nem lehet kikapcsolni."</string> <string name="screen_create_room_private_option_description">"Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve."</string>
<string name="screen_create_room_private_option_title">"Privát szoba (csak meghívással)"</string> <string name="screen_create_room_private_option_title">"Privát szoba"</string>
<string name="screen_create_room_public_option_description">"Az üzenetek nincsenek titkosítva, és bárki elolvashatja őket. A titkosítást később is engedélyezheti."</string> <string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
<string name="screen_create_room_public_option_title">"Nyilvános szoba (bárki)"</string> Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_public_option_title">"Nyilvános szoba"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Bárki csatlakozhat ehhez a szobához"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Bárki"</string>
<string name="screen_create_room_room_access_section_header">"Szobahozzáférés"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_address_section_footer">"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."</string>
<string name="screen_create_room_room_address_section_title">"Szoba címe"</string>
<string name="screen_create_room_room_name_label">"Szoba neve"</string> <string name="screen_create_room_room_name_label">"Szoba neve"</string>
<string name="screen_create_room_room_visibility_section_title">"Szoba láthatósága"</string>
<string name="screen_create_room_title">"Szoba létrehozása"</string> <string name="screen_create_room_title">"Szoba létrehozása"</string>
<string name="screen_create_room_topic_label">"Téma (nem kötelező)"</string> <string name="screen_create_room_topic_label">"Téma (nem kötelező)"</string>
<string name="screen_start_chat_error_starting_chat">"Hiba történt a csevegés indításakor"</string> <string name="screen_start_chat_error_starting_chat">"Hiba történt a csevegés indításakor"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini. <string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string> Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."</string>
<string name="screen_create_room_public_option_title">"Ruangan publik"</string> <string name="screen_create_room_public_option_title">"Ruangan publik"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Siapa pun dapat bergabung dengan ruangan ini"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Siapa pun"</string>
<string name="screen_create_room_room_access_section_header">"Akses Ruangan"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Minta untuk bergabung"</string>
<string name="screen_create_room_room_address_section_footer">"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."</string>
<string name="screen_create_room_room_address_section_title">"Alamat ruangan"</string>
<string name="screen_create_room_room_name_label">"Nama ruangan"</string> <string name="screen_create_room_room_name_label">"Nama ruangan"</string>
<string name="screen_create_room_room_visibility_section_title">"Keterlihatan ruangan"</string>
<string name="screen_create_room_title">"Buat ruangan"</string> <string name="screen_create_room_title">"Buat ruangan"</string>
<string name="screen_create_room_topic_label">"Topik (opsional)"</string> <string name="screen_create_room_topic_label">"Topik (opsional)"</string>
<string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string> <string name="screen_start_chat_error_starting_chat">"Terjadi kesalahan saat mencoba memulai obrolan"</string>

View file

@ -4,9 +4,10 @@
<string name="screen_create_room_add_people_title">"ხალხის მოწვევა"</string> <string name="screen_create_room_add_people_title">"ხალხის მოწვევა"</string>
<string name="screen_create_room_error_creating_room">"ოთახის შექმნისას შეცდომა მოხდა"</string> <string name="screen_create_room_error_creating_room">"ოთახის შექმნისას შეცდომა მოხდა"</string>
<string name="screen_create_room_private_option_description">"ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია."</string> <string name="screen_create_room_private_option_description">"ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია."</string>
<string name="screen_create_room_private_option_title">"კერძო ოთახი (მხოლოდ მოწვევა)"</string> <string name="screen_create_room_private_option_title">"კერძო ოთახი"</string>
<string name="screen_create_room_public_option_description">"შეტყობინებები არ არის დაშიფრული და ყველას შეუძლია მათი წაკითხვა. შეგიძლიათ ჩართოთ დაშიფვრა მოგვიანებით."</string> <string name="screen_create_room_public_option_description">"ყველას ამ ოთახის მოძებნა შეუძლია.
<string name="screen_create_room_public_option_title">"საჯარო ოთახი (ნებისმიერი)"</string> თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."</string>
<string name="screen_create_room_public_option_title">"საჯარო ოთახი"</string>
<string name="screen_create_room_room_name_label">"ოთახის სახელი"</string> <string name="screen_create_room_room_name_label">"ოთახის სახელი"</string>
<string name="screen_create_room_title">"ოთახის შექმნა"</string> <string name="screen_create_room_title">"ოთახის შექმნა"</string>
<string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string> <string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string>

View file

@ -4,9 +4,15 @@
<string name="screen_create_room_add_people_title">"Mensen uitnodigen"</string> <string name="screen_create_room_add_people_title">"Mensen uitnodigen"</string>
<string name="screen_create_room_error_creating_room">"Er is een fout opgetreden bij het aanmaken van de kamer"</string> <string name="screen_create_room_error_creating_room">"Er is een fout opgetreden bij het aanmaken van de kamer"</string>
<string name="screen_create_room_private_option_description">"Berichten in deze kamer zijn versleuteld. Versleuteling kan achteraf niet worden uitgeschakeld."</string> <string name="screen_create_room_private_option_description">"Berichten in deze kamer zijn versleuteld. Versleuteling kan achteraf niet worden uitgeschakeld."</string>
<string name="screen_create_room_private_option_title">"Privé kamer (alleen op uitnodiging)"</string> <string name="screen_create_room_private_option_title">"Privé kamer"</string>
<string name="screen_create_room_public_option_description">"Berichten zijn niet versleuteld en iedereen kan ze lezen. Je kunt versleuteling later inschakelen."</string> <string name="screen_create_room_public_option_description">"Iedereen kan deze kamer vinden.
<string name="screen_create_room_public_option_title">"Openbare kamer (iedereen)"</string> Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."</string>
<string name="screen_create_room_public_option_title">"Openbare kamer"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Iedereen kan toetreden tot deze kamer"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Iedereen"</string>
<string name="screen_create_room_room_access_section_header">"Toegang tot de kamer"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Vraag om toe te treden"</string>
<string name="screen_create_room_room_name_label">"Naam van de kamer"</string> <string name="screen_create_room_room_name_label">"Naam van de kamer"</string>
<string name="screen_create_room_title">"Creëer een kamer"</string> <string name="screen_create_room_title">"Creëer een kamer"</string>
<string name="screen_create_room_topic_label">"Onderwerp (optioneel)"</string> <string name="screen_create_room_topic_label">"Onderwerp (optioneel)"</string>

View file

@ -3,11 +3,20 @@
<string name="screen_create_room_action_create_room">"Nowy pokój"</string> <string name="screen_create_room_action_create_room">"Nowy pokój"</string>
<string name="screen_create_room_add_people_title">"Zaproś znajomych"</string> <string name="screen_create_room_add_people_title">"Zaproś znajomych"</string>
<string name="screen_create_room_error_creating_room">"Wystąpił błąd w trakcie tworzenia pokoju"</string> <string name="screen_create_room_error_creating_room">"Wystąpił błąd w trakcie tworzenia pokoju"</string>
<string name="screen_create_room_private_option_description">"Wiadomości w tym pokoju są szyfrowane. Szyfrowania nie można później wyłączyć."</string> <string name="screen_create_room_private_option_description">"Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end."</string>
<string name="screen_create_room_private_option_title">"Pokój prywatny (tylko zaproszenie)"</string> <string name="screen_create_room_private_option_title">"Pokój prywatny"</string>
<string name="screen_create_room_public_option_description">"Wiadomości nie są szyfrowane i każdy może je odczytać. Możesz aktywować szyfrowanie później."</string> <string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój.
<string name="screen_create_room_public_option_title">"Pokój publiczny (wszyscy)"</string> Możesz to zmienić w ustawieniach pokoju."</string>
<string name="screen_create_room_public_option_title">"Pokój publiczny"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Każdy może dołączyć do tego pokoju"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Wszyscy"</string>
<string name="screen_create_room_room_access_section_header">"Dostęp do pokoju"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Poproś o dołączenie"</string>
<string name="screen_create_room_room_address_section_footer">"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."</string>
<string name="screen_create_room_room_address_section_title">"Adres pokoju"</string>
<string name="screen_create_room_room_name_label">"Nazwa pokoju"</string> <string name="screen_create_room_room_name_label">"Nazwa pokoju"</string>
<string name="screen_create_room_room_visibility_section_title">"Widoczność pomieszczenia"</string>
<string name="screen_create_room_title">"Utwórz pokój"</string> <string name="screen_create_room_title">"Utwórz pokój"</string>
<string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string> <string name="screen_create_room_topic_label">"Temat (opcjonalnie)"</string>
<string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string> <string name="screen_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>

View file

@ -3,11 +3,20 @@
<string name="screen_create_room_action_create_room">"Nova sala"</string> <string name="screen_create_room_action_create_room">"Nova sala"</string>
<string name="screen_create_room_add_people_title">"Convidar pessoas"</string> <string name="screen_create_room_add_people_title">"Convidar pessoas"</string>
<string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string> <string name="screen_create_room_error_creating_room">"Ocorreu um erro ao criar a sala"</string>
<string name="screen_create_room_private_option_description">"As mensagens serão cifradas. Uma vez ativada, não é possível desativar a cifragem."</string> <string name="screen_create_room_private_option_description">"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são encriptadas ponta a ponta."</string>
<string name="screen_create_room_private_option_title">"Sala privada (entrada apenas por convite)"</string> <string name="screen_create_room_private_option_title">"Sala privada"</string>
<string name="screen_create_room_public_option_description">"As mensagens não serão cifradas e qualquer um as poderá ler. É possível ativar a cifragem posteriormente."</string> <string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
<string name="screen_create_room_public_option_title">"Sala pública (entrada livre)"</string> Pode alterar esta opção nas definições da sala."</string>
<string name="screen_create_room_public_option_title">"Sala pública"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Qualquer pessoa pode entrar nesta sala"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Qualquer pessoa"</string>
<string name="screen_create_room_room_access_section_header">"Acesso à sala"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Pedir para participar"</string>
<string name="screen_create_room_room_address_section_footer">"Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala."</string>
<string name="screen_create_room_room_address_section_title">"Endereço da sala"</string>
<string name="screen_create_room_room_name_label">"Nome da sala"</string> <string name="screen_create_room_room_name_label">"Nome da sala"</string>
<string name="screen_create_room_room_visibility_section_title">"Visibilidade da sala"</string>
<string name="screen_create_room_title">"Criar uma sala"</string> <string name="screen_create_room_title">"Criar uma sala"</string>
<string name="screen_create_room_topic_label">"Descrição (opcional)"</string> <string name="screen_create_room_topic_label">"Descrição (opcional)"</string>
<string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string> <string name="screen_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату. <string name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."</string> Вы можете изменить это в любое время в настройках комнаты."</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_access_section_anyone_option_description">"Любой желающий может присоединиться к этой комнате"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Любой"</string>
<string name="screen_create_room_room_access_section_header">"Доступ в комнату"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Попросить присоединиться"</string>
<string name="screen_create_room_room_address_section_footer">"Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"</string>
<string name="screen_create_room_room_address_section_title">"Адрес комнаты"</string>
<string name="screen_create_room_room_name_label">"Название комнаты"</string> <string name="screen_create_room_room_name_label">"Название комнаты"</string>
<string name="screen_create_room_room_visibility_section_title">"Видимость комнаты"</string>
<string name="screen_create_room_title">"Создать комнату"</string> <string name="screen_create_room_title">"Создать комнату"</string>
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string> <string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string> <string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Túto miestnosť môže nájsť ktokoľvek. <string name="screen_create_room_public_option_description">"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string> Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."</string>
<string name="screen_create_room_public_option_title">"Verejná miestnosť"</string> <string name="screen_create_room_public_option_title">"Verejná miestnosť"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Do tejto miestnosti sa môže pripojiť ktokoľvek"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Ktokoľvek"</string>
<string name="screen_create_room_room_access_section_header">"Prístup do miestnosti"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Požiadať o pripojenie"</string>
<string name="screen_create_room_room_address_section_footer">"Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti."</string>
<string name="screen_create_room_room_address_section_title">"Adresa miestnosti"</string>
<string name="screen_create_room_room_name_label">"Názov miestnosti"</string> <string name="screen_create_room_room_name_label">"Názov miestnosti"</string>
<string name="screen_create_room_room_visibility_section_title">"Viditeľnosť miestnosti"</string>
<string name="screen_create_room_title">"Vytvoriť miestnosť"</string> <string name="screen_create_room_title">"Vytvoriť miestnosť"</string>
<string name="screen_create_room_topic_label">"Téma (voliteľné)"</string> <string name="screen_create_room_topic_label">"Téma (voliteľné)"</string>
<string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string> <string name="screen_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string>

View file

@ -8,7 +8,15 @@
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.
You can change this anytime in room settings."</string> You can change this anytime in room settings."</string>
<string name="screen_create_room_public_option_title">"Public room"</string> <string name="screen_create_room_public_option_title">"Public room"</string>
<string name="screen_create_room_room_access_section_anyone_option_description">"Anyone can join this room"</string>
<string name="screen_create_room_room_access_section_anyone_option_title">"Anyone"</string>
<string name="screen_create_room_room_access_section_header">"Room Access"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Anyone can ask to join the room but an administrator or a moderator will have to accept the request"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Ask to join"</string>
<string name="screen_create_room_room_address_section_footer">"In order for this room to be visible in the public room directory, you will need a room address."</string>
<string name="screen_create_room_room_address_section_title">"Room address"</string>
<string name="screen_create_room_room_name_label">"Room name"</string> <string name="screen_create_room_room_name_label">"Room name"</string>
<string name="screen_create_room_room_visibility_section_title">"Room visibility"</string>
<string name="screen_create_room_title">"Create a room"</string> <string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string> <string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string> <string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>

View file

@ -8,15 +8,16 @@
package io.element.android.features.createroom.impl.configureroom package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode import app.cash.turbine.TurbineTestContext
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
@ -25,13 +26,18 @@ import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -56,33 +62,8 @@ class ConfigureRoomPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private lateinit var presenter: ConfigureRoomPresenter
private lateinit var userListDataStore: UserListDataStore
private lateinit var createRoomDataStore: CreateRoomDataStore
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
@Before @Before
fun setup() { fun setup() {
fakeMatrixClient = FakeMatrixClient()
userListDataStore = UserListDataStore()
createRoomDataStore = CreateRoomDataStore(userListDataStore)
fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService()
fakePermissionsPresenter = FakePermissionsPresenter()
presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
)
mockkStatic(File::readBytes) mockkStatic(File::readBytes)
every { any<File>().readBytes() } returns byteArrayOf() every { any<File>().readBytes() } returns byteArrayOf()
} }
@ -94,50 +75,56 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createConfigureRoomPresenter()
presenter.present() presenter.test {
}.test { val initialState = initialState()
val initialState = awaitItem()
assertThat(initialState.config).isEqualTo(CreateRoomConfig()) assertThat(initialState.config).isEqualTo(CreateRoomConfig())
assertThat(initialState.config.roomName).isNull() assertThat(initialState.config.roomName).isNull()
assertThat(initialState.config.topic).isNull() assertThat(initialState.config.topic).isNull()
assertThat(initialState.config.invites).isEmpty() assertThat(initialState.config.invites).isEmpty()
assertThat(initialState.config.avatarUri).isNull() assertThat(initialState.config.avatarUri).isNull()
assertThat(initialState.config.privacy).isEqualTo(RoomPrivacy.Private) assertThat(initialState.config.roomVisibility).isEqualTo(RoomVisibilityState.Private)
assertThat(initialState.createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(initialState.homeserverName).isEqualTo("matrix.org")
} }
} }
@Test @Test
fun `present - create room button is enabled only if the required fields are completed`() = runTest { fun `present - create room button is enabled only if the required fields are completed`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val presenter = createConfigureRoomPresenter()
presenter.present() presenter.test {
}.test { val initialState = initialState()
val initialState = awaitItem()
var config = initialState.config var config = initialState.config
assertThat(initialState.isCreateButtonEnabled).isFalse() assertThat(initialState.config.isValid).isFalse()
// Room name not empty // Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem() var newState: ConfigureRoomState = awaitItem()
config = config.copy(roomName = A_ROOM_NAME) config = config.copy(roomName = A_ROOM_NAME)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isTrue() assertThat(newState.config.isValid).isTrue()
// Clear room name // Clear room name
newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem() newState = awaitItem()
config = config.copy(roomName = null) config = config.copy(roomName = null)
assertThat(newState.config).isEqualTo(config) assertThat(newState.config).isEqualTo(config)
assertThat(newState.isCreateButtonEnabled).isFalse() assertThat(newState.config.isValid).isFalse()
} }
} }
@Test @Test
fun `present - state is updated when fields are changed`() = runTest { fun `present - state is updated when fields are changed`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val userListDataStore = UserListDataStore()
presenter.present() val pickerProvider = FakePickerProvider()
}.test { val permissionsPresenter = FakePermissionsPresenter()
val initialState = awaitItem() val presenter = createConfigureRoomPresenter(
createRoomDataStore = CreateRoomDataStore(userListDataStore),
pickerProvider = pickerProvider,
permissionsPresenter = permissionsPresenter,
)
presenter.test {
val initialState = initialState()
var expectedConfig = CreateRoomConfig() var expectedConfig = CreateRoomConfig()
assertThat(initialState.config).isEqualTo(expectedConfig) assertThat(initialState.config).isEqualTo(expectedConfig)
@ -165,22 +152,22 @@ class ConfigureRoomPresenterTest {
// Room avatar // Room avatar
// Pick avatar // Pick avatar
fakePickerProvider.givenResult(null) pickerProvider.givenResult(null)
// From gallery // From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery) pickerProvider.givenResult(uriFromGallery)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// From camera // From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera) pickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse() assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue() assertThat(newState.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted() permissionsPresenter.setPermissionGranted()
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue() assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem() newState = awaitItem()
@ -188,7 +175,7 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested // Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2) val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
fakePickerProvider.givenResult(uriFromCamera2) pickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2) expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
@ -200,13 +187,19 @@ class ConfigureRoomPresenterTest {
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Room privacy // Room privacy
newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public)) newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public) expectedConfig = expectedConfig.copy(
roomVisibility = RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(expectedConfig.roomName ?: ""),
roomAddressErrorState = RoomAddressErrorState.None,
roomAccess = RoomAccess.Anyone,
)
)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Remove user // Remove user
newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1)) newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
newState = awaitItem() newState = awaitItem()
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList()) expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
@ -215,15 +208,17 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger create room action`() = runTest { fun `present - trigger create room action`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val presenter = createConfigureRoomPresenter(
}.test { matrixClient = matrixClient
val initialState = awaitItem() )
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
fakeMatrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@ -233,18 +228,22 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - record analytics when creating room`() = runTest { fun `present - record analytics when creating room`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val analyticsService = FakeAnalyticsService()
}.test { val presenter = createConfigureRoomPresenter(
val initialState = awaitItem() matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
val createRoomResult = Result.success(RoomId("!createRoomResult:domain")) val createRoomResult = Result.success(RoomId("!createRoomResult:domain"))
fakeMatrixClient.givenCreateRoomResult(createRoomResult) matrixClient.givenCreateRoomResult(createRoomResult)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
skipItems(2) skipItems(2)
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull() val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
assertThat(analyticsEvent).isNotNull() assertThat(analyticsEvent).isNotNull()
assertThat(analyticsEvent?.isDM).isFalse() assertThat(analyticsEvent?.isDM).isFalse()
} }
@ -252,23 +251,31 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger create room with upload error and retry`() = runTest { fun `present - trigger create room with upload error and retry`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val matrixClient = createMatrixClient()
presenter.present() val analyticsService = FakeAnalyticsService()
}.test { val mediaPreProcessor = FakeMediaPreProcessor()
skipItems(1) val createRoomDataStore = CreateRoomDataStore(UserListDataStore())
val presenter = createConfigureRoomPresenter(
createRoomDataStore = createRoomDataStore,
mediaPreProcessor = mediaPreProcessor,
matrixClient = matrixClient,
analyticsService = analyticsService
)
presenter.test {
val initialState = initialState()
createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) skipItems(1)
fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE)) mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
val initialState = awaitItem() initialState.eventSink(ConfigureRoomEvents.CreateRoom)
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty() assertThat(analyticsService.capturedEvents.filterIsInstance<CreatedRoom>()).isEmpty()
fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
@ -277,23 +284,25 @@ class ConfigureRoomPresenterTest {
@Test @Test
fun `present - trigger retry and cancel actions`() = runTest { fun `present - trigger retry and cancel actions`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { val fakeMatrixClient = createMatrixClient()
presenter.present() val presenter = createConfigureRoomPresenter(
}.test { matrixClient = fakeMatrixClient
val initialState = awaitItem() )
presenter.test {
val initialState = initialState()
val createRoomResult = Result.failure<RoomId>(A_THROWABLE) val createRoomResult = Result.failure<RoomId>(A_THROWABLE)
fakeMatrixClient.givenCreateRoomResult(createRoomResult) fakeMatrixClient.givenCreateRoomResult(createRoomResult)
// Create // Create
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull()) assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
// Retry // Retry
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
val stateAfterRetry = awaitItem() val stateAfterRetry = awaitItem()
@ -305,4 +314,33 @@ class ConfigureRoomPresenterTest {
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
}
private fun createMatrixClient() = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
)
private fun createConfigureRoomPresenter(
createRoomDataStore: CreateRoomDataStore = CreateRoomDataStore(UserListDataStore()),
matrixClient: MatrixClient = createMatrixClient(),
pickerProvider: PickerProvider = FakePickerProvider(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
) = ConfigureRoomPresenter(
dataStore = createRoomDataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
mediaPreProcessor = mediaPreProcessor,
analyticsService = analyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
featureFlagService = FakeFeatureFlagService(
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
)
)
} }

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Bevestig dat je je account wilt sluiten. Deze actie kan niet ongedaan worden gemaakt."</string>
<string name="screen_deactivate_account_delete_all_messages">"Verwijder al mijn berichten"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Waarschuwing: Toekomstige gebruikers kunnen onvolledige gesprekken te zien krijgen."</string>
<string name="screen_deactivate_account_description">"Je account sluiten is %1$s, het zal:"</string>
<string name="screen_deactivate_account_description_bold_part">"onomkeerbaar"</string>
<string name="screen_deactivate_account_list_item_1">"Je account %1$s (je kunt niet opnieuw inloggen en je ID kan niet opnieuw worden gebruikt)"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"permanent uitschakelen"</string>
<string name="screen_deactivate_account_list_item_2">"Je verwijderen uit alle chatrooms."</string>
<string name="screen_deactivate_account_list_item_3">"Je accountgegevens verwijderen van onze identiteitsserver."</string>
<string name="screen_deactivate_account_list_item_4">"Je berichten zijn nog steeds zichtbaar voor geregistreerde gebruikers, maar niet beschikbaar voor nieuwe of niet-geregistreerde gebruikers als je ervoor kiest ze te verwijderen."</string>
<string name="screen_deactivate_account_title">"Account sluiten"</string>
</resources>

View file

@ -45,6 +45,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.analytics.noop)
testImplementation(projects.libraries.permissions.impl) testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)

View file

@ -8,7 +8,10 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
@ -31,12 +34,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
super.onBuilt() super.onBuilt()
lifecycle.subscribe(onCreate = { lifecycle.subscribe(onCreate = {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
}) })
analyticsService.didAskUserConsent() analyticsService.didAskUserConsent()
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach { moveToNextStepIfNeeded() }
lifecycleScope.launch { moveToNextStep() } .launchIn(lifecycleScope)
}
ftueState.isVerificationStatusKnown
.filter { it }
.onEach { moveToNextStepIfNeeded() }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
@ -99,7 +107,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.SessionVerification -> { NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback { val callback = object : FtueSessionVerificationFlowNode.Callback {
override fun onDone() { override fun onDone() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback)) createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
@ -107,7 +115,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.NotificationsOptIn -> { NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback { val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
createNode<NotificationsOptInNode>(buildContext, listOf(callback)) createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupDone() { override fun onSetupDone() {
lifecycleScope.launch { moveToNextStep() } moveToNextStepIfNeeded()
} }
} }
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@ -128,8 +136,11 @@ class FtueFlowNode @AssistedInject constructor(
} }
} }
private fun moveToNextStep() = lifecycleScope.launch { private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
FtueStep.WaitingForInitialState -> {
backstack.newRoot(NavTarget.Placeholder)
}
FtueStep.SessionVerification -> { FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification) backstack.newRoot(NavTarget.SessionVerification)
} }
@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
class PlaceholderNode @AssistedInject constructor( class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins) ) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
} }
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() { private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {

View file

@ -15,6 +15,7 @@ import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@ -23,21 +24,17 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultFtueService @Inject constructor( class DefaultFtueService @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider, private val sdkVersionProvider: BuildVersionSdkIntProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
@ -49,6 +46,14 @@ class DefaultFtueService @Inject constructor(
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
/**
* This flow emits true when the FTUE flow is ready to be displayed.
* In this case, the FTUE flow is ready when the session verification status is known.
*/
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
.map { it != SessionVerifiedStatus.Unknown }
.distinctUntilChanged()
override suspend fun reset() { override suspend fun reset() {
analyticsService.reset() analyticsService.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
@ -69,7 +74,12 @@ class DefaultFtueService @Inject constructor(
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? = suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (isSessionNotVerified()) { null -> if (!isSessionVerificationStateReady()) {
FtueStep.WaitingForInitialState
} else {
getNextStep(FtueStep.WaitingForInitialState)
}
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
FtueStep.SessionVerification FtueStep.SessionVerification
} else { } else {
getNextStep(FtueStep.SessionVerification) getNextStep(FtueStep.SessionVerification)
@ -89,34 +99,18 @@ class DefaultFtueService @Inject constructor(
} else { } else {
getNextStep(FtueStep.AnalyticsOptIn) getNextStep(FtueStep.AnalyticsOptIn)
} }
FtueStep.AnalyticsOptIn -> { FtueStep.AnalyticsOptIn -> null
updateState()
null
}
} }
private suspend fun isAnyStepIncomplete(): Boolean { private fun isSessionVerificationStateReady(): Boolean {
return listOf<suspend () -> Boolean>( return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
{ isSessionNotVerified() },
{ shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() },
{ shouldDisplayLockscreenSetup() },
).any { it() }
} }
@OptIn(FlowPreview::class)
private suspend fun isSessionNotVerified(): Boolean { private suspend fun isSessionNotVerified(): Boolean {
// Wait for the first known (or ready) verification status // Wait until the session verification status is known
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus isVerificationStatusKnown.filter { it }.first()
.filter { it != SessionVerifiedStatus.Unknown }
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
.timeout(5.seconds)
.catch {
Timber.e(it, "Failed to get session verification status, assume it's not verified")
emit(SessionVerifiedStatus.NotVerified)
}
.first()
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
} }
private suspend fun canSkipVerification(): Boolean { private suspend fun canSkipVerification(): Boolean {
@ -130,7 +124,7 @@ class DefaultFtueService @Inject constructor(
private suspend fun shouldAskNotificationPermissions(): Boolean { private suspend fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() } val isPermissionDenied = permissionStateProvider.isPermissionDenied(permission).first()
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission) val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
!isPermissionGranted && !isPermissionDenied !isPermissionGranted && !isPermissionDenied
} else { } else {
@ -144,14 +138,17 @@ class DefaultFtueService @Inject constructor(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun updateState() { internal suspend fun updateState() {
val nextStep = getNextStep()
state.value = when { state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete // Final state, there aren't any more next steps
else -> FtueState.Complete nextStep == null -> FtueState.Complete
else -> FtueState.Incomplete
} }
} }
} }
sealed interface FtueStep { sealed interface FtueStep {
data object WaitingForInitialState : FtueStep
data object SessionVerification : FtueStep data object SessionVerification : FtueStep
data object NotificationsOptIn : FtueStep data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep data object AnalyticsOptIn : FtueStep

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -35,7 +36,7 @@ class DefaultFtueServiceTest {
@Test @Test
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest { fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.Unknown) emitVerifiedStatus(SessionVerifiedStatus.Unknown)
} }
val service = createDefaultFtueService( val service = createDefaultFtueService(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -46,7 +47,7 @@ class DefaultFtueServiceTest {
assertThat(awaitItem()).isEqualTo(FtueState.Unknown) assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
// Verification state is known, we should display the flow if any check is false // Verification state is known, we should display the flow if any check is false
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
assertThat(awaitItem()).isEqualTo(FtueState.Incomplete) assertThat(awaitItem()).isEqualTo(FtueState.Incomplete)
} }
} }
@ -64,7 +65,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -73,10 +74,31 @@ class DefaultFtueServiceTest {
assertThat(service.state.value).isEqualTo(FtueState.Complete) assertThat(service.state.value).isEqualTo(FtueState.Complete)
} }
@Test
fun `given all checks being true with no analytics, FtueState is Complete`() = runTest {
val analyticsService = NoopAnalyticsService()
val sessionVerificationService = FakeSessionVerificationService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val service = createDefaultFtueService(
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
service.updateState()
assertThat(service.state.value).isEqualTo(FtueState.Complete)
}
@Test @Test
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@ -91,7 +113,7 @@ class DefaultFtueServiceTest {
// Session verification // Session verification
steps.add(service.getNextStep(steps.lastOrNull())) steps.add(service.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in // Notifications opt in
steps.add(service.getNextStep(steps.lastOrNull())) steps.add(service.getNextStep(steps.lastOrNull()))
@ -132,7 +154,7 @@ class DefaultFtueServiceTest {
) )
// Skip first 3 steps // Skip first 3 steps
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
@ -155,7 +177,7 @@ class DefaultFtueServiceTest {
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
) )
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified) sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
lockScreenService.setIsPinSetup(true) lockScreenService.setIsPinSetup(true)
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Annuler la demande"</string> <string name="screen_join_room_cancel_knock_action">"Annuler la demande"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Oui, annuler"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Êtes-vous sûr de vouloir annuler votre demande daccès à ce salon?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Annuler la demande dadhésion"</string>
<string name="screen_join_room_join_action">"Rejoindre"</string> <string name="screen_join_room_join_action">"Rejoindre"</string>
<string name="screen_join_room_knock_action">"Demander à joindre"</string> <string name="screen_join_room_knock_action">"Demander à joindre"</string>
<string name="screen_join_room_knock_message_description">"Message (facultatif)"</string> <string name="screen_join_room_knock_message_description">"Message (facultatif)"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Toetreden tot de kamer"</string> <string name="screen_join_room_join_action">"Toetreden tot de kamer"</string>
<string name="screen_join_room_knock_action">"Klop om deel te nemen"</string> <string name="screen_join_room_knock_action">"Klop om deel te nemen"</string>
<string name="screen_join_room_knock_message_description">"Bericht (optioneel)"</string>
<string name="screen_join_room_knock_sent_title">"Verzoek om toe te treden verzonden"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s ondersteunt nog geen spaces. Je kunt spaces benaderen via de webbrowser."</string> <string name="screen_join_room_space_not_supported_description">"%1$s ondersteunt nog geen spaces. Je kunt spaces benaderen via de webbrowser."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces worden nog niet ondersteund"</string> <string name="screen_join_room_space_not_supported_title">"Spaces worden nog niet ondersteund"</string>
<string name="screen_join_room_subtitle_knock">"Klik op de knop hieronder en een kamerbeheerder wordt op de hoogte gebracht. Na goedkeuring kun je deelnemen aan het gesprek."</string> <string name="screen_join_room_subtitle_knock">"Klik op de knop hieronder en een kamerbeheerder wordt op de hoogte gebracht. Na goedkeuring kun je deelnemen aan het gesprek."</string>

View file

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Anuluj prośbę"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Tak, anuluj"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Czy na pewno chcesz anulować prośbę o dołączenie do tego pokoju?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Anuluj prośbę o dołączenie"</string>
<string name="screen_join_room_join_action">"Dołącz do pokoju"</string> <string name="screen_join_room_join_action">"Dołącz do pokoju"</string>
<string name="screen_join_room_knock_action">"Zapukaj, by dołączyć"</string> <string name="screen_join_room_knock_action">"Wyślij prośbę o dołączenie"</string>
<string name="screen_join_room_knock_message_description">"Wiadomość (opcjonalne)"</string>
<string name="screen_join_room_knock_sent_description">"Otrzymasz zaproszenie dołączenia do pokoju, jeśli prośba zostanie zaakceptowana."</string>
<string name="screen_join_room_knock_sent_title">"Wysłano prośbę o dołączenie"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web."</string> <string name="screen_join_room_space_not_supported_description">"%1$s jeszcze nie obsługuje przestrzeni. Uzyskaj dostęp do przestrzeni w wersji web."</string>
<string name="screen_join_room_space_not_supported_title">"Przestrzenie nie są jeszcze obsługiwane"</string> <string name="screen_join_room_space_not_supported_title">"Przestrzenie nie są jeszcze obsługiwane"</string>
<string name="screen_join_room_subtitle_knock">"Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy."</string> <string name="screen_join_room_subtitle_knock">"Kliknij przycisk poniżej, aby powiadomić administratora pokoju. Po zatwierdzeniu będziesz mógł dołączyć do rozmowy."</string>

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_cancel_knock_action">"Cancelar pedido"</string> <string name="screen_join_room_cancel_knock_action">"Cancelar pedido"</string>
<string name="screen_join_room_cancel_knock_alert_confirmation">"Sim, cancelar"</string>
<string name="screen_join_room_cancel_knock_alert_description">"Tens a certeza de que queres cancelar o teu pedido de entrada nesta sala?"</string>
<string name="screen_join_room_cancel_knock_alert_title">"Cancela o pedido de adesão"</string>
<string name="screen_join_room_join_action">"Entrar na sala"</string> <string name="screen_join_room_join_action">"Entrar na sala"</string>
<string name="screen_join_room_knock_action">"Bater à porta"</string> <string name="screen_join_room_knock_action">"Bater à porta"</string>
<string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string> <string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string>

View file

@ -40,15 +40,15 @@
<string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string>
<string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."</string> <string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."</string>
<string name="screen_qr_code_login_device_code_title">"Adja meg az alábbi számot a másik eszközén"</string> <string name="screen_qr_code_login_device_code_title">"Adja meg az alábbi számot a másik eszközén"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Jelentkezzen be a másik eszközére, majd próbálja újra, vagy használjon egy másik eszközt, amelyre már bejelentkezett."</string> <string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Jelentkezzen be másik eszközére, majd próbálkozzon újra, vagy használjon egy másik, már bejelentkezett eszközt."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Más eszköz nincs bejelentkezve"</string> <string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Más eszköz nincs bejelentkezve"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"A bejelentkezés megszakadt a másik eszközön."</string> <string name="screen_qr_code_login_error_cancelled_subtitle">"A bejelentkezést megszakították a másik eszközön."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Bejelentkezési kérés törölve"</string> <string name="screen_qr_code_login_error_cancelled_title">"Bejelentkezési kérés törölve"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"A bejelentkezés el lett utasítva a másik eszközön."</string> <string name="screen_qr_code_login_error_declined_subtitle">"A bejelentkezést elutasították a másik eszközön."</string>
<string name="screen_qr_code_login_error_declined_title">"A bejelentkezés elutasítva"</string> <string name="screen_qr_code_login_error_declined_title">"A bejelentkezés elutasítva"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"A bejelentkezés lejárt. Próbálja újra."</string> <string name="screen_qr_code_login_error_expired_subtitle">"A bejelentkezés lejárt. Próbálja újra."</string>
<string name="screen_qr_code_login_error_expired_title">"A bejelentkezés nem fejeződött be időben"</string> <string name="screen_qr_code_login_error_expired_title">"A bejelentkezés nem fejeződött be időben"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"A másik eszköz nem támogatja a %s QR-kóddal történő bejelentkezést. <string name="screen_qr_code_login_error_linking_not_suported_subtitle">"A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.
Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel."</string> Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"A QR-kód nem támogatott"</string> <string name="screen_qr_code_login_error_linking_not_suported_title">"A QR-kód nem támogatott"</string>

View file

@ -21,6 +21,7 @@
<string name="screen_change_server_form_notice">"Anda hanya dapat terhubung ke server yang ada yang mendukung sinkronisasi geser. Admin homeserver Anda perlu mengaturnya. %1$s"</string> <string name="screen_change_server_form_notice">"Anda hanya dapat terhubung ke server yang ada yang mendukung sinkronisasi geser. Admin homeserver Anda perlu mengaturnya. %1$s"</string>
<string name="screen_change_server_subtitle">"Apa alamat server Anda?"</string> <string name="screen_change_server_subtitle">"Apa alamat server Anda?"</string>
<string name="screen_change_server_title">"Pilih server Anda"</string> <string name="screen_change_server_title">"Pilih server Anda"</string>
<string name="screen_create_account_title">"Buat akun"</string>
<string name="screen_login_error_deactivated_account">"Akun ini telah dinonaktifkan."</string> <string name="screen_login_error_deactivated_account">"Akun ini telah dinonaktifkan."</string>
<string name="screen_login_error_invalid_credentials">"Nama pengguna dan/atau kata sandi salah"</string> <string name="screen_login_error_invalid_credentials">"Nama pengguna dan/atau kata sandi salah"</string>
<string name="screen_login_error_invalid_user_id">"Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'"</string>
@ -59,6 +60,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Pindai kode QR dengan perangkat ini"</string> <string name="screen_qr_code_login_initial_state_item_4">"Pindai kode QR dengan perangkat ini"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Hanya tersedia jika penyedia akun Anda mendukungnya."</string>
<string name="screen_qr_code_login_initial_state_title">"Buka %1$s di perangkat lain untuk mendapatkan kode QR"</string> <string name="screen_qr_code_login_initial_state_title">"Buka %1$s di perangkat lain untuk mendapatkan kode QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string>

View file

@ -21,7 +21,8 @@
<string name="screen_change_server_form_notice">"Je kunt alleen verbinding maken met een bestaande server die sliding sync ondersteunt. De beheerder van de homeserver moet dit configureren. %1$s"</string> <string name="screen_change_server_form_notice">"Je kunt alleen verbinding maken met een bestaande server die sliding sync ondersteunt. De beheerder van de homeserver moet dit configureren. %1$s"</string>
<string name="screen_change_server_subtitle">"Wat is het adres van je server?"</string> <string name="screen_change_server_subtitle">"Wat is het adres van je server?"</string>
<string name="screen_change_server_title">"Selecteer je server"</string> <string name="screen_change_server_title">"Selecteer je server"</string>
<string name="screen_login_error_deactivated_account">"Dit account is gedeactiveerd."</string> <string name="screen_create_account_title">"Account aanmaken"</string>
<string name="screen_login_error_deactivated_account">"Dit account is gesloten."</string>
<string name="screen_login_error_invalid_credentials">"Onjuiste gebruikersnaam en/of wachtwoord"</string> <string name="screen_login_error_invalid_credentials">"Onjuiste gebruikersnaam en/of wachtwoord"</string>
<string name="screen_login_error_invalid_user_id">"Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord."</string> <string name="screen_login_error_refresh_tokens">"Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord."</string>
@ -59,6 +60,7 @@ Probeer handmatig in te loggen, of scan de QR code met een ander apparaat."</str
<string name="screen_qr_code_login_initial_state_item_3">"Selecteer %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Selecteer %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Nieuw apparaat koppelen”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Nieuw apparaat koppelen”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Scan de QR-code met dit apparaat"</string> <string name="screen_qr_code_login_initial_state_item_4">"Scan de QR-code met dit apparaat"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Alleen beschikbaar als je accountprovider dit ondersteunt."</string>
<string name="screen_qr_code_login_initial_state_title">"Open %1$s op een ander apparaat om de QR-code te krijgen"</string> <string name="screen_qr_code_login_initial_state_title">"Open %1$s op een ander apparaat om de QR-code te krijgen"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Gebruik de QR-code die op het andere apparaat wordt weergegeven."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Gebruik de QR-code die op het andere apparaat wordt weergegeven."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Probeer het opnieuw"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Probeer het opnieuw"</string>

View file

@ -60,6 +60,7 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."</st
<string name="screen_qr_code_login_initial_state_item_3">"Wybierz %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Wybierz %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Powiąż nowe urządzenie”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Powiąż nowe urządzenie”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Zeskanuj kod QR za pomocą tego urządzenia"</string> <string name="screen_qr_code_login_initial_state_item_4">"Zeskanuj kod QR za pomocą tego urządzenia"</string>
<string name="screen_qr_code_login_initial_state_subtitle">"Dostępne tylko wtedy, gdy Twój dostawca konta obsługuje tę funkcję."</string>
<string name="screen_qr_code_login_initial_state_title">"Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR"</string> <string name="screen_qr_code_login_initial_state_title">"Otwórz %1$s na innym urządzeniu, aby uzyskać kod QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Użyj kodu QR widocznego na drugim urządzeniu."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Użyj kodu QR widocznego na drugim urządzeniu."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Spróbuj ponownie"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Spróbuj ponownie"</string>

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.features.location.api) implementation(projects.features.location.api)
implementation(projects.features.poll.api) implementation(projects.features.poll.api)
implementation(projects.features.roomcall.api)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

@ -50,6 +50,7 @@ import io.element.android.features.messages.impl.timeline.protection.TimelinePro
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -75,7 +76,6 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>, private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>, private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>, private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
val reactionSummaryState = reactionSummaryPresenter.present() val reactionSummaryState = reactionSummaryPresenter.present()
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present() val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present() val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
val roomCallState = roomCallStatePresenter.present()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
mutableStateOf(false) mutableStateOf(false)
} }
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Remove the unread flag on entering but don't send read receipts // Remove the unread flag on entering but don't send read receipts
// as those will be handled by the timeline. // as those will be handled by the timeline.
@ -204,12 +204,6 @@ class MessagesPresenter @AssistedInject constructor(
} }
} }
val callState = when {
!canJoinCall -> RoomCallState.DISABLED
roomInfo?.hasRoomCall == true -> RoomCallState.ONGOING
else -> RoomCallState.ENABLED
}
return MessagesState( return MessagesState(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomName,
@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING, enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
callState = callState, roomCallState = roomCallState,
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetState
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -46,14 +47,8 @@ data class MessagesState(
val showReinvitePrompt: Boolean, val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean, val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean, val enableVoiceMessages: Boolean,
val callState: RoomCallState, val roomCallState: RoomCallState,
val appName: String, val appName: String,
val pinnedMessagesBannerState: PinnedMessagesBannerState, val pinnedMessagesBannerState: PinnedMessagesBannerState,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )
enum class RoomCallState {
ENABLED,
ONGOING,
DISABLED
}

View file

@ -33,13 +33,15 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roomcall.api.anOngoingCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@ -71,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
), ),
aMessagesState( aMessagesState(
callState = RoomCallState.ONGOING, roomCallState = anOngoingCallState(),
), ),
aMessagesState( aMessagesState(
enableVoiceMessages = true, enableVoiceMessages = true,
@ -81,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
), ),
aMessagesState( aMessagesState(
callState = RoomCallState.DISABLED, roomCallState = aStandByCallState(canStartCall = false),
), ),
aMessagesState( aMessagesState(
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState( pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
@ -97,7 +99,7 @@ fun aMessagesState(
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)), roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
userEventPermissions: UserEventPermissions = aUserEventPermissions(), userEventPermissions: UserEventPermissions = aUserEventPermissions(),
composerState: MessageComposerState = aMessageComposerState( composerState: MessageComposerState = aMessageComposerState(
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal, mode = MessageComposerMode.Normal,
), ),
@ -116,7 +118,7 @@ fun aMessagesState(
hasNetworkConnection: Boolean = true, hasNetworkConnection: Boolean = true,
showReinvitePrompt: Boolean = false, showReinvitePrompt: Boolean = false,
enableVoiceMessages: Boolean = true, enableVoiceMessages: Boolean = true,
callState: RoomCallState = RoomCallState.ENABLED, roomCallState: RoomCallState = aStandByCallState(),
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(), pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
eventSink: (MessagesEvents) -> Unit = {}, eventSink: (MessagesEvents) -> Unit = {},
) = MessagesState( ) = MessagesState(
@ -140,7 +142,7 @@ fun aMessagesState(
showReinvitePrompt = showReinvitePrompt, showReinvitePrompt = showReinvitePrompt,
enableTextFormatting = true, enableTextFormatting = true,
enableVoiceMessages = enableVoiceMessages, enableVoiceMessages = enableVoiceMessages,
callState = callState, roomCallState = roomCallState,
appName = "Element", appName = "Element",
pinnedMessagesBannerState = pinnedMessagesBannerState, pinnedMessagesBannerState = pinnedMessagesBannerState,
eventSink = eventSink, eventSink = eventSink,

View file

@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -69,7 +68,7 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.JoinCallMenuItem import io.element.android.features.messages.impl.timeline.components.CallMenuItem
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@ -81,6 +80,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
@ -93,8 +93,6 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -190,7 +188,7 @@ fun MessagesView(
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes, heroes = state.heroes,
callState = state.callState, roomCallState = state.roomCallState,
onBackClick = { onBackClick = {
// Since the textfield is now based on an Android view, this is no longer done automatically. // Since the textfield is now based on an Android view, this is no longer done automatically.
// We need to hide the keyboard when navigating out of this screen. // We need to hide the keyboard when navigating out of this screen.
@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
heroes: ImmutableList<AvatarData>, heroes: ImmutableList<AvatarData>,
callState: RoomCallState, roomCallState: RoomCallState,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
}, },
actions = { actions = {
CallMenuItem( CallMenuItem(
isCallOngoing = callState == RoomCallState.ONGOING, roomCallState = roomCallState,
onClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
enabled = callState != RoomCallState.DISABLED
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
}, },
@ -519,24 +516,6 @@ private fun MessagesViewTopBar(
) )
} }
@Composable
private fun CallMenuItem(
isCallOngoing: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
if (isCallOngoing) {
JoinCallMenuItem(onJoinCallClick = onClick)
} else {
IconButton(onClick = onClick, enabled = enabled) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
}
@Composable @Composable
private fun RoomAvatarAndNameRow( private fun RoomAvatarAndNameRow(
roomName: String, roomName: String,

View file

@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface AttachmentsPreviewEvents { sealed interface AttachmentsPreviewEvents {
data object SendAttachment : AttachmentsPreviewEvents data object SendAttachment : AttachmentsPreviewEvents
data object Cancel : AttachmentsPreviewEvents
data object ClearSendState : AttachmentsPreviewEvents data object ClearSendState : AttachmentsPreviewEvents
} }

View file

@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.attachment) private val onDoneListener = OnDoneListener {
navigateUp()
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
onDoneListener = onDoneListener,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = this::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -9,16 +9,22 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -29,11 +35,17 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
@Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(attachment: Attachment): AttachmentsPreviewPresenter fun create(
attachment: Attachment,
onDoneListener: OnDoneListener,
): AttachmentsPreviewPresenter
} }
@Composable @Composable
@ -44,11 +56,27 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle) mutableStateOf<SendActionState>(SendActionState.Idle)
} }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
TextEditorState.Markdown(markdownTextEditorState)
)
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState) is AttachmentsPreviewEvents.SendAttachment -> {
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(
attachment = attachment,
caption = caption,
sendActionState = sendActionState,
)
}
AttachmentsPreviewEvents.Cancel -> {
coroutineScope.cancel(attachment)
}
AttachmentsPreviewEvents.ClearSendState -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
it.cancel() it.cancel()
@ -62,26 +90,42 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
return AttachmentsPreviewState( return AttachmentsPreviewState(
attachment = attachment, attachment = attachment,
sendActionState = sendActionState.value, sendActionState = sendActionState.value,
textEditorState = textEditorState,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.sendAttachment( private fun CoroutineScope.sendAttachment(
attachment: Attachment, attachment: Attachment,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = launch { ) = launch {
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( sendMedia(
mediaAttachment = attachment, mediaAttachment = attachment,
caption = caption,
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
} }
} }
private fun CoroutineScope.cancel(
attachment: Attachment,
) = launch {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
onDoneListener()
}
private suspend fun sendMedia( private suspend fun sendMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = runCatching { ) = runCatching {
val context = coroutineContext val context = coroutineContext
@ -96,11 +140,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaSender.sendMedia( mediaSender.sendMedia(
uri = mediaAttachment.localMedia.uri, uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType, mimeType = mediaAttachment.localMedia.info.mimeType,
caption = caption,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
}.fold( }.fold(
onSuccess = { onSuccess = {
sendActionState.value = SendActionState.Done onDoneListener()
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment") Timber.e(error, "Failed to send attachment")

View file

@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.textcomposer.model.TextEditorState
data class AttachmentsPreviewState( data class AttachmentsPreviewState(
val attachment: Attachment, val attachment: Attachment,
val sendActionState: SendActionState, val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val eventSink: (AttachmentsPreviewEvents) -> Unit val eventSink: (AttachmentsPreviewEvents) -> Unit
) ) {
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
it.isMimeTypeImage() || it.isMimeTypeVideo()
}.orFalse()
}
@Immutable @Immutable
sealed interface SendActionState { sealed interface SendActionState {
@ -27,5 +36,4 @@ sealed interface SendActionState {
} }
data class Failure(val error: Throwable) : SendActionState data class Failure(val error: Throwable) : SendActionState
data object Done : SendActionState
} }

View file

@ -12,13 +12,19 @@ import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> { open class AttachmentsPreviewStateProvider : PreviewParameterProvider<AttachmentsPreviewState> {
override val values: Sequence<AttachmentsPreviewState> override val values: Sequence<AttachmentsPreviewState>
get() = sequenceOf( get() = sequenceOf(
anAttachmentsPreviewState(), anAttachmentsPreviewState(),
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
fun anAttachmentsPreviewState( fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(), mediaInfo: MediaInfo = anImageMediaInfo(),
sendActionState: SendActionState = SendActionState.Idle textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
) = AttachmentsPreviewState( ) = AttachmentsPreviewState(
attachment = Attachment.Media( attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo), localMedia = LocalMedia("file://path".toUri(), mediaInfo),
), ),
sendActionState = sendActionState, sendActionState = sendActionState,
textEditorState = textEditorState,
eventSink = {} eventSink = {}
) )

View file

@ -7,65 +7,82 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.textcomposer.TextComposer
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.display.TextDisplay
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.rememberZoomableState
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun postSendAttachment() { fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment) state.eventSink(AttachmentsPreviewEvents.SendAttachment)
} }
fun postCancel() {
state.eventSink(AttachmentsPreviewEvents.Cancel)
}
fun postClearSendState() { fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState) state.eventSink(AttachmentsPreviewEvents.ClearSendState)
} }
if (state.sendActionState is SendActionState.Done) { BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
val latestOnDismiss by rememberUpdatedState(onDismiss) postCancel()
LaunchedEffect(state.sendActionState) {
latestOnDismiss()
}
} }
Scaffold(modifier) { Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = ::postCancel,
)
},
title = {},
)
}
) {
AttachmentPreviewContent( AttachmentPreviewContent(
attachment = state.attachment, state = state,
onSendClick = ::postSendAttachment, onSendClick = ::postSendAttachment,
onDismiss = onDismiss
) )
} }
AttachmentSendStateView( AttachmentSendStateView(
@ -106,21 +123,19 @@ private fun AttachmentSendStateView(
@Composable @Composable
private fun AttachmentPreviewContent( private fun AttachmentPreviewContent(
attachment: Attachment, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
onDismiss: () -> Unit,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding(), .navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
when (attachment) { when (val attachment = state.attachment) {
is Attachment.Media -> { is Attachment.Media -> {
val localMediaViewState = rememberLocalMediaViewState( val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState( zoomableState = rememberZoomableState(
@ -137,27 +152,46 @@ private fun AttachmentPreviewContent(
} }
} }
AttachmentsPreviewBottomActions( AttachmentsPreviewBottomActions(
onCancelClick = onDismiss, state = state,
onSendClick = onSendClick, onSendClick = onSendClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Black.copy(alpha = 0.7f)) .background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 24.dp) .height(IntrinsicSize.Min)
.defaultMinSize(minHeight = 80.dp) .align(Alignment.BottomCenter)
.imePadding(),
) )
} }
} }
@Composable @Composable
private fun AttachmentsPreviewBottomActions( private fun AttachmentsPreviewBottomActions(
onCancelClick: () -> Unit, state: AttachmentsPreviewState,
onSendClick: () -> Unit, onSendClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
ButtonRowMolecule(modifier = modifier) { TextComposer(
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) modifier = modifier,
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) state = state.textEditorState,
} voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Attachment(state.allowCaption),
onRequestFocus = {},
onSendMessage = onSendClick,
showTextFormatting = false,
onResetComposerMode = {},
onAddAttachment = {},
onDismissTextFormatting = {},
enableVoiceMessages = false,
onVoiceRecorderEvent = {},
onVoicePlayerEvent = {},
onSendVoiceMessage = {},
onDeleteVoiceMessage = {},
onReceiveSuggestion = {},
resolveMentionDisplay = { _, _ -> TextDisplay.Plain },
onError = {},
onTyping = {},
onSelectRichContent = {},
)
} }
// Only preview in dark, dark theme is forced on the Node. // Only preview in dark, dark theme is forced on the Node.
@ -166,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = {},
) )
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
fun interface OnDoneListener {
operator fun invoke()
}

View file

@ -15,8 +15,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
@ -41,7 +39,6 @@ import javax.inject.Inject
class IdentityChangeStatePresenter @Inject constructor( class IdentityChangeStatePresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService,
) : Presenter<IdentityChangeState> { ) : Presenter<IdentityChangeState> {
@Composable @Composable
override fun present(): IdentityChangeState { override fun present(): IdentityChangeState {
@ -64,11 +61,7 @@ class IdentityChangeStatePresenter @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() { private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.IdentityPinningViolationNotifications) room.syncUpdateFlow
.filter { it }
.flatMapLatest {
room.syncUpdateFlow
}
.filter { .filter {
// Room cannot become unencrypted, so we can just apply a filter here. // Room cannot become unencrypted, so we can just apply a filter here.
room.isEncrypted room.isEncrypted

View file

@ -14,8 +14,7 @@ import io.element.android.features.messages.impl.aMessagesState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.TextEditorState
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@ -25,11 +24,9 @@ internal fun MessagesViewWithIdentityChangePreview(
MessagesView( MessagesView(
state = aMessagesState( state = aMessagesState(
composerState = aMessageComposerState( composerState = aMessageComposerState(
textEditorState = TextEditorState.Markdown( textEditorState = aTextEditorStateMarkdown(
state = MarkdownTextEditorState( initialText = "",
initialText = "", initialFocus = false,
initialFocus = false,
)
) )
), ),
identityChangeState = identityChangeState, identityChangeState = identityChangeState,

View file

@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
// Reset composer right away // Reset composer right away
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit) resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
when (capturedMode) { when (capturedMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> room.sendMessage( is MessageComposerMode.Normal -> room.sendMessage(
body = message.markdown, body = message.markdown,
htmlBody = message.html, htmlBody = message.html,
@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
): ComposerDraft? { ): ComposerDraft? {
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false) val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
val draftType = when (val mode = messageComposerContext.composerMode) { val draftType = when (val mode = messageComposerContext.composerMode) {
is MessageComposerMode.Attachment,
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
is MessageComposerMode.Edit -> { is MessageComposerMode.Edit -> {
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) } mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }

View file

@ -8,10 +8,10 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -24,7 +24,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
} }
fun aMessageComposerState( fun aMessageComposerState(
textEditorState: TextEditorState = TextEditorState.Rich(aRichTextEditorState()), textEditorState: TextEditorState = aTextEditorStateRich(),
isFullScreen: Boolean = false, isFullScreen: Boolean = false,
mode: MessageComposerMode = MessageComposerMode.Normal, mode: MessageComposerMode = MessageComposerMode.Normal,
showTextFormatting: Boolean = false, showTextFormatting: Boolean = false,

View file

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -89,7 +90,8 @@ class PinnedMessagesListPresenter @AssistedInject constructor(
// We don't need to compute those values // We don't need to compute those values
userHasPermissionToSendMessage = false, userHasPermissionToSendMessage = false,
userHasPermissionToSendReaction = false, userHasPermissionToSendReaction = false,
isCallOngoing = false, // We do not care about the call state here.
roomCallState = aStandByCallState(),
// don't compute this value or the pin icon will be shown // don't compute this value or the pin icon will be shown
pinnedEventIds = emptyList(), pinnedEventIds = emptyList(),
typingNotificationState = TypingNotificationState( typingNotificationState = TypingNotificationState(

View file

@ -32,8 +32,8 @@ import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
@ -73,6 +73,7 @@ class TimelinePresenter @AssistedInject constructor(
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(), private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>, private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
private val typingNotificationPresenter: Presenter<TypingNotificationState>, private val typingNotificationPresenter: Presenter<TypingNotificationState>,
private val roomCallStatePresenter: Presenter<RoomCallState>,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
} }
val typingNotificationState = typingNotificationPresenter.present() val typingNotificationState = typingNotificationPresenter.present()
val timelineRoomInfo by remember(typingNotificationState) { val roomCallState = roomCallStatePresenter.present()
val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
derivedStateOf { derivedStateOf {
TimelineRoomInfo( TimelineRoomInfo(
name = room.displayName, name = room.displayName,
isDm = room.isDm, isDm = room.isDm,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = userHasPermissionToSendReaction, userHasPermissionToSendReaction = userHasPermissionToSendReaction,
isCallOngoing = roomInfo?.hasRoomCall.orFalse(), roomCallState = roomCallState,
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(), pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
) )

View file

@ -12,6 +12,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.resolve.Reso
import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.NewEventState
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
@ -73,7 +74,7 @@ data class TimelineRoomInfo(
val name: String?, val name: String?,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToSendReaction: Boolean, val userHasPermissionToSendReaction: Boolean,
val isCallOngoing: Boolean, val roomCallState: RoomCallState,
val pinnedEventIds: List<EventId>, val pinnedEventIds: List<EventId>,
val typingNotificationState: TypingNotificationState, val typingNotificationState: TypingNotificationState,
) )

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.typing.TypingNotificationState import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.aTypingNotificationState import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
name = name, name = name,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToSendReaction = true, userHasPermissionToSendReaction = true,
isCallOngoing = false, roomCallState = aStandByCallState(),
pinnedEventIds = pinnedEventIds, pinnedEventIds = pinnedEventIds,
typingNotificationState = typingNotificationState, typingNotificationState = typingNotificationState,
) )

View file

@ -0,0 +1,120 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
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
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun CallMenuItem(
roomCallState: RoomCallState,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (roomCallState) {
is RoomCallState.StandBy -> {
StandByCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
is RoomCallState.OnGoing -> {
OnGoingCallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
modifier = modifier,
)
}
}
}
@Composable
private fun StandByCallMenuItem(
roomCallState: RoomCallState.StandBy,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
modifier = modifier,
onClick = onJoinCallClick,
enabled = roomCallState.canStartCall,
) {
Icon(
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = stringResource(CommonStrings.a11y_start_call),
)
}
}
@Composable
private fun OnGoingCallMenuItem(
roomCallState: RoomCallState.OnGoing,
onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!roomCallState.isUserLocallyInTheCall) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = modifier.heightIn(min = 36.dp),
enabled = roomCallState.canJoinCall,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
} else {
// Else user is already in the call, hide the button.
Box(modifier)
}
}
@PreviewsDayNight
@Composable
internal fun CallMenuItemPreview(
@PreviewParameter(RoomCallStateProvider::class) roomCallState: RoomCallState
) = ElementPreview {
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = {}
)
}

View file

@ -0,0 +1,14 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
enum class ContentPadding {
Textual,
Media,
CaptionedMedia
}

View file

@ -1,52 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun JoinCallMenuItem(
onJoinCallClick: () -> Unit,
) {
Button(
onClick = onJoinCallClick,
colors = ButtonDefaults.buttonColors(
contentColor = ElementTheme.colors.bgCanvasDefault,
containerColor = ElementTheme.colors.iconAccentTertiary
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
modifier = Modifier.heightIn(min = 36.dp),
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(CommonStrings.action_join),
style = ElementTheme.typography.fontBodyMdMedium
)
Spacer(Modifier.width(8.dp))
}
}

View file

@ -31,6 +31,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.RoomCallStateProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun TimelineItemCallNotifyView( internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event, event: TimelineItem.Event,
isCallOngoing: Boolean, roomCallState: RoomCallState,
onLongClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
) )
} }
} }
if (isCallOngoing) { if (roomCallState is RoomCallState.OnGoing) {
JoinCallMenuItem(onJoinCallClick) CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
)
} else { } else {
Text( Text(
text = event.sentTime, text = event.sentTime,
@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
internal fun TimelineItemCallNotifyViewPreview() { internal fun TimelineItemCallNotifyViewPreview() {
ElementPreview { ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
TimelineItemCallNotifyView( RoomCallStateProvider().values.forEach { roomCallState ->
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()), TimelineItemCallNotifyView(
isCallOngoing = true, event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
onLongClick = {}, roomCallState = roomCallState,
onJoinCallClick = {}, onLongClick = {},
) onJoinCallClick = {},
TimelineItemCallNotifyView( )
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()), }
isCallOngoing = false,
onLongClick = {},
onJoinCallClick = {},
)
} }
} }
} }

View file

@ -325,7 +325,12 @@ private fun TimelineItemEventRowContent(
MessageEventBubble( MessageEventBubble(
modifier = Modifier modifier = Modifier
.constrainAs(message) { .constrainAs(message) {
top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE) val topMargin = if (bubbleState.cutTopStart) {
NEGATIVE_MARGIN_FOR_BUBBLE
} else {
0.dp
}
top.linkTo(sender.bottom, margin = topMargin)
if (event.isMine) { if (event.isMine) {
end.linkTo(parent.end, margin = 16.dp) end.linkTo(parent.end, margin = 16.dp)
} else { } else {
@ -522,32 +527,33 @@ private fun MessageEventBubbleContent(
fun CommonLayout( fun CommonLayout(
timestampPosition: TimestampPosition, timestampPosition: TimestampPosition,
showThreadDecoration: Boolean, showThreadDecoration: Boolean,
paddingBehaviour: ContentPadding,
inReplyToDetails: InReplyToDetails?, inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canShrinkContent: Boolean = false, canShrinkContent: Boolean = false,
) { ) {
val timestampLayoutModifier: Modifier val timestampLayoutModifier =
val contentModifier: Modifier if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) {
when { Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
inReplyToDetails != null -> { } else {
if (timestampPosition == TimestampPosition.Overlay) { Modifier
timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp) }
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
val topPadding = if (inReplyToDetails != null) 0.dp else 8.dp
val contentModifier = when (paddingBehaviour) {
ContentPadding.Textual ->
Modifier.padding(start = 12.dp, end = 12.dp, top = topPadding, bottom = 8.dp)
ContentPadding.Media -> {
if (inReplyToDetails == null) {
Modifier
} else { } else {
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp) Modifier.clip(RoundedCornerShape(10.dp))
timestampLayoutModifier = Modifier
} }
} }
timestampPosition != TimestampPosition.Overlay -> { ContentPadding.CaptionedMedia ->
timestampLayoutModifier = Modifier Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
contentModifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
}
else -> {
timestampLayoutModifier = Modifier
contentModifier = Modifier
}
} }
val threadDecoration = @Composable { val threadDecoration = @Composable {
if (showThreadDecoration) { if (showThreadDecoration) {
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp)) ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
@ -601,9 +607,17 @@ private fun MessageEventBubbleContent(
is TimelineItemPollContent -> TimestampPosition.Below is TimelineItemPollContent -> TimestampPosition.Below
else -> TimestampPosition.Default else -> TimestampPosition.Default
} }
val paddingBehaviour = when (event.content) {
is TimelineItemImageContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemVideoContent -> if (event.content.showCaption) ContentPadding.CaptionedMedia else ContentPadding.Media
is TimelineItemStickerContent,
is TimelineItemLocationContent -> ContentPadding.Media
else -> ContentPadding.Textual
}
CommonLayout( CommonLayout(
showThreadDecoration = event.isThreaded, showThreadDecoration = event.isThreaded,
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
paddingBehaviour = paddingBehaviour,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier.semantics(mergeDescendants = true) { modifier = bubbleModifier.semantics(mergeDescendants = true) {

View file

@ -50,7 +50,9 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
isMine = it, isMine = it,
timelineItemReactions = aTimelineItemReactions(count = 0), timelineItemReactions = aTimelineItemReactions(count = 0),
content = aTimelineItemImageContent( content = aTimelineItemImageContent(
aspectRatio = 2.5f aspectRatio = 2.5f,
filename = "image.jpg",
caption = "A reply with an image.",
), ),
inReplyTo = inReplyToDetails, inReplyTo = inReplyToDetails,
displayNameAmbiguous = displayNameAmbiguous, displayNameAmbiguous = displayNameAmbiguous,

View file

@ -105,7 +105,7 @@ internal fun TimelineItemRow(
TimelineItemCallNotifyView( TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
event = timelineItem, event = timelineItem,
isCallOngoing = timelineRoomInfo.isCallOngoing, roomCallState = timelineRoomInfo.roomCallState,
onLongClick = onLongClick, onLongClick = onLongClick,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
) )

View file

@ -51,7 +51,6 @@ import io.element.android.libraries.designsystem.components.blurhash.blurHashBac
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@ -69,9 +68,7 @@ fun TimelineItemImageView(
modifier = modifier.semantics { contentDescription = description }, modifier = modifier.semantics { contentDescription = description },
) { ) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier.clip(RoundedCornerShape(10.dp))
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
} else { } else {
Modifier Modifier
} }
@ -88,13 +85,7 @@ fun TimelineItemImageView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData( model = content.thumbnailMediaRequestData,
source = content.preferredMediaSource,
kind = MediaRequestData.Kind.File(
fileName = content.filename,
mimeType = content.mimeType,
),
),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
alignment = Alignment.Center, alignment = Alignment.Center,
contentDescription = description, contentDescription = description,
@ -119,6 +110,7 @@ fun TimelineItemImageView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText( EditorStyledText(
modifier = Modifier modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption, text = caption,
style = ElementRichTextEditorStyle.textStyle(), style = ElementRichTextEditorStyle.textStyle(),

View file

@ -57,6 +57,8 @@ import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -70,14 +72,14 @@ fun TimelineItemVideoView(
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image) val description = stringResource(CommonStrings.common_video)
Column( Column(
modifier = modifier.semantics { contentDescription = description } modifier = modifier.semantics { contentDescription = description }
) { ) {
val containerModifier = if (content.showCaption) { val containerModifier = if (content.showCaption) {
Modifier Modifier
.padding(top = 6.dp) .padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
} else { } else {
Modifier Modifier
} }
@ -93,13 +95,13 @@ fun TimelineItemVideoView(
var isLoaded by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier), .then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = MediaRequestData( model = MediaRequestData(
source = content.thumbnailSource, source = content.thumbnailSource,
kind = MediaRequestData.Kind.File( kind = MediaRequestData.Kind.Thumbnail(
fileName = content.filename, width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
mimeType = content.mimeType height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
) )
), ),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
@ -137,6 +139,7 @@ fun TimelineItemVideoView(
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
EditorStyledText( EditorStyledText(
modifier = Modifier modifier = Modifier
.padding(horizontal = 4.dp) // This is (12.dp - 8.dp) contentPadding from CommonLayout
.widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio), .widthIn(min = MIN_HEIGHT_IN_DP.dp * aspectRatio, max = MAX_HEIGHT_IN_DP.dp * aspectRatio),
text = caption, text = caption,
style = ElementRichTextEditorStyle.textStyle(), style = ElementRichTextEditorStyle.textStyle(),

View file

@ -93,6 +93,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
blurhash = messageType.info?.blurhash, blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0), formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
@ -146,6 +148,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream, mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(), width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(), height = messageType.info?.height?.toInt(),
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
duration = messageType.info?.duration ?: Duration.ZERO, duration = messageType.info?.duration ?: Duration.ZERO,
blurHash = messageType.info?.blurhash, blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import java.util.TimeZone
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> { open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
override val values: Sequence<AggregatedReaction> override val values: Sequence<AggregatedReaction>
@ -29,7 +30,9 @@ fun anAggregatedReaction(
count: Int = 1, count: Int = 1,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
): AggregatedReaction { ): AggregatedReaction {
val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US) val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val date = Date(1_689_061_264L) val date = Date(1_689_061_264L)
val senders = buildList { val senders = buildList {
repeat(count) { index -> repeat(count) { index ->

View file

@ -7,9 +7,12 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT
import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH
import io.element.android.libraries.matrix.ui.media.MediaRequestData
data class TimelineItemImageContent( data class TimelineItemImageContent(
override val filename: String, override val filename: String,
@ -23,15 +26,31 @@ data class TimelineItemImageContent(
val blurhash: String?, val blurhash: String?,
val width: Int?, val width: Int?,
val height: Int?, val height: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val aspectRatio: Float? val aspectRatio: Float?
) : TimelineItemEventContentWithAttachment { ) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent" override val type: String = "TimelineItemImageContent"
val showCaption = caption != null val showCaption = caption != null
val preferredMediaSource = if (mimeType == MimeTypes.Gif) { val thumbnailMediaRequestData: MediaRequestData by lazy {
mediaSource if (mimeType.isMimeTypeAnimatedImage()) {
} else { MediaRequestData(
thumbnailSource ?: mediaSource source = mediaSource,
kind = MediaRequestData.Kind.File(
fileName = filename,
mimeType = mimeType
)
)
} else {
MediaRequestData(
source = thumbnailSource ?: mediaSource,
kind = MediaRequestData.Kind.Thumbnail(
width = thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
height = thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT
),
)
}
} }
} }

View file

@ -37,6 +37,8 @@ fun aTimelineItemImageContent(
blurhash = blurhash, blurhash = blurhash,
width = null, width = null,
height = 300, height = 300,
thumbnailWidth = null,
thumbnailHeight = 150,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
formattedFileSize = "4MB", formattedFileSize = "4MB",
fileExtension = "jpg" fileExtension = "jpg"

View file

@ -22,6 +22,8 @@ data class TimelineItemVideoContent(
val blurHash: String?, val blurHash: String?,
val height: Int?, val height: Int?,
val width: Int?, val width: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val mimeType: String, val mimeType: String,
val formattedFileSize: String, val formattedFileSize: String,
val fileExtension: String, val fileExtension: String,

View file

@ -35,8 +35,10 @@ fun aTimelineItemVideoContent(
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
duration = 100.milliseconds, duration = 100.milliseconds,
videoSource = MediaSource(""), videoSource = MediaSource(""),
height = 300,
width = 150, width = 150,
height = 300,
thumbnailWidth = 150,
thumbnailHeight = 300,
mimeType = MimeTypes.Mp4, mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB", formattedFileSize = "14MB",
fileExtension = "mp4" fileExtension = "mp4"

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