Merge branch 'release/0.7.3' into main
This commit is contained in:
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
@@ -79,7 +79,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
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'
|
||||
with:
|
||||
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
|
||||
|
||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.20" />
|
||||
<option name="version" value="2.0.21" />
|
||||
</component>
|
||||
</project>
|
||||
16
CHANGES.md
16
CHANGES.md
@@ -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)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
# 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).
|
||||
|
||||
@@ -71,7 +71,7 @@ We're doing this as a way to share code between platforms and while we've seen p
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ android {
|
||||
} else {
|
||||
"io.element.android.x"
|
||||
}
|
||||
targetSdk = Versions.targetSdk
|
||||
versionCode = Versions.versionCode
|
||||
versionName = Versions.versionName
|
||||
targetSdk = Versions.TARGET_SDK
|
||||
versionCode = Versions.VERSION_CODE
|
||||
versionName = Versions.VERSION_NAME
|
||||
|
||||
// Keep abiFilter for the universalApk
|
||||
ndk {
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
<!-- To be able to install APK from the application -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- Do not enable enableOnBackInvokedCallback until https://issuetracker.google.com/issues/271303558 is fixed -->
|
||||
<application
|
||||
android:name=".ElementXApplication"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -25,8 +25,10 @@ import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
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.share.api.ShareEntryPoint
|
||||
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.BaseFlowNode
|
||||
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.permalink.PermalinkData
|
||||
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.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -99,6 +104,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sendingQueue: SendQueues,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
private val enableNativeSlidingSyncUseCase: EnableNativeSlidingSyncUseCase,
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
@@ -123,6 +129,12 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
matrixClient.roomMembershipObserver(),
|
||||
)
|
||||
|
||||
private val verificationListener = object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(sessionVerificationRequestDetails: SessionVerificationRequestDetails) {
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(sessionVerificationRequestDetails))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.subscribe(
|
||||
@@ -131,6 +143,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
// TODO We do not support Space yet, so directly navigate to main space
|
||||
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
|
||||
loggedInFlowProcessor.observeEvents(coroutineScope)
|
||||
matrixClient.sessionVerificationService().setListener(verificationListener)
|
||||
|
||||
ftueService.state
|
||||
.onEach { ftueState ->
|
||||
@@ -152,6 +165,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
matrixClient.sessionVerificationService().setListener(null)
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
@@ -232,6 +246,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object LogoutForNativeSlidingSyncMigrationNeeded : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class IncomingVerificationRequest(val data: SessionVerificationRequestDetails) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -260,7 +277,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun onSetUpRecoveryClick() {
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.SetUpRecovery))
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
|
||||
}
|
||||
|
||||
override fun onSessionConfirmRecoveryKeyClick() {
|
||||
@@ -432,6 +449,16 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.IncomingVerificationRequest -> {
|
||||
incomingVerificationEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(IncomingVerificationEntryPoint.Params(navTarget.data))
|
||||
.callback(object : IncomingVerificationEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
|
||||
|
||||
@SingleIn(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 restoreMutex = Mutex()
|
||||
|
||||
init {
|
||||
authenticationService.listenToNewMatrixClients { matrixClient ->
|
||||
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll() {
|
||||
sessionIdsToMatrixClient.clear()
|
||||
}
|
||||
|
||||
5
appnav/src/main/res/values-nl/translations.xml
Normal file
5
appnav/src/main/res/values-nl/translations.xml
Normal 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 & 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>
|
||||
@@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
|
||||
matrixClientsHolder.restoreWithSavedState(savedStateMap)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ allprojects {
|
||||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||
}
|
||||
dependencies {
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.16")
|
||||
detektPlugins("io.nlopez.compose.rules:detekt:0.4.17")
|
||||
}
|
||||
|
||||
// KtLint
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/40007030.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40007030.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: TODO.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -183,6 +183,7 @@ private fun WebView.setup(
|
||||
allowFileAccess = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
@Suppress("DEPRECATION")
|
||||
databaseEnabled = true
|
||||
loadsImagesAutomatically = true
|
||||
userAgentString = userAgent
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.core.content.IntentCompat
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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.di.CallBindings
|
||||
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.utils.CallIntentDataParser
|
||||
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.preferences.api.store.AppPreferencesStore
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private val loggerTag = LoggerTag("ElementCallActivity")
|
||||
|
||||
class ElementCallActivity :
|
||||
AppCompatActivity(),
|
||||
CallScreenNavigator,
|
||||
@@ -132,7 +136,7 @@ class ElementCallActivity :
|
||||
DisposableEffect(Unit) {
|
||||
val listener = Runnable {
|
||||
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 {
|
||||
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
|
||||
}
|
||||
@@ -146,7 +150,7 @@ class ElementCallActivity :
|
||||
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
|
||||
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -185,23 +189,23 @@ class ElementCallActivity :
|
||||
|
||||
private fun setCallType(intent: Intent?) {
|
||||
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)
|
||||
when {
|
||||
// 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()
|
||||
callType != null -> {
|
||||
webViewTarget.value = callType
|
||||
presenter = presenterFactory.create(callType, this)
|
||||
}
|
||||
intentUrl != null -> {
|
||||
val fallbackInputs = CallType.ExternalUrl(intentUrl)
|
||||
webViewTarget.value = fallbackInputs
|
||||
presenter = presenterFactory.create(fallbackInputs, this)
|
||||
}
|
||||
// Coming back from notification, do nothing
|
||||
else -> return
|
||||
val currentCallType = webViewTarget.value
|
||||
if (currentCallType == null && callType == null) {
|
||||
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
|
||||
finish()
|
||||
} else if (currentCallType == null) {
|
||||
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
|
||||
webViewTarget.value = callType
|
||||
presenter = presenterFactory.create(callType!!, this)
|
||||
} else if (callType != currentCallType) {
|
||||
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
|
||||
setIntent(intent)
|
||||
recreate()
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.ElementCallConfig
|
||||
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.RingingCallNotificationCreator
|
||||
import io.element.android.libraries.di.AppScope
|
||||
@@ -82,6 +83,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
|
||||
private val notificationManagerCompat: NotificationManagerCompat,
|
||||
private val matrixClientProvider: MatrixClientProvider,
|
||||
private val defaultCurrentCallService: DefaultCurrentCallService,
|
||||
) : ActiveCallManager {
|
||||
private var timedOutCallJob: Job? = null
|
||||
|
||||
@@ -89,6 +91,7 @@ class DefaultActiveCallManager @Inject constructor(
|
||||
|
||||
init {
|
||||
observeRingingCall()
|
||||
observeCurrentCall()
|
||||
}
|
||||
|
||||
override fun registerIncomingCall(notificationData: CallNotificationData) {
|
||||
@@ -209,6 +212,28 @@ class DefaultActiveCallManager @Inject constructor(
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.CallState
|
||||
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.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -299,5 +300,6 @@ class DefaultActiveCallManagerTest {
|
||||
),
|
||||
notificationManagerCompat = notificationManagerCompat,
|
||||
matrixClientProvider = matrixClientProvider,
|
||||
defaultCurrentCallService = DefaultCurrentCallService(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -40,6 +40,7 @@ dependencies {
|
||||
implementation(projects.libraries.usersearch.impl)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
api(projects.features.createroom.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
@@ -56,6 +57,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.usersearch.test)
|
||||
testImplementation(projects.features.createroom.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
package io.element.android.features.createroom.impl
|
||||
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -18,5 +18,7 @@ data class CreateRoomConfig(
|
||||
val topic: String? = null,
|
||||
val avatarUri: Uri? = null,
|
||||
val invites: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
val privacy: RoomPrivacy = RoomPrivacy.Private,
|
||||
)
|
||||
val roomVisibility: RoomVisibilityState = RoomVisibilityState.Private,
|
||||
) {
|
||||
val isValid = roomName.isNullOrEmpty().not() && roomVisibility.isValid()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
package io.element.android.features.createroom.impl
|
||||
|
||||
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.userlist.UserListDataStore
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -31,28 +37,89 @@ class CreateRoomDataStore @Inject constructor(
|
||||
field = value
|
||||
}
|
||||
|
||||
fun getCreateRoomConfig(): Flow<CreateRoomConfig> = combine(
|
||||
val createRoomConfigWithInvites: Flow<CreateRoomConfig> = combine(
|
||||
selectedUserListDataStore.selectedUsers(),
|
||||
createRoomConfigFlow,
|
||||
) { selectedUsers, config ->
|
||||
config.copy(invites = selectedUsers.toImmutableList())
|
||||
}
|
||||
|
||||
fun setRoomName(roomName: String?) {
|
||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() }))
|
||||
fun setRoomName(roomName: String) {
|
||||
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?) {
|
||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() }))
|
||||
fun setTopic(topic: String) {
|
||||
createRoomConfigFlow.getAndUpdate { config ->
|
||||
config.copy(topic = topic.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
}
|
||||
|
||||
fun setAvatarUri(uri: Uri?, cached: Boolean = false) {
|
||||
cachedAvatarUri = uri.takeIf { cached }
|
||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri))
|
||||
createRoomConfigFlow.getAndUpdate { config ->
|
||||
config.copy(avatarUri = uri)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPrivacy(privacy: RoomPrivacy) {
|
||||
createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy))
|
||||
fun setRoomVisibility(visibility: RoomVisibilityItem) {
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,17 @@
|
||||
|
||||
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.ui.media.AvatarAction
|
||||
|
||||
sealed interface ConfigureRoomEvents {
|
||||
data class RoomNameChanged(val name: String) : ConfigureRoomEvents
|
||||
data class TopicChanged(val topic: String) : ConfigureRoomEvents
|
||||
data class RoomPrivacyChanged(val privacy: RoomPrivacy) : ConfigureRoomEvents
|
||||
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
|
||||
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
|
||||
data class RoomVisibilityChanged(val visibilityItem: RoomVisibilityItem) : ConfigureRoomEvents
|
||||
data class RoomAccessChanged(val roomAccess: RoomAccessItem) : 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 object CancelCreateRoom : ConfigureRoomEvents
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
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.core.RoomId
|
||||
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.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class ConfigureRoomPresenter @Inject constructor(
|
||||
@@ -47,6 +50,7 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val analyticsService: AnalyticsService,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<ConfigureRoomState> {
|
||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||
private var pendingPermissionRequest = false
|
||||
@@ -54,7 +58,9 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
@Composable
|
||||
override fun present(): ConfigureRoomState {
|
||||
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(
|
||||
onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) },
|
||||
@@ -92,9 +98,11 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
when (event) {
|
||||
is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name)
|
||||
is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic)
|
||||
is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy)
|
||||
is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
|
||||
is ConfigureRoomEvents.CreateRoom -> createRoom(event.config)
|
||||
is ConfigureRoomEvents.RoomVisibilityChanged -> dataStore.setRoomVisibility(event.visibilityItem)
|
||||
is ConfigureRoomEvents.RemoveUserFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser)
|
||||
is ConfigureRoomEvents.RoomAccessChanged -> dataStore.setRoomAccess(event.roomAccess)
|
||||
is ConfigureRoomEvents.RoomAddressChanged -> dataStore.setRoomAddress(event.roomAddress)
|
||||
is ConfigureRoomEvents.CreateRoom -> createRoom(createRoomConfig.value)
|
||||
is ConfigureRoomEvents.HandleAvatarAction -> {
|
||||
when (event.action) {
|
||||
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
|
||||
@@ -113,10 +121,12 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
}
|
||||
|
||||
return ConfigureRoomState(
|
||||
isKnockFeatureEnabled = isKnockFeatureEnabled,
|
||||
config = createRoomConfig.value,
|
||||
avatarActions = avatarActions,
|
||||
createRoomAction = createRoomAction.value,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
homeserverName = homeserverName,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -127,26 +137,50 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
) = launch {
|
||||
suspend {
|
||||
val avatarUrl = config.avatarUri?.let { uploadAvatar(it) }
|
||||
val params = CreateRoomParameters(
|
||||
name = config.roomName,
|
||||
topic = config.topic,
|
||||
isEncrypted = config.privacy == RoomPrivacy.Private,
|
||||
isDirect = false,
|
||||
visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE,
|
||||
preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT,
|
||||
invite = config.invites.map { it.userId },
|
||||
avatar = avatarUrl,
|
||||
)
|
||||
matrixClient.createRoom(params).getOrThrow()
|
||||
.also {
|
||||
val params = if (config.roomVisibility is RoomVisibilityState.Public) {
|
||||
CreateRoomParameters(
|
||||
name = config.roomName,
|
||||
topic = config.topic,
|
||||
isEncrypted = false,
|
||||
isDirect = false,
|
||||
visibility = RoomVisibility.PUBLIC,
|
||||
joinRuleOverride = config.roomVisibility.roomAccess.toJoinRule(),
|
||||
preset = RoomPreset.PUBLIC_CHAT,
|
||||
invite = config.invites.map { it.userId },
|
||||
avatar = avatarUrl,
|
||||
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()
|
||||
analyticsService.capture(CreatedRoom(isDM = false))
|
||||
}
|
||||
.getOrThrow()
|
||||
}.runCatchingUpdatingState(createRoomAction)
|
||||
}
|
||||
|
||||
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()
|
||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ConfigureRoomState(
|
||||
val isKnockFeatureEnabled: Boolean,
|
||||
val config: CreateRoomConfig,
|
||||
val avatarActions: ImmutableList<AvatarAction>,
|
||||
val createRoomAction: AsyncAction<RoomId>,
|
||||
val cameraPermissionState: PermissionsState,
|
||||
val homeserverName: String,
|
||||
val eventSink: (ConfigureRoomEvents) -> Unit
|
||||
) {
|
||||
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,30 +10,59 @@ package io.element.android.features.createroom.impl.configureroom
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
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.media.AvatarAction
|
||||
import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
||||
override val values: Sequence<ConfigureRoomState>
|
||||
get() = sequenceOf(
|
||||
aConfigureRoomState(),
|
||||
aConfigureRoomState().copy(
|
||||
aConfigureRoomState(
|
||||
isKnockFeatureEnabled = false,
|
||||
config = CreateRoomConfig(
|
||||
roomName = "Room 101",
|
||||
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines",
|
||||
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 shouldn’t 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(
|
||||
config = CreateRoomConfig(),
|
||||
avatarActions = persistentListOf(),
|
||||
createRoomAction = AsyncAction.Uninitialized,
|
||||
cameraPermissionState = aPermissionsState(showDialog = false),
|
||||
eventSink = { },
|
||||
fun aConfigureRoomState(
|
||||
config: CreateRoomConfig = CreateRoomConfig(),
|
||||
isKnockFeatureEnabled: Boolean = true,
|
||||
avatarActions: List<AvatarAction> = emptyList(),
|
||||
createRoomAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -11,9 +11,11 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -33,18 +36,22 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.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.async.AsyncActionView
|
||||
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.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
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.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
@@ -72,11 +79,11 @@ fun ConfigureRoomView(
|
||||
modifier = modifier.clearFocusOnTap(focusManager),
|
||||
topBar = {
|
||||
ConfigureRoomToolbar(
|
||||
isNextActionEnabled = state.isCreateButtonEnabled,
|
||||
isNextActionEnabled = state.config.isValid,
|
||||
onBackClick = onBackClick,
|
||||
onNextClick = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(ConfigureRoomEvents.CreateRoom(state.config))
|
||||
state.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -103,23 +110,42 @@ fun ConfigureRoomView(
|
||||
)
|
||||
if (state.config.invites.isNotEmpty()) {
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
selectedUsers = state.config.invites,
|
||||
onUserRemove = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it))
|
||||
state.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(it))
|
||||
},
|
||||
)
|
||||
}
|
||||
RoomPrivacyOptions(
|
||||
modifier = Modifier.padding(bottom = 40.dp),
|
||||
selected = state.config.privacy,
|
||||
RoomVisibilityOptions(
|
||||
selected = when (state.config.roomVisibility) {
|
||||
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
|
||||
is RoomVisibilityState.Public -> RoomVisibilityItem.Public
|
||||
},
|
||||
onOptionClick = {
|
||||
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) },
|
||||
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) },
|
||||
)
|
||||
|
||||
@@ -221,23 +247,123 @@ private fun RoomTopic(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomPrivacyOptions(
|
||||
selected: RoomPrivacy?,
|
||||
onOptionClick: (RoomPrivacyItem) -> Unit,
|
||||
private fun ConfigureRoomOptions(
|
||||
title: String,
|
||||
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,
|
||||
) {
|
||||
val items = roomPrivacyItems()
|
||||
Column(modifier = modifier.selectableGroup()) {
|
||||
items.forEach { item ->
|
||||
RoomPrivacyOption(
|
||||
roomPrivacyItem = item,
|
||||
isSelected = selected == item.privacy,
|
||||
onOptionClick = onOptionClick,
|
||||
ConfigureRoomOptions(
|
||||
title = stringResource(R.string.screen_create_room_room_visibility_section_title),
|
||||
modifier = modifier,
|
||||
) {
|
||||
RoomVisibilityItem.entries.forEach { item ->
|
||||
val isSelected = item == selected
|
||||
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
|
||||
@Composable
|
||||
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
<string name="screen_create_room_private_option_title">"Прыватны пакой (толькі па запрашэнні)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Паведамленні не зашыфраваны, і кожны можа іх прачытаць. Вы можаце ўключыць шыфраванне пазней."</string>
|
||||
<string name="screen_create_room_public_option_title">"Публічны пакой (для ўсіх)"</string>
|
||||
<string name="screen_create_room_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_title">"Стварыце пакой"</string>
|
||||
<string name="screen_create_room_topic_label">"Тэма (неабавязкова)"</string>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<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>
|
||||
<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_visibility_section_title">"Viditelnost místnosti"</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_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
<string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτό το δωμάτιο.
|
||||
Μπορείς να το αλλάξεις ανά πάσα στιγμή στις ρυθμίσεις δωματίου."</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_title">"Δημιούργησε ένα δωμάτιο"</string>
|
||||
<string name="screen_create_room_topic_label">"Θέμα (προαιρετικό)"</string>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<string name="screen_create_room_public_option_description">"Kõik saavad seda jututuba leida.
|
||||
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_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_visibility_section_title">"Jututoa nähtavus"</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_start_chat_error_starting_chat">"Vestluse alustamisel tekkis viga"</string>
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
<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_error_creating_room">"Une erreur s’est 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_title">"Salon privé (sur invitation seulement)"</string>
|
||||
<string name="screen_create_room_public_option_description">"Les messages ne sont pas chiffrés et n’importe qui peut les lire. Vous pouvez activer le chiffrement ultérieurement."</string>
|
||||
<string name="screen_create_room_public_option_title">"Salon public (tout le monde)"</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é"</string>
|
||||
<string name="screen_create_room_public_option_description">"N’importe qui peut trouver ce salon.
|
||||
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 d’une 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_visibility_section_title">"Visibilité du 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_start_chat_error_starting_chat">"Une erreur s’est produite lors de la tentative de création de la discussion"</string>
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
<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_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_title">"Privát szoba (csak meghívással)"</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_title">"Nyilvános szoba (bárki)"</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"</string>
|
||||
<string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
|
||||
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_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_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>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<string name="screen_create_room_public_option_description">"Siapa pun dapat mencari ruangan ini.
|
||||
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_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_visibility_section_title">"Keterlihatan ruangan"</string>
|
||||
<string name="screen_create_room_title">"Buat ruangan"</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>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<string name="screen_create_room_add_people_title">"ხალხის მოწვევა"</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_title">"კერძო ოთახი (მხოლოდ მოწვევა)"</string>
|
||||
<string name="screen_create_room_public_option_description">"შეტყობინებები არ არის დაშიფრული და ყველას შეუძლია მათი წაკითხვა. შეგიძლიათ ჩართოთ დაშიფვრა მოგვიანებით."</string>
|
||||
<string name="screen_create_room_public_option_title">"საჯარო ოთახი (ნებისმიერი)"</string>
|
||||
<string name="screen_create_room_private_option_title">"კერძო ოთახი"</string>
|
||||
<string name="screen_create_room_public_option_description">"ყველას ამ ოთახის მოძებნა შეუძლია.
|
||||
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."</string>
|
||||
<string name="screen_create_room_public_option_title">"საჯარო ოთახი"</string>
|
||||
<string name="screen_create_room_room_name_label">"ოთახის სახელი"</string>
|
||||
<string name="screen_create_room_title">"ოთახის შექმნა"</string>
|
||||
<string name="screen_create_room_topic_label">"თემა (სურვილისამებრ)"</string>
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
<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_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_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_title">"Openbare kamer (iedereen)"</string>
|
||||
<string name="screen_create_room_private_option_title">"Privé kamer"</string>
|
||||
<string name="screen_create_room_public_option_description">"Iedereen kan deze kamer vinden.
|
||||
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_title">"Creëer een kamer"</string>
|
||||
<string name="screen_create_room_topic_label">"Onderwerp (optioneel)"</string>
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
<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_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_title">"Pokój prywatny (tylko zaproszenie)"</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_title">"Pokój publiczny (wszyscy)"</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"</string>
|
||||
<string name="screen_create_room_public_option_description">"Każdy może znaleźć ten pokój.
|
||||
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_visibility_section_title">"Widoczność pomieszczenia"</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_start_chat_error_starting_chat">"Wystąpił błąd podczas próby rozpoczęcia czatu"</string>
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
<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_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_title">"Sala privada (entrada apenas por convite)"</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_title">"Sala pública (entrada livre)"</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"</string>
|
||||
<string name="screen_create_room_public_option_description">"Qualquer um pode encontrar esta sala.
|
||||
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_visibility_section_title">"Visibilidade da 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_start_chat_error_starting_chat">"Ocorreu um erro ao tentar iniciar uma conversa"</string>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<string name="screen_create_room_public_option_description">"Любой желающий может найти эту комнату.
|
||||
Вы можете изменить это в любое время в настройках комнаты."</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_visibility_section_title">"Видимость комнаты"</string>
|
||||
<string name="screen_create_room_title">"Создать комнату"</string>
|
||||
<string name="screen_create_room_topic_label">"Тема (необязательно)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при запуске чата"</string>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<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>
|
||||
<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_visibility_section_title">"Viditeľnosť miestnosti"</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_start_chat_error_starting_chat">"Pri pokuse o spustenie konverzácie sa vyskytla chyba"</string>
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
<string name="screen_create_room_public_option_description">"Anyone can find this room.
|
||||
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_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_visibility_section_title">"Room visibility"</string>
|
||||
<string name="screen_create_room_title">"Create a room"</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>
|
||||
|
||||
@@ -8,15 +8,16 @@
|
||||
package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.CreatedRoom
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.CreateRoomDataStore
|
||||
import io.element.android.features.createroom.impl.userlist.UserListDataStore
|
||||
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.test.AN_AVATAR_URL
|
||||
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.ui.components.aMatrixUser
|
||||
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.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
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.FakePermissionsPresenterFactory
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
@@ -56,33 +62,8 @@ class ConfigureRoomPresenterTest {
|
||||
@get:Rule
|
||||
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
|
||||
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)
|
||||
every { any<File>().readBytes() } returns byteArrayOf()
|
||||
}
|
||||
@@ -94,50 +75,56 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val presenter = createConfigureRoomPresenter()
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
assertThat(initialState.config).isEqualTo(CreateRoomConfig())
|
||||
assertThat(initialState.config.roomName).isNull()
|
||||
assertThat(initialState.config.topic).isNull()
|
||||
assertThat(initialState.config.invites).isEmpty()
|
||||
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
|
||||
fun `present - create room button is enabled only if the required fields are completed`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val presenter = createConfigureRoomPresenter()
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
var config = initialState.config
|
||||
assertThat(initialState.isCreateButtonEnabled).isFalse()
|
||||
assertThat(initialState.config.isValid).isFalse()
|
||||
|
||||
// Room name not empty
|
||||
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
|
||||
var newState: ConfigureRoomState = awaitItem()
|
||||
config = config.copy(roomName = A_ROOM_NAME)
|
||||
assertThat(newState.config).isEqualTo(config)
|
||||
assertThat(newState.isCreateButtonEnabled).isTrue()
|
||||
assertThat(newState.config.isValid).isTrue()
|
||||
|
||||
// Clear room name
|
||||
newState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
|
||||
newState = awaitItem()
|
||||
config = config.copy(roomName = null)
|
||||
assertThat(newState.config).isEqualTo(config)
|
||||
assertThat(newState.isCreateButtonEnabled).isFalse()
|
||||
assertThat(newState.config.isValid).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - state is updated when fields are changed`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val userListDataStore = UserListDataStore()
|
||||
val pickerProvider = FakePickerProvider()
|
||||
val permissionsPresenter = FakePermissionsPresenter()
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
createRoomDataStore = CreateRoomDataStore(userListDataStore),
|
||||
pickerProvider = pickerProvider,
|
||||
permissionsPresenter = permissionsPresenter,
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
var expectedConfig = CreateRoomConfig()
|
||||
assertThat(initialState.config).isEqualTo(expectedConfig)
|
||||
|
||||
@@ -165,22 +152,22 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
// Room avatar
|
||||
// Pick avatar
|
||||
fakePickerProvider.givenResult(null)
|
||||
pickerProvider.givenResult(null)
|
||||
// From gallery
|
||||
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
|
||||
fakePickerProvider.givenResult(uriFromGallery)
|
||||
pickerProvider.givenResult(uriFromGallery)
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
newState = awaitItem()
|
||||
expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery)
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
// From camera
|
||||
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
|
||||
fakePickerProvider.givenResult(uriFromCamera)
|
||||
pickerProvider.givenResult(uriFromCamera)
|
||||
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
newState = awaitItem()
|
||||
assertThat(newState.cameraPermissionState.showDialog).isTrue()
|
||||
fakePermissionsPresenter.setPermissionGranted()
|
||||
permissionsPresenter.setPermissionGranted()
|
||||
newState = awaitItem()
|
||||
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
|
||||
newState = awaitItem()
|
||||
@@ -188,7 +175,7 @@ class ConfigureRoomPresenterTest {
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
// Do it again, no permission is requested
|
||||
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
|
||||
fakePickerProvider.givenResult(uriFromCamera2)
|
||||
pickerProvider.givenResult(uriFromCamera2)
|
||||
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
newState = awaitItem()
|
||||
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
|
||||
@@ -200,13 +187,19 @@ class ConfigureRoomPresenterTest {
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
|
||||
// Room privacy
|
||||
newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public))
|
||||
newState.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(RoomVisibilityItem.Public))
|
||||
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)
|
||||
|
||||
// Remove user
|
||||
newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1))
|
||||
newState.eventSink(ConfigureRoomEvents.RemoveUserFromSelection(selectedUser1))
|
||||
newState = awaitItem()
|
||||
expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList())
|
||||
assertThat(newState.config).isEqualTo(expectedConfig)
|
||||
@@ -215,15 +208,17 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - trigger create room action`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val matrixClient = createMatrixClient()
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
matrixClient = matrixClient
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
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)
|
||||
val stateAfterCreateRoom = awaitItem()
|
||||
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
@@ -233,18 +228,22 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - record analytics when creating room`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val matrixClient = createMatrixClient()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
matrixClient = matrixClient,
|
||||
analyticsService = analyticsService
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
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)
|
||||
|
||||
val analyticsEvent = fakeAnalyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
|
||||
val analyticsEvent = analyticsService.capturedEvents.filterIsInstance<CreatedRoom>().firstOrNull()
|
||||
assertThat(analyticsEvent).isNotNull()
|
||||
assertThat(analyticsEvent?.isDM).isFalse()
|
||||
}
|
||||
@@ -252,23 +251,31 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - trigger create room with upload error and retry`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val matrixClient = createMatrixClient()
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
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))
|
||||
fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
|
||||
fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
|
||||
skipItems(1)
|
||||
mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
|
||||
matrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE))
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
val stateAfterCreateRoom = awaitItem()
|
||||
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))
|
||||
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||
matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
|
||||
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||
@@ -277,23 +284,25 @@ class ConfigureRoomPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - trigger retry and cancel actions`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val fakeMatrixClient = createMatrixClient()
|
||||
val presenter = createConfigureRoomPresenter(
|
||||
matrixClient = fakeMatrixClient
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = initialState()
|
||||
val createRoomResult = Result.failure<RoomId>(A_THROWABLE)
|
||||
|
||||
fakeMatrixClient.givenCreateRoomResult(createRoomResult)
|
||||
|
||||
// Create
|
||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
val stateAfterCreateRoom = awaitItem()
|
||||
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
assertThat((stateAfterCreateRoom.createRoomAction as? AsyncAction.Failure)?.error).isEqualTo(createRoomResult.exceptionOrNull())
|
||||
|
||||
// Retry
|
||||
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||
stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
val stateAfterRetry = awaitItem()
|
||||
@@ -305,4 +314,33 @@ class ConfigureRoomPresenterTest {
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -45,6 +45,7 @@ dependencies {
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.services.analytics.noop)
|
||||
testImplementation(projects.libraries.permissions.impl)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
package io.element.android.features.ftue.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.BaseFlowNode
|
||||
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.SessionScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
super.onBuilt()
|
||||
|
||||
lifecycle.subscribe(onCreate = {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
})
|
||||
|
||||
analyticsService.didAskUserConsent()
|
||||
.distinctUntilChanged()
|
||||
.onEach {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
ftueState.isVerificationStatusKnown
|
||||
.filter { it }
|
||||
.onEach { moveToNextStepIfNeeded() }
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
@@ -99,7 +107,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
NavTarget.SessionVerification -> {
|
||||
val callback = object : FtueSessionVerificationFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
|
||||
@@ -107,7 +115,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
NavTarget.NotificationsOptIn -> {
|
||||
val callback = object : NotificationsOptInNode.Callback {
|
||||
override fun onNotificationsOptInFinished() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
|
||||
@@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
NavTarget.LockScreenSetup -> {
|
||||
val callback = object : LockScreenEntryPoint.Callback {
|
||||
override fun onSetupDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
moveToNextStepIfNeeded()
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
FtueStep.WaitingForInitialState -> {
|
||||
backstack.newRoot(NavTarget.Placeholder)
|
||||
}
|
||||
FtueStep.SessionVerification -> {
|
||||
backstack.newRoot(NavTarget.SessionVerification)
|
||||
}
|
||||
@@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
class PlaceholderNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@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>() {
|
||||
|
||||
@@ -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.lockscreen.api.LockScreenService
|
||||
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.matrix.api.verification.SessionVerificationService
|
||||
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.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.timeout
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultFtueService @Inject constructor(
|
||||
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
||||
@@ -49,6 +46,14 @@ class DefaultFtueService @Inject constructor(
|
||||
) : FtueService {
|
||||
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() {
|
||||
analyticsService.reset()
|
||||
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
@@ -69,7 +74,12 @@ class DefaultFtueService @Inject constructor(
|
||||
|
||||
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
|
||||
when (currentStep) {
|
||||
null -> if (isSessionNotVerified()) {
|
||||
null -> if (!isSessionVerificationStateReady()) {
|
||||
FtueStep.WaitingForInitialState
|
||||
} else {
|
||||
getNextStep(FtueStep.WaitingForInitialState)
|
||||
}
|
||||
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
|
||||
FtueStep.SessionVerification
|
||||
} else {
|
||||
getNextStep(FtueStep.SessionVerification)
|
||||
@@ -89,34 +99,18 @@ class DefaultFtueService @Inject constructor(
|
||||
} else {
|
||||
getNextStep(FtueStep.AnalyticsOptIn)
|
||||
}
|
||||
FtueStep.AnalyticsOptIn -> {
|
||||
updateState()
|
||||
null
|
||||
}
|
||||
FtueStep.AnalyticsOptIn -> null
|
||||
}
|
||||
|
||||
private suspend fun isAnyStepIncomplete(): Boolean {
|
||||
return listOf<suspend () -> Boolean>(
|
||||
{ isSessionNotVerified() },
|
||||
{ shouldAskNotificationPermissions() },
|
||||
{ needsAnalyticsOptIn() },
|
||||
{ shouldDisplayLockscreenSetup() },
|
||||
).any { it() }
|
||||
private fun isSessionVerificationStateReady(): Boolean {
|
||||
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun isSessionNotVerified(): Boolean {
|
||||
// Wait for the first known (or ready) verification status
|
||||
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
|
||||
.filter { it != SessionVerifiedStatus.Unknown }
|
||||
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
|
||||
.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()
|
||||
// Wait until the session verification status is known
|
||||
isVerificationStatusKnown.filter { it }.first()
|
||||
|
||||
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
|
||||
}
|
||||
|
||||
private suspend fun canSkipVerification(): Boolean {
|
||||
@@ -130,7 +124,7 @@ class DefaultFtueService @Inject constructor(
|
||||
private suspend fun shouldAskNotificationPermissions(): Boolean {
|
||||
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||
val permission = Manifest.permission.POST_NOTIFICATIONS
|
||||
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
|
||||
val isPermissionDenied = permissionStateProvider.isPermissionDenied(permission).first()
|
||||
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
|
||||
!isPermissionGranted && !isPermissionDenied
|
||||
} else {
|
||||
@@ -144,14 +138,17 @@ class DefaultFtueService @Inject constructor(
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal suspend fun updateState() {
|
||||
val nextStep = getNextStep()
|
||||
state.value = when {
|
||||
isAnyStepIncomplete() -> FtueState.Incomplete
|
||||
else -> FtueState.Complete
|
||||
// Final state, there aren't any more next steps
|
||||
nextStep == null -> FtueState.Complete
|
||||
else -> FtueState.Incomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FtueStep {
|
||||
data object WaitingForInitialState : FtueStep
|
||||
data object SessionVerification : FtueStep
|
||||
data object NotificationsOptIn : FtueStep
|
||||
data object AnalyticsOptIn : FtueStep
|
||||
|
||||
@@ -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.test.InMemorySessionPreferencesStore
|
||||
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.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -35,7 +36,7 @@ class DefaultFtueServiceTest {
|
||||
@Test
|
||||
fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
}
|
||||
val service = createDefaultFtueService(
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
@@ -46,7 +47,7 @@ class DefaultFtueServiceTest {
|
||||
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +65,7 @@ class DefaultFtueServiceTest {
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
analyticsService.setDidAskUserConsent()
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
@@ -73,10 +74,31 @@ class DefaultFtueServiceTest {
|
||||
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
|
||||
fun `traverse flow`() = runTest {
|
||||
val sessionVerificationService = FakeSessionVerificationService().apply {
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
}
|
||||
val analyticsService = FakeAnalyticsService()
|
||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||
@@ -91,7 +113,7 @@ class DefaultFtueServiceTest {
|
||||
|
||||
// Session verification
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
|
||||
// Notifications opt in
|
||||
steps.add(service.getNextStep(steps.lastOrNull()))
|
||||
@@ -132,7 +154,7 @@ class DefaultFtueServiceTest {
|
||||
)
|
||||
|
||||
// Skip first 3 steps
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
permissionStateProvider.setPermissionGranted()
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
@@ -155,7 +177,7 @@ class DefaultFtueServiceTest {
|
||||
lockScreenService = lockScreenService,
|
||||
)
|
||||
|
||||
sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
lockScreenService.setIsPinSetup(true)
|
||||
|
||||
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_alert_confirmation">"Oui, annuler"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_description">"Êtes-vous sûr de vouloir annuler votre demande d’accès à ce salon?"</string>
|
||||
<string name="screen_join_room_cancel_knock_alert_title">"Annuler la demande d’adhésion"</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_message_description">"Message (facultatif)"</string>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_knock_action">"Bater à porta"</string>
|
||||
<string name="screen_join_room_knock_message_description">"Mensagem (opcional)"</string>
|
||||
|
||||
@@ -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_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_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_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_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_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_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>
|
||||
<string name="screen_qr_code_login_error_linking_not_suported_title">"A QR-kód nem támogatott"</string>
|
||||
|
||||
@@ -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_subtitle">"Apa alamat 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_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>
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -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_subtitle">"Wat is het adres van 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_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>
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -29,6 +29,7 @@ dependencies {
|
||||
implementation(projects.features.call.api)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.poll.api)
|
||||
implementation(projects.features.roomcall.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
|
||||
@@ -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.networkmonitor.api.NetworkMonitor
|
||||
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.architecture.AsyncData
|
||||
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.ui.messages.reply.map
|
||||
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.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -98,6 +98,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
private val reactionSummaryPresenter: Presenter<ReactionSummaryState>,
|
||||
private val readReceiptBottomSheetPresenter: Presenter<ReadReceiptBottomSheetState>,
|
||||
private val pinnedMessagesBannerPresenter: Presenter<PinnedMessagesBannerState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@@ -133,6 +134,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
val reactionSummaryState = reactionSummaryPresenter.present()
|
||||
val readReceiptBottomSheetState = readReceiptBottomSheetPresenter.present()
|
||||
val pinnedMessagesBannerState = pinnedMessagesBannerPresenter.present()
|
||||
val roomCallState = roomCallStatePresenter.present()
|
||||
|
||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
|
||||
@@ -152,8 +154,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Remove the unread flag on entering but don't send read receipts
|
||||
// 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(
|
||||
roomId = room.roomId,
|
||||
roomName = roomName,
|
||||
@@ -232,7 +226,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
appName = buildMeta.applicationName,
|
||||
callState = callState,
|
||||
roomCallState = roomCallState,
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
|
||||
@@ -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.protection.TimelineProtectionState
|
||||
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.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
@@ -46,14 +47,8 @@ data class MessagesState(
|
||||
val showReinvitePrompt: Boolean,
|
||||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val callState: RoomCallState,
|
||||
val roomCallState: RoomCallState,
|
||||
val appName: String,
|
||||
val pinnedMessagesBannerState: PinnedMessagesBannerState,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
||||
enum class RoomCallState {
|
||||
ENABLED,
|
||||
ONGOING,
|
||||
DISABLED
|
||||
}
|
||||
|
||||
@@ -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.aVoiceMessageComposerState
|
||||
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.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
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.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@@ -71,7 +73,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
),
|
||||
),
|
||||
aMessagesState(
|
||||
callState = RoomCallState.ONGOING,
|
||||
roomCallState = anOngoingCallState(),
|
||||
),
|
||||
aMessagesState(
|
||||
enableVoiceMessages = true,
|
||||
@@ -81,7 +83,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||
),
|
||||
),
|
||||
aMessagesState(
|
||||
callState = RoomCallState.DISABLED,
|
||||
roomCallState = aStandByCallState(canStartCall = false),
|
||||
),
|
||||
aMessagesState(
|
||||
pinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(
|
||||
@@ -97,7 +99,7 @@ fun aMessagesState(
|
||||
roomAvatar: AsyncData<AvatarData> = AsyncData.Success(AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom)),
|
||||
userEventPermissions: UserEventPermissions = aUserEventPermissions(),
|
||||
composerState: MessageComposerState = aMessageComposerState(
|
||||
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
|
||||
textEditorState = aTextEditorStateRich(initialText = "Hello", initialFocus = true),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
@@ -116,7 +118,7 @@ fun aMessagesState(
|
||||
hasNetworkConnection: Boolean = true,
|
||||
showReinvitePrompt: Boolean = false,
|
||||
enableVoiceMessages: Boolean = true,
|
||||
callState: RoomCallState = RoomCallState.ENABLED,
|
||||
roomCallState: RoomCallState = aStandByCallState(),
|
||||
pinnedMessagesBannerState: PinnedMessagesBannerState = aLoadedPinnedMessagesBannerState(),
|
||||
eventSink: (MessagesEvents) -> Unit = {},
|
||||
) = MessagesState(
|
||||
@@ -140,7 +142,7 @@ fun aMessagesState(
|
||||
showReinvitePrompt = showReinvitePrompt,
|
||||
enableTextFormatting = true,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
callState = callState,
|
||||
roomCallState = roomCallState,
|
||||
appName = "Element",
|
||||
pinnedMessagesBannerState = pinnedMessagesBannerState,
|
||||
eventSink = eventSink,
|
||||
|
||||
@@ -52,7 +52,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
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.actionlist.ActionListEvents
|
||||
import io.element.android.features.messages.impl.actionlist.ActionListView
|
||||
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.TimelineEvents
|
||||
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.CustomReactionEvents
|
||||
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.VoiceMessageSendingFailedDialog
|
||||
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.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
|
||||
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.PreviewsDayNight
|
||||
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.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
@@ -190,7 +188,7 @@ fun MessagesView(
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||
heroes = state.heroes,
|
||||
callState = state.callState,
|
||||
roomCallState = state.roomCallState,
|
||||
onBackClick = {
|
||||
// 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.
|
||||
@@ -479,7 +477,7 @@ private fun MessagesViewTopBar(
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
callState: RoomCallState,
|
||||
roomCallState: RoomCallState,
|
||||
onRoomDetailsClick: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
@@ -509,9 +507,8 @@ private fun MessagesViewTopBar(
|
||||
},
|
||||
actions = {
|
||||
CallMenuItem(
|
||||
isCallOngoing = callState == RoomCallState.ONGOING,
|
||||
onClick = onJoinCallClick,
|
||||
enabled = callState != RoomCallState.DISABLED
|
||||
roomCallState = roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
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
|
||||
private fun RoomAvatarAndNameRow(
|
||||
roomName: String,
|
||||
|
||||
@@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
|
||||
@Immutable
|
||||
sealed interface AttachmentsPreviewEvents {
|
||||
data object SendAttachment : AttachmentsPreviewEvents
|
||||
data object Cancel : AttachmentsPreviewEvents
|
||||
data object ClearSendState : AttachmentsPreviewEvents
|
||||
}
|
||||
|
||||
@@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||
|
||||
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
|
||||
override fun View(modifier: Modifier) {
|
||||
@@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||
val state = presenter.present()
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
onDismiss = this::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,16 +9,22 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
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.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.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -29,11 +35,17 @@ import kotlin.coroutines.coroutineContext
|
||||
|
||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
@Assisted private val attachment: Attachment,
|
||||
@Assisted private val onDoneListener: OnDoneListener,
|
||||
private val mediaSender: MediaSender,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
) : Presenter<AttachmentsPreviewState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
||||
fun create(
|
||||
attachment: Attachment,
|
||||
onDoneListener: OnDoneListener,
|
||||
): AttachmentsPreviewPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -44,11 +56,27 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
mutableStateOf<SendActionState>(SendActionState.Idle)
|
||||
}
|
||||
|
||||
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
|
||||
val textEditorState by rememberUpdatedState(
|
||||
TextEditorState.Markdown(markdownTextEditorState)
|
||||
)
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
fun handleEvents(attachmentsPreviewEvents: 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 -> {
|
||||
ongoingSendAttachmentJob.value?.let {
|
||||
it.cancel()
|
||||
@@ -62,26 +90,42 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
return AttachmentsPreviewState(
|
||||
attachment = attachment,
|
||||
sendActionState = sendActionState.value,
|
||||
textEditorState = textEditorState,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.sendAttachment(
|
||||
attachment: Attachment,
|
||||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = launch {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> {
|
||||
sendMedia(
|
||||
mediaAttachment = attachment,
|
||||
caption = caption,
|
||||
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(
|
||||
mediaAttachment: Attachment.Media,
|
||||
caption: String?,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = runCatching {
|
||||
val context = coroutineContext
|
||||
@@ -96,11 +140,12 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
mediaSender.sendMedia(
|
||||
uri = mediaAttachment.localMedia.uri,
|
||||
mimeType = mediaAttachment.localMedia.info.mimeType,
|
||||
caption = caption,
|
||||
progressCallback = progressCallback
|
||||
).getOrThrow()
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
sendActionState.value = SendActionState.Done
|
||||
onDoneListener()
|
||||
},
|
||||
onFailure = { error ->
|
||||
Timber.e(error, "Failed to send attachment")
|
||||
|
||||
@@ -9,12 +9,21 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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(
|
||||
val attachment: Attachment,
|
||||
val sendActionState: SendActionState,
|
||||
val textEditorState: TextEditorState,
|
||||
val eventSink: (AttachmentsPreviewEvents) -> Unit
|
||||
)
|
||||
) {
|
||||
val allowCaption: Boolean = (attachment as? Attachment.Media)?.localMedia?.info?.mimeType?.let {
|
||||
it.isMimeTypeImage() || it.isMimeTypeVideo()
|
||||
}.orFalse()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface SendActionState {
|
||||
@@ -27,5 +36,4 @@ sealed interface SendActionState {
|
||||
}
|
||||
|
||||
data class Failure(val error: Throwable) : SendActionState
|
||||
data object Done : SendActionState
|
||||
}
|
||||
|
||||
@@ -12,13 +12,19 @@ import androidx.core.net.toUri
|
||||
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.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.anAudioMediaInfo
|
||||
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> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
get() = sequenceOf(
|
||||
anAttachmentsPreviewState(),
|
||||
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
||||
@@ -27,11 +33,13 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||
|
||||
fun anAttachmentsPreviewState(
|
||||
mediaInfo: MediaInfo = anImageMediaInfo(),
|
||||
sendActionState: SendActionState = SendActionState.Idle
|
||||
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
|
||||
sendActionState: SendActionState = SendActionState.Idle,
|
||||
) = AttachmentsPreviewState(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
|
||||
),
|
||||
sendActionState = sendActionState,
|
||||
textEditorState = textEditorState,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -7,65 +7,82 @@
|
||||
|
||||
package io.element.android.features.messages.impl.attachments.preview
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.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.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.preview.ElementPreviewDark
|
||||
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.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.wysiwyg.display.TextDisplay
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
state: AttachmentsPreviewState,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun postSendAttachment() {
|
||||
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||
}
|
||||
|
||||
fun postCancel() {
|
||||
state.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||
}
|
||||
|
||||
fun postClearSendState() {
|
||||
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||
}
|
||||
|
||||
if (state.sendActionState is SendActionState.Done) {
|
||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
||||
LaunchedEffect(state.sendActionState) {
|
||||
latestOnDismiss()
|
||||
}
|
||||
BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
|
||||
postCancel()
|
||||
}
|
||||
|
||||
Scaffold(modifier) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
onClick = ::postCancel,
|
||||
)
|
||||
},
|
||||
title = {},
|
||||
)
|
||||
}
|
||||
) {
|
||||
AttachmentPreviewContent(
|
||||
attachment = state.attachment,
|
||||
state = state,
|
||||
onSendClick = ::postSendAttachment,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
AttachmentSendStateView(
|
||||
@@ -106,21 +123,19 @@ private fun AttachmentSendStateView(
|
||||
|
||||
@Composable
|
||||
private fun AttachmentPreviewContent(
|
||||
attachment: Attachment,
|
||||
state: AttachmentsPreviewState,
|
||||
onSendClick: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (attachment) {
|
||||
when (val attachment = state.attachment) {
|
||||
is Attachment.Media -> {
|
||||
val localMediaViewState = rememberLocalMediaViewState(
|
||||
zoomableState = rememberZoomableState(
|
||||
@@ -137,27 +152,46 @@ private fun AttachmentPreviewContent(
|
||||
}
|
||||
}
|
||||
AttachmentsPreviewBottomActions(
|
||||
onCancelClick = onDismiss,
|
||||
state = state,
|
||||
onSendClick = onSendClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 80.dp)
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.height(IntrinsicSize.Min)
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttachmentsPreviewBottomActions(
|
||||
onCancelClick: () -> Unit,
|
||||
state: AttachmentsPreviewState,
|
||||
onSendClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ButtonRowMolecule(modifier = modifier) {
|
||||
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick)
|
||||
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick)
|
||||
}
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
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.
|
||||
@@ -166,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
|
||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
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.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
@@ -41,7 +39,6 @@ import javax.inject.Inject
|
||||
class IdentityChangeStatePresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : Presenter<IdentityChangeState> {
|
||||
@Composable
|
||||
override fun present(): IdentityChangeState {
|
||||
@@ -64,11 +61,7 @@ class IdentityChangeStatePresenter @Inject constructor(
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun ProduceStateScope<PersistentList<RoomMemberIdentityStateChange>>.observeRoomMemberIdentityStateChange() {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.IdentityPinningViolationNotifications)
|
||||
.filter { it }
|
||||
.flatMapLatest {
|
||||
room.syncUpdateFlow
|
||||
}
|
||||
room.syncUpdateFlow
|
||||
.filter {
|
||||
// Room cannot become unencrypted, so we can just apply a filter here.
|
||||
room.isEncrypted
|
||||
|
||||
@@ -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.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
@@ -25,11 +24,9 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
MessagesView(
|
||||
state = aMessagesState(
|
||||
composerState = aMessageComposerState(
|
||||
textEditorState = TextEditorState.Markdown(
|
||||
state = MarkdownTextEditorState(
|
||||
initialText = "",
|
||||
initialFocus = false,
|
||||
)
|
||||
textEditorState = aTextEditorStateMarkdown(
|
||||
initialText = "",
|
||||
initialFocus = false,
|
||||
)
|
||||
),
|
||||
identityChangeState = identityChangeState,
|
||||
|
||||
@@ -436,6 +436,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
// Reset composer right away
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = capturedMode is MessageComposerMode.Edit)
|
||||
when (capturedMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> room.sendMessage(
|
||||
body = message.markdown,
|
||||
htmlBody = message.html,
|
||||
@@ -605,6 +606,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||
): ComposerDraft? {
|
||||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = false)
|
||||
val draftType = when (val mode = messageComposerContext.composerMode) {
|
||||
is MessageComposerMode.Attachment,
|
||||
is MessageComposerMode.Normal -> ComposerDraftType.NewMessage
|
||||
is MessageComposerMode.Edit -> {
|
||||
mode.eventOrTransactionId.eventId?.let { eventId -> ComposerDraftType.Edit(eventId) }
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
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.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -24,7 +24,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||
}
|
||||
|
||||
fun aMessageComposerState(
|
||||
textEditorState: TextEditorState = TextEditorState.Rich(aRichTextEditorState()),
|
||||
textEditorState: TextEditorState = aTextEditorStateRich(),
|
||||
isFullScreen: Boolean = false,
|
||||
mode: MessageComposerMode = MessageComposerMode.Normal,
|
||||
showTextFormatting: Boolean = false,
|
||||
|
||||
@@ -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.protection.TimelineProtectionState
|
||||
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.Presenter
|
||||
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
|
||||
userHasPermissionToSendMessage = 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
|
||||
pinnedEventIds = emptyList(),
|
||||
typingNotificationState = TypingNotificationState(
|
||||
|
||||
@@ -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.poll.api.actions.EndPollAction
|
||||
import io.element.android.features.poll.api.actions.SendPollResponseAction
|
||||
import io.element.android.features.roomcall.api.RoomCallState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
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.UniqueId
|
||||
@@ -73,6 +73,7 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
private val timelineItemIndexer: TimelineItemIndexer = TimelineItemIndexer(),
|
||||
private val resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState>,
|
||||
private val typingNotificationPresenter: Presenter<TypingNotificationState>,
|
||||
private val roomCallStatePresenter: Presenter<RoomCallState>,
|
||||
) : Presenter<TimelineState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -229,14 +230,15 @@ class TimelinePresenter @AssistedInject constructor(
|
||||
}
|
||||
|
||||
val typingNotificationState = typingNotificationPresenter.present()
|
||||
val timelineRoomInfo by remember(typingNotificationState) {
|
||||
val roomCallState = roomCallStatePresenter.present()
|
||||
val timelineRoomInfo by remember(typingNotificationState, roomCallState) {
|
||||
derivedStateOf {
|
||||
TimelineRoomInfo(
|
||||
name = room.displayName,
|
||||
isDm = room.isDm,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = userHasPermissionToSendReaction,
|
||||
isCallOngoing = roomInfo?.hasRoomCall.orFalse(),
|
||||
roomCallState = roomCallState,
|
||||
pinnedEventIds = roomInfo?.pinnedEventIds.orEmpty(),
|
||||
typingNotificationState = typingNotificationState,
|
||||
)
|
||||
|
||||
@@ -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.TimelineItem
|
||||
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.UniqueId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
@@ -73,7 +74,7 @@ data class TimelineRoomInfo(
|
||||
val name: String?,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToSendReaction: Boolean,
|
||||
val isCallOngoing: Boolean,
|
||||
val roomCallState: RoomCallState,
|
||||
val pinnedEventIds: List<EventId>,
|
||||
val typingNotificationState: TypingNotificationState,
|
||||
)
|
||||
|
||||
@@ -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.typing.TypingNotificationState
|
||||
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.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -249,7 +250,7 @@ internal fun aTimelineRoomInfo(
|
||||
name = name,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToSendReaction = true,
|
||||
isCallOngoing = false,
|
||||
roomCallState = aStandByCallState(),
|
||||
pinnedEventIds = pinnedEventIds,
|
||||
typingNotificationState = typingNotificationState,
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.model.TimelineItem
|
||||
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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -41,7 +43,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
internal fun TimelineItemCallNotifyView(
|
||||
event: TimelineItem.Event,
|
||||
isCallOngoing: Boolean,
|
||||
roomCallState: RoomCallState,
|
||||
onLongClick: (TimelineItem.Event) -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -82,8 +84,11 @@ internal fun TimelineItemCallNotifyView(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isCallOngoing) {
|
||||
JoinCallMenuItem(onJoinCallClick)
|
||||
if (roomCallState is RoomCallState.OnGoing) {
|
||||
CallMenuItem(
|
||||
roomCallState = roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = event.sentTime,
|
||||
@@ -101,18 +106,14 @@ internal fun TimelineItemCallNotifyView(
|
||||
internal fun TimelineItemCallNotifyViewPreview() {
|
||||
ElementPreview {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
TimelineItemCallNotifyView(
|
||||
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
||||
isCallOngoing = true,
|
||||
onLongClick = {},
|
||||
onJoinCallClick = {},
|
||||
)
|
||||
TimelineItemCallNotifyView(
|
||||
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
||||
isCallOngoing = false,
|
||||
onLongClick = {},
|
||||
onJoinCallClick = {},
|
||||
)
|
||||
RoomCallStateProvider().values.forEach { roomCallState ->
|
||||
TimelineItemCallNotifyView(
|
||||
event = aTimelineItemEvent(content = TimelineItemCallNotifyContent()),
|
||||
roomCallState = roomCallState,
|
||||
onLongClick = {},
|
||||
onJoinCallClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +325,12 @@ private fun TimelineItemEventRowContent(
|
||||
MessageEventBubble(
|
||||
modifier = Modifier
|
||||
.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) {
|
||||
end.linkTo(parent.end, margin = 16.dp)
|
||||
} else {
|
||||
@@ -522,32 +527,33 @@ private fun MessageEventBubbleContent(
|
||||
fun CommonLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
showThreadDecoration: Boolean,
|
||||
paddingBehaviour: ContentPadding,
|
||||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier,
|
||||
canShrinkContent: Boolean = false,
|
||||
) {
|
||||
val timestampLayoutModifier: Modifier
|
||||
val contentModifier: Modifier
|
||||
when {
|
||||
inReplyToDetails != null -> {
|
||||
if (timestampPosition == TimestampPosition.Overlay) {
|
||||
timestampLayoutModifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
contentModifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||
val timestampLayoutModifier =
|
||||
if (inReplyToDetails != null && timestampPosition == TimestampPosition.Overlay) {
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
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 {
|
||||
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 0.dp, bottom = 8.dp)
|
||||
timestampLayoutModifier = Modifier
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
}
|
||||
}
|
||||
timestampPosition != TimestampPosition.Overlay -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
contentModifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
}
|
||||
else -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
contentModifier = Modifier
|
||||
}
|
||||
ContentPadding.CaptionedMedia ->
|
||||
Modifier.padding(start = 8.dp, end = 8.dp, top = topPadding, bottom = 8.dp)
|
||||
}
|
||||
|
||||
val threadDecoration = @Composable {
|
||||
if (showThreadDecoration) {
|
||||
ThreadDecoration(modifier = Modifier.padding(top = 8.dp, start = 12.dp, end = 12.dp))
|
||||
@@ -601,9 +607,17 @@ private fun MessageEventBubbleContent(
|
||||
is TimelineItemPollContent -> TimestampPosition.Below
|
||||
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(
|
||||
showThreadDecoration = event.isThreaded,
|
||||
timestampPosition = timestampPosition,
|
||||
paddingBehaviour = paddingBehaviour,
|
||||
inReplyToDetails = event.inReplyTo,
|
||||
canShrinkContent = event.content is TimelineItemVoiceContent,
|
||||
modifier = bubbleModifier.semantics(mergeDescendants = true) {
|
||||
|
||||
@@ -50,7 +50,9 @@ internal fun TimelineItemEventRowWithReplyContentToPreview(
|
||||
isMine = it,
|
||||
timelineItemReactions = aTimelineItemReactions(count = 0),
|
||||
content = aTimelineItemImageContent(
|
||||
aspectRatio = 2.5f
|
||||
aspectRatio = 2.5f,
|
||||
filename = "image.jpg",
|
||||
caption = "A reply with an image.",
|
||||
),
|
||||
inReplyTo = inReplyToDetails,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
|
||||
@@ -105,7 +105,7 @@ internal fun TimelineItemRow(
|
||||
TimelineItemCallNotifyView(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
event = timelineItem,
|
||||
isCallOngoing = timelineRoomInfo.isCallOngoing,
|
||||
roomCallState = timelineRoomInfo.roomCallState,
|
||||
onLongClick = onLongClick,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
|
||||
@@ -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.PreviewsDayNight
|
||||
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.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
@@ -69,9 +68,7 @@ fun TimelineItemImageView(
|
||||
modifier = modifier.semantics { contentDescription = description },
|
||||
) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -88,13 +85,7 @@ fun TimelineItemImageView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(
|
||||
source = content.preferredMediaSource,
|
||||
kind = MediaRequestData.Kind.File(
|
||||
fileName = content.filename,
|
||||
mimeType = content.mimeType,
|
||||
),
|
||||
),
|
||||
model = content.thumbnailMediaRequestData,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
contentDescription = description,
|
||||
@@ -119,6 +110,7 @@ fun TimelineItemImageView(
|
||||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
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),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
|
||||
@@ -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.PreviewsDayNight
|
||||
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.textcomposer.ElementRichTextEditorStyle
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -70,14 +72,14 @@ fun TimelineItemVideoView(
|
||||
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val description = stringResource(CommonStrings.common_image)
|
||||
val description = stringResource(CommonStrings.common_video)
|
||||
Column(
|
||||
modifier = modifier.semantics { contentDescription = description }
|
||||
) {
|
||||
val containerModifier = if (content.showCaption) {
|
||||
Modifier
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -93,13 +95,13 @@ fun TimelineItemVideoView(
|
||||
var isLoaded by remember { mutableStateOf(false) }
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
.fillMaxWidth()
|
||||
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
|
||||
model = MediaRequestData(
|
||||
source = content.thumbnailSource,
|
||||
kind = MediaRequestData.Kind.File(
|
||||
fileName = content.filename,
|
||||
mimeType = content.mimeType
|
||||
kind = MediaRequestData.Kind.Thumbnail(
|
||||
width = content.thumbnailWidth?.toLong() ?: MAX_THUMBNAIL_WIDTH,
|
||||
height = content.thumbnailHeight?.toLong() ?: MAX_THUMBNAIL_HEIGHT,
|
||||
)
|
||||
),
|
||||
contentScale = ContentScale.Fit,
|
||||
@@ -137,6 +139,7 @@ fun TimelineItemVideoView(
|
||||
val aspectRatio = content.aspectRatio ?: DEFAULT_ASPECT_RATIO
|
||||
EditorStyledText(
|
||||
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),
|
||||
text = caption,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
|
||||
@@ -93,6 +93,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
blurhash = messageType.info?.blurhash,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
|
||||
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
|
||||
@@ -146,6 +148,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
|
||||
width = messageType.info?.width?.toInt(),
|
||||
height = messageType.info?.height?.toInt(),
|
||||
thumbnailWidth = messageType.info?.thumbnailInfo?.width?.toInt(),
|
||||
thumbnailHeight = messageType.info?.thumbnailInfo?.height?.toInt(),
|
||||
duration = messageType.info?.duration ?: Duration.ZERO,
|
||||
blurHash = messageType.info?.blurhash,
|
||||
aspectRatio = aspectRatio,
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.TimeZone
|
||||
|
||||
open class AggregatedReactionProvider : PreviewParameterProvider<AggregatedReaction> {
|
||||
override val values: Sequence<AggregatedReaction>
|
||||
@@ -29,7 +30,9 @@ fun anAggregatedReaction(
|
||||
count: Int = 1,
|
||||
isHighlighted: Boolean = false,
|
||||
): 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 senders = buildList {
|
||||
repeat(count) { index ->
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
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.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(
|
||||
override val filename: String,
|
||||
@@ -23,15 +26,31 @@ data class TimelineItemImageContent(
|
||||
val blurhash: String?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val thumbnailWidth: Int?,
|
||||
val thumbnailHeight: Int?,
|
||||
val aspectRatio: Float?
|
||||
) : TimelineItemEventContentWithAttachment {
|
||||
override val type: String = "TimelineItemImageContent"
|
||||
|
||||
val showCaption = caption != null
|
||||
|
||||
val preferredMediaSource = if (mimeType == MimeTypes.Gif) {
|
||||
mediaSource
|
||||
} else {
|
||||
thumbnailSource ?: mediaSource
|
||||
val thumbnailMediaRequestData: MediaRequestData by lazy {
|
||||
if (mimeType.isMimeTypeAnimatedImage()) {
|
||||
MediaRequestData(
|
||||
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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ fun aTimelineItemImageContent(
|
||||
blurhash = blurhash,
|
||||
width = null,
|
||||
height = 300,
|
||||
thumbnailWidth = null,
|
||||
thumbnailHeight = 150,
|
||||
aspectRatio = aspectRatio,
|
||||
formattedFileSize = "4MB",
|
||||
fileExtension = "jpg"
|
||||
|
||||
@@ -22,6 +22,8 @@ data class TimelineItemVideoContent(
|
||||
val blurHash: String?,
|
||||
val height: Int?,
|
||||
val width: Int?,
|
||||
val thumbnailWidth: Int?,
|
||||
val thumbnailHeight: Int?,
|
||||
val mimeType: String,
|
||||
val formattedFileSize: String,
|
||||
val fileExtension: String,
|
||||
|
||||
@@ -35,8 +35,10 @@ fun aTimelineItemVideoContent(
|
||||
aspectRatio = aspectRatio,
|
||||
duration = 100.milliseconds,
|
||||
videoSource = MediaSource(""),
|
||||
height = 300,
|
||||
width = 150,
|
||||
height = 300,
|
||||
thumbnailWidth = 150,
|
||||
thumbnailHeight = 300,
|
||||
mimeType = MimeTypes.Mp4,
|
||||
formattedFileSize = "14MB",
|
||||
fileExtension = "mp4"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user