Merge branch 'develop' into feature/fga/waiting_ss_room
This commit is contained in:
commit
da27970116
249 changed files with 3147 additions and 677 deletions
11
.github/ISSUE_TEMPLATE/story.yml
vendored
11
.github/ISSUE_TEMPLATE/story.yml
vendored
|
|
@ -6,25 +6,28 @@ labels: [T-Story]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
|
label: Story
|
||||||
|
description: A story should take roughly a week or a sprint to finish. Each story is usually made up of a number of tasks that take half to a full day.
|
||||||
value: |
|
value: |
|
||||||
As a user…
|
As a user…
|
||||||
I want to…
|
I want to…
|
||||||
so that I can…
|
so that I can…
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
<!-- These should be a list of technical tasks which take ½-1 day to complete -->
|
<!--These should be a list of technical tasks which take ½-1 day to complete-->
|
||||||
```[tasklist]
|
```[tasklist]
|
||||||
### Tasklist
|
### Tasklist
|
||||||
- [ ] Task 1
|
- [ ] Task 1
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] QA signoff on completion
|
- [ ] QA signoff on completion
|
||||||
- [ ] Design signoff on completion
|
- [ ] Design signoff on completion
|
||||||
- [ ] Product signoff on completion
|
- [ ] Product signoff on completion
|
||||||
```
|
|
||||||
|
|
||||||
## Stretch goals
|
## Stretch goals
|
||||||
None at this time
|
None at this time
|
||||||
<!-- or add a tasklist -->
|
<!--or add a tasklist-->
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
-
|
-
|
||||||
|
|
|
||||||
2
.github/workflows/maestro.yml
vendored
2
.github/workflows/maestro.yml
vendored
|
|
@ -50,4 +50,6 @@ jobs:
|
||||||
USERNAME=maestroelement
|
USERNAME=maestroelement
|
||||||
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
|
PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
|
||||||
ROOM_NAME=MyRoom
|
ROOM_NAME=MyRoom
|
||||||
|
INVITEE1_MXID=@maestroelement2:matrix.org
|
||||||
|
INVITEE2_MXID=@maestroelement3:matrix.org
|
||||||
APP_ID=io.element.android.x.debug
|
APP_ID=io.element.android.x.debug
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ From root dir of the project
|
||||||
```shell
|
```shell
|
||||||
maestro test \
|
maestro test \
|
||||||
-e APP_ID=io.element.android.x.debug \
|
-e APP_ID=io.element.android.x.debug \
|
||||||
-e USERNAME=user \
|
-e USERNAME=user1 \
|
||||||
-e PASSWORD=123 \
|
-e PASSWORD=123 \
|
||||||
-e ROOM_NAME="MyRoom" \
|
-e ROOM_NAME="MyRoom" \
|
||||||
|
-e INVITEE1_MXID=user2 \
|
||||||
|
-e INVITEE2_MXID=user3 \
|
||||||
.maestro/allTests.yaml
|
.maestro/allTests.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
13
.maestro/tests/roomList/createAndDeleteDM.yaml
Normal file
13
.maestro/tests/roomList/createAndDeleteDM.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
# Purpose: Test the creation and deletion of a DM room.
|
||||||
|
- tapOn: "Create a new conversation or room"
|
||||||
|
- tapOn: "Search for someone"
|
||||||
|
- inputText: ${INVITEE1_MXID}
|
||||||
|
- tapOn:
|
||||||
|
text: ${INVITEE1_MXID}
|
||||||
|
index: 1
|
||||||
|
- takeScreenshot: build/maestro/330-createAndDeleteDM
|
||||||
|
- tapOn: "maestroelement2"
|
||||||
|
- tapOn: "Leave room"
|
||||||
|
- tapOn: "Leave"
|
||||||
33
.maestro/tests/roomList/createAndDeleteRoom.yaml
Normal file
33
.maestro/tests/roomList/createAndDeleteRoom.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
# Purpose: Test the creation and deletion of a room
|
||||||
|
- tapOn: "Create a new conversation or room"
|
||||||
|
- tapOn: "New room"
|
||||||
|
- tapOn: "Search for someone"
|
||||||
|
- inputText: ${INVITEE1_MXID}
|
||||||
|
- tapOn:
|
||||||
|
text: ${INVITEE1_MXID}
|
||||||
|
index: 1
|
||||||
|
- tapOn: "Next"
|
||||||
|
- tapOn: "e.g. your project name"
|
||||||
|
- inputText: "aRoomName"
|
||||||
|
- tapOn: "What is this room about?"
|
||||||
|
- inputText: "aRoomTopic"
|
||||||
|
- tapOn: "Create"
|
||||||
|
- takeScreenshot: build/maestro/320-createAndDeleteRoom
|
||||||
|
- tapOn: "aRoomName"
|
||||||
|
- tapOn: "Invite people"
|
||||||
|
# assert there's 1 memeber and 1 invitee
|
||||||
|
- tapOn: "Search for someone"
|
||||||
|
- inputText: ${INVITEE2_MXID}
|
||||||
|
- tapOn:
|
||||||
|
text: ${INVITEE2_MXID}
|
||||||
|
index: 1
|
||||||
|
- tapOn: "Send"
|
||||||
|
- tapOn: "Back"
|
||||||
|
- tapOn: "aRoomName"
|
||||||
|
- tapOn: "People"
|
||||||
|
# assert there's 1 memeber and 2 invitees
|
||||||
|
- tapOn: "Back"
|
||||||
|
- tapOn: "Leave room"
|
||||||
|
- tapOn: "Leave"
|
||||||
14
.maestro/tests/roomList/roomContextMenu.yaml
Normal file
14
.maestro/tests/roomList/roomContextMenu.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
# Purpose: Test the context menu of a room in the room list
|
||||||
|
- longPressOn: ${ROOM_NAME}
|
||||||
|
- takeScreenshot: build/maestro/310-RoomList-ContextMenu
|
||||||
|
- tapOn:
|
||||||
|
text: "Settings"
|
||||||
|
index: 0
|
||||||
|
- tapOn: "Back"
|
||||||
|
- longPressOn: ${ROOM_NAME}
|
||||||
|
- tapOn:
|
||||||
|
text: "Leave room"
|
||||||
|
index: 0
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
appId: ${APP_ID}
|
appId: ${APP_ID}
|
||||||
---
|
---
|
||||||
- takeScreenshot: build/maestro/300-RoomList
|
|
||||||
- runFlow: searchRoomList.yaml
|
- runFlow: searchRoomList.yaml
|
||||||
|
- takeScreenshot: build/maestro/300-RoomList
|
||||||
- runFlow: timeline/timeline.yaml
|
- runFlow: timeline/timeline.yaml
|
||||||
|
- runFlow: roomContextMenu.yaml
|
||||||
|
- runFlow: createAndDeleteRoom.yaml
|
||||||
|
- runFlow: createAndDeleteDM.yaml
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.appnav
|
||||||
|
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.Remove
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Don't process NewRoot if the nav target already exists in the stack.
|
* Don't process NewRoot if the nav target already exists in the stack.
|
||||||
|
|
@ -29,3 +30,14 @@ fun <T : Any> BackStack<T>.safeRoot(element: T) {
|
||||||
if (containsRoot) return
|
if (containsRoot) return
|
||||||
accept(NewRoot(element))
|
accept(NewRoot(element))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the last element on the backstack equals to the given one.
|
||||||
|
*/
|
||||||
|
fun <T : Any> BackStack<T>.removeLast(element: T) {
|
||||||
|
val lastExpectedNavElement = elements.value.lastOrNull {
|
||||||
|
it.key.navTarget == element
|
||||||
|
} ?: return
|
||||||
|
accept(Remove(lastExpectedNavElement.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
|
@ -93,7 +92,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
snackbarDispatcher: SnackbarDispatcher,
|
snackbarDispatcher: SnackbarDispatcher,
|
||||||
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.SplashScreen,
|
initialElement = NavTarget.RoomList,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
|
|
@ -105,22 +104,14 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { isConsentAsked ->
|
.onEach { isConsentAsked ->
|
||||||
if (isConsentAsked) {
|
if (isConsentAsked) {
|
||||||
switchToRoomList()
|
backstack.removeLast(NavTarget.AnalyticsOptIn)
|
||||||
} else {
|
} else {
|
||||||
switchToAnalytics()
|
backstack.push(NavTarget.AnalyticsOptIn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToRoomList() {
|
|
||||||
backstack.safeRoot(NavTarget.RoomList)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun switchToAnalytics() {
|
|
||||||
backstack.safeRoot(NavTarget.AnalyticsSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun onOpenBugReport() = Unit
|
fun onOpenBugReport() = Unit
|
||||||
}
|
}
|
||||||
|
|
@ -196,9 +187,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
|
||||||
object SplashScreen : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Permanent : NavTarget
|
object Permanent : NavTarget
|
||||||
|
|
||||||
|
|
@ -224,12 +212,11 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
object InviteList : NavTarget
|
object InviteList : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object AnalyticsSettings : NavTarget
|
object AnalyticsOptIn : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.SplashScreen -> splashNode(buildContext)
|
|
||||||
NavTarget.Permanent -> {
|
NavTarget.Permanent -> {
|
||||||
createNode<LoggedInNode>(buildContext)
|
createNode<LoggedInNode>(buildContext)
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +309,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
.callback(callback)
|
.callback(callback)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
NavTarget.AnalyticsSettings -> {
|
NavTarget.AnalyticsOptIn -> {
|
||||||
analyticsOptInEntryPoint.createNode(this, buildContext)
|
analyticsOptInEntryPoint.createNode(this, buildContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -341,12 +328,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun splashNode(buildContext: BuildContext) = node(buildContext) {
|
|
||||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import com.bumble.appyx.core.node.node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -42,6 +41,7 @@ import io.element.android.appnav.intent.IntentResolver
|
||||||
import io.element.android.appnav.intent.ResolvedIntent
|
import io.element.android.appnav.intent.ResolvedIntent
|
||||||
import io.element.android.appnav.root.RootPresenter
|
import io.element.android.appnav.root.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
import io.element.android.appnav.root.RootView
|
||||||
|
import io.element.android.features.login.api.LoginUserStory
|
||||||
import io.element.android.features.login.api.oidc.OidcAction
|
import io.element.android.features.login.api.oidc.OidcAction
|
||||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||||
import io.element.android.features.preferences.api.CacheService
|
import io.element.android.features.preferences.api.CacheService
|
||||||
|
|
@ -49,19 +49,23 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.architecture.waitForChildAttached
|
||||||
import io.element.android.libraries.deeplink.DeeplinkData
|
import io.element.android.libraries.deeplink.DeeplinkData
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
class RootFlowNode @AssistedInject constructor(
|
class RootFlowNode @AssistedInject constructor(
|
||||||
|
|
@ -74,6 +78,7 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
private val bugReportEntryPoint: BugReportEntryPoint,
|
private val bugReportEntryPoint: BugReportEntryPoint,
|
||||||
private val intentResolver: IntentResolver,
|
private val intentResolver: IntentResolver,
|
||||||
private val oidcActionFlow: OidcActionFlow,
|
private val oidcActionFlow: OidcActionFlow,
|
||||||
|
private val loginUserStory: LoginUserStory,
|
||||||
) :
|
) :
|
||||||
BackstackNode<RootFlowNode.NavTarget>(
|
BackstackNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
|
|
@ -90,21 +95,15 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeLoggedInState() {
|
private fun observeLoggedInState() {
|
||||||
authenticationService.isLoggedIn()
|
combine(
|
||||||
.distinctUntilChanged()
|
cacheService.onClearedCacheEventFlow(),
|
||||||
.combine(
|
isUserLoggedInFlow(),
|
||||||
cacheService.cacheIndex().onEach {
|
) { _, isLoggedIn -> isLoggedIn }
|
||||||
Timber.v("cacheIndex=$it")
|
.onEach { isLoggedIn ->
|
||||||
matrixClientsHolder.removeAll()
|
Timber.v("isLoggedIn=$isLoggedIn")
|
||||||
}
|
|
||||||
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
|
|
||||||
.onEach { pair ->
|
|
||||||
val isLoggedIn = pair.first
|
|
||||||
val cacheIndex = pair.second
|
|
||||||
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
tryToRestoreLatestSession(
|
tryToRestoreLatestSession(
|
||||||
onSuccess = { switchToLoggedInFlow(it, cacheIndex) },
|
onSuccess = { switchToLoggedInFlow(it) },
|
||||||
onFailure = { switchToNotLoggedInFlow() }
|
onFailure = { switchToNotLoggedInFlow() }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -114,8 +113,19 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
|
|
||||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
|
private fun switchToLoggedInFlow(sessionId: SessionId) {
|
||||||
|
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUserLoggedInFlow(): Flow<Boolean> {
|
||||||
|
return combine(
|
||||||
|
authenticationService.isLoggedIn(),
|
||||||
|
loginUserStory.loginFlowIsDone
|
||||||
|
) { isLoggedIn, loginFlowIsDone ->
|
||||||
|
isLoggedIn && loginFlowIsDone
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun switchToNotLoggedInFlow() {
|
private fun switchToNotLoggedInFlow() {
|
||||||
|
|
@ -123,28 +133,38 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
backstack.safeRoot(NavTarget.NotLoggedInFlow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun restoreSessionIfNeeded(
|
||||||
|
sessionId: SessionId,
|
||||||
|
onFailure: () -> Unit = {},
|
||||||
|
onSuccess: (SessionId) -> Unit = {},
|
||||||
|
) {
|
||||||
|
// If the session is already known it'll be restored by the node hierarchy
|
||||||
|
if (matrixClientsHolder.knowSession(sessionId)) {
|
||||||
|
Timber.v("Session $sessionId already alive, no need to restore.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authenticationService.restoreSession(sessionId)
|
||||||
|
.onSuccess { matrixClient ->
|
||||||
|
matrixClientsHolder.add(matrixClient)
|
||||||
|
Timber.v("Succeed to restore session $sessionId")
|
||||||
|
onSuccess(sessionId)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
Timber.v("Failed to restore session $sessionId")
|
||||||
|
onFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun tryToRestoreLatestSession(
|
private suspend fun tryToRestoreLatestSession(
|
||||||
onSuccess: (UserId) -> Unit = {},
|
onSuccess: (SessionId) -> Unit = {},
|
||||||
onFailure: () -> Unit = {}
|
onFailure: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val latestKnownUserId = authenticationService.getLatestSessionId()
|
val latestSessionId = authenticationService.getLatestSessionId()
|
||||||
if (latestKnownUserId == null) {
|
if (latestSessionId == null) {
|
||||||
onFailure()
|
onFailure()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (matrixClientsHolder.knowSession(latestKnownUserId)) {
|
restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
|
||||||
onSuccess(latestKnownUserId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authenticationService.restoreSession(UserId(latestKnownUserId.value))
|
|
||||||
.onSuccess { matrixClient ->
|
|
||||||
matrixClientsHolder.add(matrixClient)
|
|
||||||
onSuccess(matrixClient.sessionId)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
Timber.v("Failed to restore session...")
|
|
||||||
onFailure()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onOpenBugReport() {
|
private fun onOpenBugReport() {
|
||||||
|
|
@ -175,7 +195,10 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
object NotLoggedInFlow : NavTarget
|
object NotLoggedInFlow : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget
|
data class LoggedInFlow(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val navId: UUID = UUID.randomUUID(),
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object BugReport : NavTarget
|
object BugReport : NavTarget
|
||||||
|
|
@ -186,7 +209,6 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
is NavTarget.LoggedInFlow -> {
|
is NavTarget.LoggedInFlow -> {
|
||||||
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
|
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
|
||||||
Timber.w("Couldn't find any session, go through SplashScreen")
|
Timber.w("Couldn't find any session, go through SplashScreen")
|
||||||
backstack.newRoot(NavTarget.SplashScreen)
|
|
||||||
}
|
}
|
||||||
val inputs = LoggedInFlowNode.Inputs(matrixClient)
|
val inputs = LoggedInFlowNode.Inputs(matrixClient)
|
||||||
val callback = object : LoggedInFlowNode.Callback {
|
val callback = object : LoggedInFlowNode.Callback {
|
||||||
|
|
@ -247,9 +269,16 @@ class RootFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
|
||||||
val cacheIndex = cacheService.cacheIndex().first()
|
//TODO handle multi-session
|
||||||
return attachChild {
|
return waitForChildAttached { navTarget ->
|
||||||
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
|
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CacheService.onClearedCacheEventFlow(): Flow<Unit> {
|
||||||
|
return clearedCacheEventFlow
|
||||||
|
.onEach { sessionId -> matrixClientsHolder.remove(sessionId) }
|
||||||
|
.map { }
|
||||||
|
.onStart { emit((Unit)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ koverMerged {
|
||||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||||
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||||
excludes += "io.element.android.features.location.impl.map.MapState"
|
excludes += "io.element.android.features.location.impl.map.MapState"
|
||||||
|
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
1
changelog.d/712.bugfix
Normal file
1
changelog.d/712.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Fix actions for redacted, not sent and media messages
|
||||||
1
changelog.d/792.bugfix
Normal file
1
changelog.d/792.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Use the `Outlined` version of M3 textfields for the login screen.
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,25 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
|
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
data class Location(
|
data class Location(
|
||||||
val lat: Double,
|
val lat: Double,
|
||||||
val lon: Double,
|
val lon: Double,
|
||||||
val accuracy: Float,
|
val accuracy: Float,
|
||||||
)
|
) : Parcelable {
|
||||||
|
companion object {
|
||||||
fun parseGeoUri(geoUri: String): Location? {
|
fun fromGeoUri(geoUri: String): Location? {
|
||||||
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
|
val result = Regex(GEO_URI_REGEX).matchEntire(geoUri) ?: return null
|
||||||
return Location (
|
return Location(
|
||||||
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
|
lat = result.groups["latitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||||
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
|
lon = result.groups["longitude"]?.value?.toDoubleOrNull() ?: return null,
|
||||||
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
|
accuracy = result.groups["uncertainty"]?.value?.toFloatOrNull() ?: 0f,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
|
||||||
|
interface ShowLocationEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
|
data class Inputs(val location: Location, val description: String?) : NodeInputs
|
||||||
|
|
||||||
|
fun createNode(parentNode: Node, buildContext: BuildContext, inputs: Inputs): Node
|
||||||
|
}
|
||||||
|
|
@ -19,57 +19,57 @@ package io.element.android.features.location.api
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
internal class GeoUrisKtTest {
|
internal class LocationKtTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parseGeoUri - returns null for invalid urls`() {
|
fun `parseGeoUri - returns null for invalid urls`() {
|
||||||
assertThat(parseGeoUri("")).isNull()
|
assertThat(Location.fromGeoUri("")).isNull()
|
||||||
assertThat(parseGeoUri("http://example.com/")).isNull()
|
assertThat(Location.fromGeoUri("http://example.com/")).isNull()
|
||||||
assertThat(parseGeoUri("geo:")).isNull()
|
assertThat(Location.fromGeoUri("geo:")).isNull()
|
||||||
assertThat(parseGeoUri("geo:1.234")).isNull()
|
assertThat(Location.fromGeoUri("geo:1.234")).isNull()
|
||||||
assertThat(parseGeoUri("geo:1.234,")).isNull()
|
assertThat(Location.fromGeoUri("geo:1.234,")).isNull()
|
||||||
assertThat(parseGeoUri("geo:,1.234")).isNull()
|
assertThat(Location.fromGeoUri("geo:,1.234")).isNull()
|
||||||
assertThat(parseGeoUri("notgeo:1.234,5.678")).isNull()
|
assertThat(Location.fromGeoUri("notgeo:1.234,5.678")).isNull()
|
||||||
assertThat(parseGeoUri("geo:+1.234,5.678")).isNull()
|
assertThat(Location.fromGeoUri("geo:+1.234,5.678")).isNull()
|
||||||
assertThat(parseGeoUri("geo:+1.234,*5.678")).isNull()
|
assertThat(Location.fromGeoUri("geo:+1.234,*5.678")).isNull()
|
||||||
assertThat(parseGeoUri("geo:not,good")).isNull()
|
assertThat(Location.fromGeoUri("geo:not,good")).isNull()
|
||||||
assertThat(parseGeoUri("geo:1.234,5.678;u=wrong")).isNull()
|
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=wrong")).isNull()
|
||||||
assertThat(parseGeoUri("geo:1.234,5.678trailing")).isNull()
|
assertThat(Location.fromGeoUri("geo:1.234,5.678trailing")).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parseGeoUri - returns location for valid urls`() {
|
fun `parseGeoUri - returns location for valid urls`() {
|
||||||
assertThat(parseGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1.234,5.678")).isEqualTo(Location(
|
||||||
lat = 1.234,
|
lat = 1.234,
|
||||||
lon = 5.678,
|
lon = 5.678,
|
||||||
accuracy = 0f,
|
accuracy = 0f,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(parseGeoUri("geo:1,5")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1,5")).isEqualTo(Location(
|
||||||
lat = 1.0,
|
lat = 1.0,
|
||||||
lon = 5.0,
|
lon = 5.0,
|
||||||
accuracy = 0f,
|
accuracy = 0f,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(parseGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1.234,5.678;u=3000")).isEqualTo(Location(
|
||||||
lat = 1.234,
|
lat = 1.234,
|
||||||
lon = 5.678,
|
lon = 5.678,
|
||||||
accuracy = 3000f,
|
accuracy = 3000f,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(parseGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:1,5;u=3000")).isEqualTo(Location(
|
||||||
lat = 1.0,
|
lat = 1.0,
|
||||||
lon = 5.0,
|
lon = 5.0,
|
||||||
accuracy = 3000f,
|
accuracy = 3000f,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(parseGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:-1.234,-5.678;u=9.10")).isEqualTo(Location(
|
||||||
lat = -1.234,
|
lat = -1.234,
|
||||||
lon = -5.678,
|
lon = -5.678,
|
||||||
accuracy = 9.10f,
|
accuracy = 9.10f,
|
||||||
))
|
))
|
||||||
|
|
||||||
assertThat(parseGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
|
assertThat(Location.fromGeoUri("geo:-1,-5;u=9.10")).isEqualTo(Location(
|
||||||
lat = -1.0,
|
lat = -1.0,
|
||||||
lon = -5.0,
|
lon = -5.0,
|
||||||
accuracy = 9.10f,
|
accuracy = 9.10f,
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.services.analytics.api)
|
||||||
implementation(libs.maplibre)
|
implementation(libs.maplibre)
|
||||||
implementation(libs.maplibre.annotation)
|
implementation(libs.maplibre.annotation)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -62,6 +63,9 @@ class SendLocationPresenter @Inject constructor(
|
||||||
room.sendLocation(
|
room.sendLocation(
|
||||||
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
|
body = "Location at latitude: ${event.lat}, longitude: ${event.lng}",
|
||||||
geoUri = "geo:${event.lat},${event.lng}",
|
geoUri = "geo:${event.lat},${event.lng}",
|
||||||
|
description = null,
|
||||||
|
zoomLevel = 15, // Send default zoom level for now.
|
||||||
|
assetType = AssetType.PIN,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.core.content.getSystemService
|
||||||
import androidx.core.location.LocationListenerCompat
|
import androidx.core.location.LocationListenerCompat
|
||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import androidx.core.location.LocationRequestCompat
|
import androidx.core.location.LocationRequestCompat
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import kotlinx.coroutines.asExecutor
|
import kotlinx.coroutines.asExecutor
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.location.impl.map
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -29,7 +30,10 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -44,10 +48,12 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
|
||||||
import com.mapbox.mapboxsdk.maps.Style
|
import com.mapbox.mapboxsdk.maps.Style
|
||||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
|
||||||
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
|
||||||
|
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.internal.buildTileServerUrl
|
import io.element.android.features.location.api.internal.buildTileServerUrl
|
||||||
import io.element.android.features.location.impl.location.Location
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -69,7 +75,11 @@ fun MapView(
|
||||||
// When in preview, early return a Box with the received modifier preserving layout
|
// When in preview, early return a Box with the received modifier preserving layout
|
||||||
if (LocalInspectionMode.current) {
|
if (LocalInspectionMode.current) {
|
||||||
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
|
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
|
||||||
Box(modifier = modifier)
|
Box(
|
||||||
|
modifier = modifier.background(Color.DarkGray)
|
||||||
|
) {
|
||||||
|
Text("[MapView]", modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,11 +90,14 @@ fun MapView(
|
||||||
}
|
}
|
||||||
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
|
var mapRefs by remember { mutableStateOf<MapRefs?>(null) }
|
||||||
|
|
||||||
|
val attributionColour = ElementTheme.colors.iconPrimary
|
||||||
|
|
||||||
// Build map
|
// Build map
|
||||||
LaunchedEffect(darkMode) {
|
LaunchedEffect(darkMode) {
|
||||||
mapView.awaitMap().let { map ->
|
mapView.awaitMap().let { map ->
|
||||||
map.uiSettings.apply {
|
map.uiSettings.apply {
|
||||||
attributionGravity = Gravity.TOP
|
attributionGravity = Gravity.TOP
|
||||||
|
setAttributionTintColor(attributionColour.toArgb())
|
||||||
logoGravity = Gravity.TOP
|
logoGravity = Gravity.TOP
|
||||||
isCompassEnabled = false
|
isCompassEnabled = false
|
||||||
isRotateGesturesEnabled = false
|
isRotateGesturesEnabled = false
|
||||||
|
|
@ -155,7 +168,7 @@ fun MapView(
|
||||||
.withLatLng(LatLng(location.lat, location.lon))
|
.withLatLng(LatLng(location.lat, location.lon))
|
||||||
.withIconImage("pin")
|
.withIconImage("pin")
|
||||||
.withIconSize(1.3f)
|
.withIconSize(1.3f)
|
||||||
.withIconOffset(arrayOf(0f, 0.5f))
|
.withIconAnchor(ICON_ANCHOR_BOTTOM)
|
||||||
)
|
)
|
||||||
Timber.d("Shown pin at location: $location")
|
Timber.d("Shown pin at location: $location")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class AndroidLocationActions @Inject constructor(
|
||||||
|
@ApplicationContext private val appContext: Context
|
||||||
|
) : LocationActions {
|
||||||
|
|
||||||
|
private var activityContext: Context? = null
|
||||||
|
|
||||||
|
override fun share(location: Location, label: String?) {
|
||||||
|
runCatching {
|
||||||
|
val uri = Uri.parse(buildUrl(location, label))
|
||||||
|
val showMapsIntent = Intent(Intent.ACTION_VIEW).setData(uri)
|
||||||
|
val chooserIntent = Intent.createChooser(showMapsIntent, null)
|
||||||
|
chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
appContext.startActivity(chooserIntent)
|
||||||
|
}.onSuccess {
|
||||||
|
Timber.v("Open location succeed")
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Open location failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun buildUrl(
|
||||||
|
location: Location,
|
||||||
|
label: String?,
|
||||||
|
urlEncoder: (String) -> String = Uri::encode
|
||||||
|
): String {
|
||||||
|
// Ref: https://developer.android.com/guide/components/intents-common#ViewMap
|
||||||
|
val base = "geo:0,0?q=%.6f,%.6f".format(location.lat, location.lon)
|
||||||
|
return if (label == null) {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
"%s (%s)".format(base, urlEncoder(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,13 +14,10 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.location.impl.location
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
/**
|
import io.element.android.features.location.api.Location
|
||||||
* Represents a location sample emitted by the device's location subsystem.
|
|
||||||
*/
|
interface LocationActions {
|
||||||
data class Location(
|
fun share(location: Location, label: String?)
|
||||||
val lat: Double,
|
}
|
||||||
val lon: Double,
|
|
||||||
val accuracy: Float,
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class ShowLocationEntryPointImpl @Inject constructor() : ShowLocationEntryPoint {
|
||||||
|
override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ShowLocationEntryPoint.Inputs): Node {
|
||||||
|
return parentNode.createNode<ShowLocationNode>(buildContext, listOf(inputs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
sealed interface ShowLocationEvents {
|
||||||
|
object Share : ShowLocationEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
||||||
|
@ContributesNode(RoomScope::class)
|
||||||
|
class ShowLocationNode @AssistedInject constructor(
|
||||||
|
presenterFactory: ShowLocationPresenter.Factory,
|
||||||
|
analyticsService: AnalyticsService,
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onResume = {
|
||||||
|
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.LocationView))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val inputs: ShowLocationEntryPoint.Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.location, inputs.description)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
ShowLocationView(
|
||||||
|
state = presenter.present(),
|
||||||
|
modifier = modifier,
|
||||||
|
onBackPressed = ::navigateUp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
||||||
|
class ShowLocationPresenter @AssistedInject constructor(
|
||||||
|
private val actions: LocationActions,
|
||||||
|
@Assisted private val location: Location,
|
||||||
|
@Assisted private val description: String?
|
||||||
|
) : Presenter<ShowLocationState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(location: Location, description: String?): ShowLocationPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): ShowLocationState {
|
||||||
|
return ShowLocationState(
|
||||||
|
location = location,
|
||||||
|
description = description
|
||||||
|
) {
|
||||||
|
when (it) {
|
||||||
|
ShowLocationEvents.Share -> actions.share(location, description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
data class ShowLocationState(
|
||||||
|
val location: Location,
|
||||||
|
val description: String?,
|
||||||
|
val eventSink: (ShowLocationEvents) -> Unit,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
|
||||||
|
override val values: Sequence<ShowLocationState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
ShowLocationState(
|
||||||
|
Location(1.23, 2.34, 4f),
|
||||||
|
description = null,
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
|
ShowLocationState(
|
||||||
|
Location(1.23, 2.34, 4f),
|
||||||
|
description = "My favourite place!",
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
|
ShowLocationState(
|
||||||
|
Location(1.23, 2.34, 4f),
|
||||||
|
description = "For some reason I decided to write a small essay in the location description. " +
|
||||||
|
"It is so long that it will wrap onto more than two lines!",
|
||||||
|
eventSink = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Share
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.features.location.impl.map.MapState
|
||||||
|
import io.element.android.features.location.impl.map.MapView
|
||||||
|
import io.element.android.features.location.impl.map.rememberMapState
|
||||||
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
|
||||||
|
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.theme.compound.generated.TypographyTokens
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShowLocationView(
|
||||||
|
state: ShowLocationState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onBackPressed: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val mapState = rememberMapState(
|
||||||
|
location = state.location,
|
||||||
|
position = MapState.CameraPosition(state.location.lat, state.location.lon, 15.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(modifier,
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.screen_view_location_title),
|
||||||
|
style = TypographyTokens.fontBodyLgMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BackButton(onClick = onBackPressed)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { state.eventSink(ShowLocationEvents.Share) }) {
|
||||||
|
Icon(imageVector = Icons.Outlined.Share, contentDescription = stringResource(CommonStrings.action_share))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.consumeWindowInsets(paddingValues)
|
||||||
|
.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
state.description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = TypographyTokens.fontBodyMdRegular,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MapView(
|
||||||
|
mapState = mapState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun ShowLocationViewLightPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun ShowLocationViewDarkPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: ShowLocationState) {
|
||||||
|
ShowLocationView(
|
||||||
|
state = state,
|
||||||
|
onBackPressed = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,18 +16,19 @@
|
||||||
|
|
||||||
package io.element.android.features.location.impl.location
|
package io.element.android.features.location.impl.location
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
fun fakeLocationUpdatesFlow(): Flow<io.element.android.features.location.impl.location.Location> = flow {
|
fun fakeLocationUpdatesFlow(): Flow<Location> = flow {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
emit(aLocation())
|
emit(aLocation())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aLocation() = io.element.android.features.location.impl.location.Location(
|
private fun aLocation() = Location(
|
||||||
lat = 51.49404,
|
lat = 51.49404,
|
||||||
lon = -0.25484,
|
lon = -0.25484,
|
||||||
accuracy = 5f
|
accuracy = 5f
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import org.junit.Test
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
internal class AndroidLocationActionsTest {
|
||||||
|
|
||||||
|
// We use an Android-native encoder in the actual app, switch to an equivalent JVM one for the tests
|
||||||
|
private fun urlEncoder(input: String) = URLEncoder.encode(input, "US-ASCII")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildUrl - truncates excessive decimals to 6dp`() {
|
||||||
|
val location = Location(
|
||||||
|
lat = 1.234567890123,
|
||||||
|
lon = 123.456789012345,
|
||||||
|
accuracy = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
val actual = buildUrl(location, null, ::urlEncoder)
|
||||||
|
val expected = "geo:0,0?q=1.234568,123.456789"
|
||||||
|
|
||||||
|
assertThat(actual).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildUrl - appends label if set`() {
|
||||||
|
val location = Location(
|
||||||
|
lat = 1.000001,
|
||||||
|
lon = 2.000001,
|
||||||
|
accuracy = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
val actual = buildUrl(location, "point", ::urlEncoder)
|
||||||
|
val expected = "geo:0,0?q=1.000001,2.000001 (point)"
|
||||||
|
|
||||||
|
assertThat(actual).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildUrl - URL encodes label`() {
|
||||||
|
val location = Location(
|
||||||
|
lat = 1.000001,
|
||||||
|
lon = 2.000001,
|
||||||
|
accuracy = 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
val actual = buildUrl(location, "(weird/stuff here)", ::urlEncoder)
|
||||||
|
val expected = "geo:0,0?q=1.000001,2.000001 (%28weird%2Fstuff+here%29)"
|
||||||
|
|
||||||
|
assertThat(actual).isEqualTo(expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
|
||||||
|
class FakeLocationActions : LocationActions {
|
||||||
|
|
||||||
|
var sharedLocation: Location? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
var sharedLabel: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun share(location: Location, label: String?) {
|
||||||
|
sharedLocation = location
|
||||||
|
sharedLabel = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ShowLocationPresenterTest {
|
||||||
|
|
||||||
|
private val actions = FakeLocationActions()
|
||||||
|
private val location = Location(1.23, 4.56, 7.8f)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emits initial state`() = runTest {
|
||||||
|
val presenter = ShowLocationPresenter(
|
||||||
|
actions,
|
||||||
|
location,
|
||||||
|
A_DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
Truth.assertThat(initialState.location).isEqualTo(location)
|
||||||
|
Truth.assertThat(initialState.description).isEqualTo(A_DESCRIPTION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses action to share location`() = runTest {
|
||||||
|
val presenter = ShowLocationPresenter(
|
||||||
|
actions,
|
||||||
|
location,
|
||||||
|
A_DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(ShowLocationEvents.Share)
|
||||||
|
|
||||||
|
Truth.assertThat(actions.sharedLocation).isEqualTo(location)
|
||||||
|
Truth.assertThat(actions.sharedLabel).isEqualTo(A_DESCRIPTION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val A_DESCRIPTION = "My happy place"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface LoginUserStory {
|
||||||
|
val loginFlowIsDone: StateFlow<Boolean>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.login.api.LoginUserStory
|
||||||
|
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 DefaultLoginUserStory @Inject constructor() : LoginUserStory {
|
||||||
|
// True by default, will be set to false when the login user story is started, and set to true again once it's done.
|
||||||
|
override val loginFlowIsDone: MutableStateFlow<Boolean> = MutableStateFlow(true)
|
||||||
|
|
||||||
|
fun setLoginFlowIsDone(value: Boolean) {
|
||||||
|
loginFlowIsDone.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.login.impl
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -28,6 +27,7 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -39,8 +39,10 @@ import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
|
||||||
import io.element.android.features.login.impl.oidc.webview.OidcNode
|
import io.element.android.features.login.impl.oidc.webview.OidcNode
|
||||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||||
|
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||||
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
|
||||||
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
|
||||||
|
import io.element.android.features.login.impl.screens.waitlistscreen.WaitListNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
|
|
@ -58,6 +60,7 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||||
private val customTabHandler: CustomTabHandler,
|
private val customTabHandler: CustomTabHandler,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.ConfirmAccountProvider,
|
initialElement = NavTarget.ConfirmAccountProvider,
|
||||||
|
|
@ -75,6 +78,11 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
|
override fun onBuilt() {
|
||||||
|
super.onBuilt()
|
||||||
|
defaultLoginUserStory.setLoginFlowIsDone(false)
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object ConfirmAccountProvider : NavTarget
|
object ConfirmAccountProvider : NavTarget
|
||||||
|
|
@ -88,6 +96,9 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object LoginPassword : NavTarget
|
object LoginPassword : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class WaitList(val loginFormState: LoginFormState) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -144,12 +155,28 @@ class LoginFlowNode @AssistedInject constructor(
|
||||||
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.LoginPassword -> {
|
NavTarget.LoginPassword -> {
|
||||||
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
|
val callback = object : LoginPasswordNode.Callback {
|
||||||
|
override fun onWaitListError(loginFormState: LoginFormState) {
|
||||||
|
backstack.newRoot(NavTarget.WaitList(loginFormState))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
is NavTarget.OidcView -> {
|
is NavTarget.OidcView -> {
|
||||||
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||||
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||||
}
|
}
|
||||||
|
is NavTarget.WaitList -> {
|
||||||
|
val inputs = WaitListNode.Inputs(
|
||||||
|
loginFormState = navTarget.loginFormState,
|
||||||
|
)
|
||||||
|
val callback = object : WaitListNode.Callback {
|
||||||
|
override fun onCancelClicked() {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<WaitListNode>(buildContext, plugins = listOf(callback, inputs))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.error
|
||||||
|
|
||||||
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
|
|
||||||
|
fun Throwable.isWaitListError(): Boolean {
|
||||||
|
return message?.contains("IO_ELEMENT_X_WAIT_LIST").orFalse()
|
||||||
|
}
|
||||||
|
|
@ -91,7 +91,7 @@ fun ConfirmAccountProviderView(
|
||||||
text = stringResource(id = R.string.screen_account_provider_continue),
|
text = stringResource(id = R.string.screen_account_provider_continue),
|
||||||
showProgress = isLoading,
|
showProgress = isLoading,
|
||||||
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
|
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
|
||||||
enabled = state.submitEnabled,
|
enabled = state.submitEnabled || isLoading,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.testTag(TestTags.loginContinue)
|
.testTag(TestTags.loginContinue)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -33,13 +34,22 @@ class LoginPasswordNode @AssistedInject constructor(
|
||||||
private val presenter: LoginPasswordPresenter,
|
private val presenter: LoginPasswordPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onWaitListError(loginFormState: LoginFormState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onWaitListError(loginFormState: LoginFormState) {
|
||||||
|
plugins<Callback>().forEach { it.onWaitListError(loginFormState) }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
LoginPasswordView(
|
LoginPasswordView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackPressed = ::navigateUp
|
onBackPressed = ::navigateUp,
|
||||||
|
onWaitListError = ::onWaitListError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
|
@ -36,6 +37,7 @@ import javax.inject.Inject
|
||||||
class LoginPasswordPresenter @Inject constructor(
|
class LoginPasswordPresenter @Inject constructor(
|
||||||
private val authenticationService: MatrixAuthenticationService,
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
private val accountProviderDataSource: AccountProviderDataSource,
|
private val accountProviderDataSource: AccountProviderDataSource,
|
||||||
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
) : Presenter<LoginPasswordState> {
|
) : Presenter<LoginPasswordState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -77,6 +79,8 @@ class LoginPasswordPresenter @Inject constructor(
|
||||||
loggedInState.value = Async.Loading()
|
loggedInState.value = Async.Loading()
|
||||||
authenticationService.login(formState.login.trim(), formState.password)
|
authenticationService.login(formState.login.trim(), formState.password)
|
||||||
.onSuccess { sessionId ->
|
.onSuccess { sessionId ->
|
||||||
|
// We will not navigate to the WaitList screen, so the login user story is done
|
||||||
|
defaultLoginUserStory.setLoginFlowIsDone(true)
|
||||||
loggedInState.value = Async.Success(sessionId)
|
loggedInState.value = Async.Success(sessionId)
|
||||||
}
|
}
|
||||||
.onFailure { failure ->
|
.onFailure { failure ->
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.features.login.impl.screens.loginpassword
|
package io.element.android.features.login.impl.screens.loginpassword
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -56,6 +55,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.login.impl.R
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.features.login.impl.error.isWaitListError
|
||||||
import io.element.android.features.login.impl.error.loginError
|
import io.element.android.features.login.impl.error.loginError
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||||
|
|
@ -68,9 +68,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.theme.components.autofill
|
import io.element.android.libraries.designsystem.theme.components.autofill
|
||||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||||
|
|
@ -82,8 +82,9 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginPasswordView(
|
fun LoginPasswordView(
|
||||||
state: LoginPasswordState,
|
state: LoginPasswordState,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onBackPressed: () -> Unit,
|
onBackPressed: () -> Unit,
|
||||||
|
onWaitListError: (LoginFormState) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isLoading by remember(state.loginAction) {
|
val isLoading by remember(state.loginAction) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
|
|
@ -108,53 +109,60 @@ fun LoginPasswordView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
|
.verticalScroll(state = scrollState)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
// Title
|
||||||
|
IconTitleSubtitleMolecule(
|
||||||
Column(
|
modifier = Modifier.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||||
|
iconImageVector = Icons.Filled.AccountCircle,
|
||||||
|
title = stringResource(
|
||||||
|
id = R.string.screen_account_provider_signin_title,
|
||||||
|
state.accountProvider.title
|
||||||
|
),
|
||||||
|
subTitle = stringResource(id = R.string.screen_login_subtitle)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
LoginForm(
|
||||||
|
state = state,
|
||||||
|
isLoading = isLoading,
|
||||||
|
onSubmit = ::submit
|
||||||
|
)
|
||||||
|
// Min spacing
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
// Flexible spacing to keep the submit button at the bottom
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// Submit
|
||||||
|
ButtonWithProgress(
|
||||||
|
text = stringResource(R.string.screen_login_submit),
|
||||||
|
showProgress = isLoading,
|
||||||
|
onClick = ::submit,
|
||||||
|
enabled = state.submitEnabled || isLoading,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(state = scrollState)
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.testTag(TestTags.loginContinue)
|
||||||
) {
|
)
|
||||||
// Title
|
Spacer(modifier = Modifier.height(60.dp))
|
||||||
IconTitleSubtitleMolecule(
|
|
||||||
modifier = Modifier.padding(top = 20.dp),
|
|
||||||
iconImageVector = Icons.Filled.AccountCircle,
|
|
||||||
title = stringResource(
|
|
||||||
id = R.string.screen_account_provider_signin_title,
|
|
||||||
state.accountProvider.title
|
|
||||||
),
|
|
||||||
subTitle = stringResource(id = R.string.screen_login_form_header)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
LoginForm(state = state,
|
|
||||||
isLoading = isLoading,
|
|
||||||
onSubmit = ::submit
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(28.dp))
|
|
||||||
// Submit
|
|
||||||
ButtonWithProgress(
|
|
||||||
text = stringResource(R.string.screen_login_submit),
|
|
||||||
showProgress = isLoading,
|
|
||||||
onClick = ::submit,
|
|
||||||
enabled = state.submitEnabled,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.testTag(TestTags.loginContinue)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.loginAction is Async.Failure) {
|
if (state.loginAction is Async.Failure) {
|
||||||
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
when {
|
||||||
state.eventSink(LoginPasswordEvents.ClearError)
|
state.loginAction.error.isWaitListError() -> {
|
||||||
})
|
onWaitListError(state.formState)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LoginErrorDialog(error = state.loginAction.error, onDismiss = {
|
||||||
|
state.eventSink(LoginPasswordEvents.ClearError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +190,7 @@ internal fun LoginForm(
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextField(
|
OutlinedTextField(
|
||||||
value = loginFieldState,
|
value = loginFieldState,
|
||||||
readOnly = isLoading,
|
readOnly = isLoading,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -193,7 +201,7 @@ internal fun LoginForm(
|
||||||
loginFieldState = it
|
loginFieldState = it
|
||||||
eventSink(LoginPasswordEvents.SetLogin(it))
|
eventSink(LoginPasswordEvents.SetLogin(it))
|
||||||
}),
|
}),
|
||||||
label = {
|
placeholder = {
|
||||||
Text(text = stringResource(R.string.screen_login_username_hint))
|
Text(text = stringResource(R.string.screen_login_username_hint))
|
||||||
},
|
},
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
|
|
@ -225,7 +233,7 @@ internal fun LoginForm(
|
||||||
passwordVisible = false
|
passwordVisible = false
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextField(
|
OutlinedTextField(
|
||||||
value = passwordFieldState,
|
value = passwordFieldState,
|
||||||
readOnly = isLoading,
|
readOnly = isLoading,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -240,7 +248,7 @@ internal fun LoginForm(
|
||||||
passwordFieldState = it
|
passwordFieldState = it
|
||||||
eventSink(LoginPasswordEvents.SetPassword(it))
|
eventSink(LoginPasswordEvents.SetPassword(it))
|
||||||
},
|
},
|
||||||
label = {
|
placeholder = {
|
||||||
Text(text = stringResource(R.string.screen_login_password_hint))
|
Text(text = stringResource(R.string.screen_login_password_hint))
|
||||||
},
|
},
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
|
@ -269,6 +277,7 @@ internal fun LoginForm(
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||||
ErrorDialog(
|
ErrorDialog(
|
||||||
|
title = stringResource(id = CommonStrings.dialog_title_error),
|
||||||
content = stringResource(loginError(error)),
|
content = stringResource(loginError(error)),
|
||||||
onDismiss = onDismiss
|
onDismiss = onDismiss
|
||||||
)
|
)
|
||||||
|
|
@ -288,6 +297,7 @@ internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStatePr
|
||||||
private fun ContentToPreview(state: LoginPasswordState) {
|
private fun ContentToPreview(state: LoginPasswordState) {
|
||||||
LoginPasswordView(
|
LoginPasswordView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = {}
|
onBackPressed = {},
|
||||||
|
onWaitListError = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
sealed interface WaitListEvents {
|
||||||
|
object AttemptLogin : WaitListEvents
|
||||||
|
object ClearError : WaitListEvents
|
||||||
|
object Continue : WaitListEvents
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||||
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.inputs
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
class WaitListNode @AssistedInject constructor(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
presenterFactory: WaitListPresenter.Factory,
|
||||||
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
data class Inputs(val loginFormState: LoginFormState) : NodeInputs
|
||||||
|
|
||||||
|
private val inputs: Inputs = inputs()
|
||||||
|
private val presenter = presenterFactory.create(inputs.loginFormState)
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onCancelClicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCancelClicked() {
|
||||||
|
plugins<Callback>().forEach { it.onCancelClicked() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state = presenter.present()
|
||||||
|
WaitListView(
|
||||||
|
state = state,
|
||||||
|
onCancelClicked = ::onCancelClicked,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class WaitListPresenter @AssistedInject constructor(
|
||||||
|
@Assisted private val formState: LoginFormState,
|
||||||
|
private val buildMeta: BuildMeta,
|
||||||
|
private val authenticationService: MatrixAuthenticationService,
|
||||||
|
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||||
|
) : Presenter<WaitListState> {
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(loginFormState: LoginFormState): WaitListPresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): WaitListState {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val homeserverUrl = remember {
|
||||||
|
authenticationService.getHomeserverDetails().value?.url ?: "server"
|
||||||
|
}
|
||||||
|
|
||||||
|
val loginAction: MutableState<Async<SessionId>> = remember {
|
||||||
|
mutableStateOf(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attemptNumber: MutableState<Int> = remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
fun handleEvents(event: WaitListEvents) {
|
||||||
|
when (event) {
|
||||||
|
WaitListEvents.AttemptLogin -> {
|
||||||
|
// Do not attempt to login on first resume of the View.
|
||||||
|
attemptNumber.value++
|
||||||
|
if (attemptNumber.value > 1) {
|
||||||
|
coroutineScope.loginAttempt(formState, loginAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WaitListEvents.ClearError -> loginAction.value = Async.Uninitialized
|
||||||
|
WaitListEvents.Continue -> defaultLoginUserStory.setLoginFlowIsDone(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WaitListState(
|
||||||
|
appName = buildMeta.applicationName,
|
||||||
|
serverName = homeserverUrl,
|
||||||
|
loginAction = loginAction.value,
|
||||||
|
eventSink = ::handleEvents
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.loginAttempt(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch {
|
||||||
|
Timber.w("Attempt to login...")
|
||||||
|
loggedInState.value = Async.Loading()
|
||||||
|
authenticationService.login(formState.login.trim(), formState.password)
|
||||||
|
.onSuccess { sessionId ->
|
||||||
|
loggedInState.value = Async.Success(sessionId)
|
||||||
|
}
|
||||||
|
.onFailure { failure ->
|
||||||
|
loggedInState.value = Async.Failure(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
// Do not use default value, so no member get forgotten in the presenters.
|
||||||
|
data class WaitListState(
|
||||||
|
val appName: String,
|
||||||
|
val serverName: String,
|
||||||
|
val loginAction: Async<SessionId>,
|
||||||
|
val eventSink: (WaitListEvents) -> Unit
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
open class WaitListStateProvider : PreviewParameterProvider<WaitListState> {
|
||||||
|
override val values: Sequence<WaitListState>
|
||||||
|
get() = sequenceOf(
|
||||||
|
aWaitListState(loginAction = Async.Uninitialized),
|
||||||
|
aWaitListState(loginAction = Async.Loading()),
|
||||||
|
aWaitListState(loginAction = Async.Failure(Throwable())),
|
||||||
|
aWaitListState(loginAction = Async.Failure(Throwable(message = "IO_ELEMENT_X_WAIT_LIST"))),
|
||||||
|
aWaitListState(loginAction = Async.Success(SessionId("@alice:element.io"))),
|
||||||
|
// Add other state here
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun aWaitListState(
|
||||||
|
appName: String = "Element X",
|
||||||
|
serverName: String = "server.org",
|
||||||
|
loginAction: Async<SessionId> = Async.Uninitialized,
|
||||||
|
) = WaitListState(
|
||||||
|
appName = appName,
|
||||||
|
serverName = serverName,
|
||||||
|
loginAction = loginAction,
|
||||||
|
eventSink = {}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.BiasAbsoluteAlignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import io.element.android.features.login.impl.R
|
||||||
|
import io.element.android.features.login.impl.error.isWaitListError
|
||||||
|
import io.element.android.features.login.impl.error.loginError
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
// Ref: https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3/Element-X?type=design&node-id=6761-148425
|
||||||
|
// Only the first screen can be displayed, since once logged in, this Node will be remove by the RootNode.
|
||||||
|
@Composable
|
||||||
|
fun WaitListView(
|
||||||
|
state: WaitListState,
|
||||||
|
onCancelClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
OnLifecycleEvent { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
WaitListBackground()
|
||||||
|
WaitListContent(state, onCancelClicked)
|
||||||
|
WaitListError(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitListError(state: WaitListState) {
|
||||||
|
// Display a dialog for error other than the waitlist error
|
||||||
|
state.loginAction.errorOrNull()?.let { error ->
|
||||||
|
if (error.isWaitListError().not()) {
|
||||||
|
RetryDialog(
|
||||||
|
content = stringResource(id = loginError(error)),
|
||||||
|
onRetry = {
|
||||||
|
state.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
state.eventSink.invoke(WaitListEvents.ClearError)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitListBackground(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.3f)
|
||||||
|
.background(Color.White)
|
||||||
|
)
|
||||||
|
Image(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
painter = painterResource(id = R.drawable.light_dark),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.7f)
|
||||||
|
.background(Color(0xFF121418))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitListContent(
|
||||||
|
state: WaitListState,
|
||||||
|
onCancelClicked: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
|
) {
|
||||||
|
if (state.loginAction !is Async.Success) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onCancelClicked,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
disabledContainerColor = Color.White,
|
||||||
|
disabledContentColor = Color.Black,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.action_cancel),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = BiasAbsoluteAlignment(
|
||||||
|
horizontalBias = 0f,
|
||||||
|
verticalBias = -0.05f
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
if (state.loginAction.isLoading()) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
val titleRes = when (state.loginAction) {
|
||||||
|
is Async.Success -> R.string.screen_waitlist_title_success
|
||||||
|
else -> R.string.screen_waitlist_title
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = withColoredPeriod(titleRes),
|
||||||
|
style = ElementTheme.typography.fontHeadingXlBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
val subtitle = when (state.loginAction) {
|
||||||
|
is Async.Success -> stringResource(
|
||||||
|
id = R.string.screen_waitlist_message_success,
|
||||||
|
state.appName,
|
||||||
|
)
|
||||||
|
else -> stringResource(
|
||||||
|
id = R.string.screen_waitlist_message,
|
||||||
|
state.appName,
|
||||||
|
state.serverName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.widthIn(max = 360.dp),
|
||||||
|
text = subtitle,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.loginAction is Async.Success) {
|
||||||
|
Button(
|
||||||
|
onClick = { state.eventSink.invoke(WaitListEvents.Continue) },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color.White,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
disabledContainerColor = Color.White,
|
||||||
|
disabledContentColor = Color.Black,
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = CommonStrings.action_continue),
|
||||||
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun withColoredPeriod(
|
||||||
|
@StringRes textRes: Int,
|
||||||
|
) = buildAnnotatedString {
|
||||||
|
val text = stringResource(textRes)
|
||||||
|
append(text)
|
||||||
|
if (text.endsWith(".")) {
|
||||||
|
addStyle(
|
||||||
|
style = SpanStyle(
|
||||||
|
// Light.colorGreen700
|
||||||
|
color = Color(0xff0bc491),
|
||||||
|
),
|
||||||
|
start = text.length - 1,
|
||||||
|
end = text.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun WaitListViewLightPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) =
|
||||||
|
ElementPreviewLight { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun WaitListViewDarkPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) =
|
||||||
|
ElementPreviewDark { ContentToPreview(state) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview(state: WaitListState) {
|
||||||
|
WaitListView(
|
||||||
|
state = state,
|
||||||
|
onCancelClicked = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
features/login/impl/src/main/res/drawable/light_dark.png
Normal file
BIN
features/login/impl/src/main/res/drawable/light_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -24,7 +24,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
|
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
|
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
|
||||||
<string name="screen_login_form_header">"Zadejte své údaje"</string>
|
<string name="screen_login_form_header">"Zadejte své údaje"</string>
|
||||||
<string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string>
|
|
||||||
<string name="screen_login_title">"Vítejte zpět!"</string>
|
<string name="screen_login_title">"Vítejte zpět!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
|
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
|
||||||
<string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string>
|
<string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string>
|
||||||
|
|
@ -33,6 +32,12 @@
|
||||||
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
|
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
|
||||||
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
|
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
|
||||||
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
|
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
|
||||||
|
<string name="screen_waitlist_message">"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
|
||||||
|
|
||||||
|
Díky za trpělivost!"</string>
|
||||||
|
<string name="screen_waitlist_message_success">"Vítá vás %1$s"</string>
|
||||||
|
<string name="screen_waitlist_title">"Jste v pořadníku!"</string>
|
||||||
|
<string name="screen_waitlist_title_success">"Jdete do toho!"</string>
|
||||||
<string name="screen_change_server_submit">"Pokračovat"</string>
|
<string name="screen_change_server_submit">"Pokračovat"</string>
|
||||||
<string name="screen_change_server_title">"Vyberte svůj server"</string>
|
<string name="screen_change_server_title">"Vyberte svůj server"</string>
|
||||||
<string name="screen_login_password_hint">"Heslo"</string>
|
<string name="screen_login_password_hint">"Heslo"</string>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"</string>
|
<string name="screen_login_error_invalid_user_id">"Dies ist kein gültiger Benutzeridentifikator. Erwartetes Format: \'@user:homeserver.org\'"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."</string>
|
<string name="screen_login_error_unsupported_authentication">"Der ausgewählte Homeserver unterstützt kein Passwort- oder OIDC-Login. Bitte kontaktiere deinen Admin oder wähle einen anderen Homeserver."</string>
|
||||||
<string name="screen_login_form_header">"Gib deine Daten ein"</string>
|
<string name="screen_login_form_header">"Gib deine Daten ein"</string>
|
||||||
<string name="screen_login_server_header">"Wo deine Gespräche leben"</string>
|
|
||||||
<string name="screen_login_title">"Willkommen zurück!"</string>
|
<string name="screen_login_title">"Willkommen zurück!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
|
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
|
||||||
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
|
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
|
||||||
|
|
@ -33,6 +32,12 @@
|
||||||
<string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
|
<string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
|
||||||
<string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string>
|
<string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string>
|
||||||
<string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string>
|
<string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string>
|
||||||
|
<string name="screen_waitlist_message">"Im Moment besteht eine hohe Nachfrage nach %1$s auf %2$s. Besuche die App in ein paar Tagen wieder und versuche es erneut.
|
||||||
|
|
||||||
|
Vielen Dank für deine Geduld!"</string>
|
||||||
|
<string name="screen_waitlist_message_success">"Willkommen bei %1$s!"</string>
|
||||||
|
<string name="screen_waitlist_title">"Du hast es fast geschafft!"</string>
|
||||||
|
<string name="screen_waitlist_title_success">"Du bist dabei."</string>
|
||||||
<string name="screen_change_server_submit">"Weiter"</string>
|
<string name="screen_change_server_submit">"Weiter"</string>
|
||||||
<string name="screen_change_server_title">"Wählen deinen Server"</string>
|
<string name="screen_change_server_title">"Wählen deinen Server"</string>
|
||||||
<string name="screen_login_password_hint">"Passwort"</string>
|
<string name="screen_login_password_hint">"Passwort"</string>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
|
<string name="screen_login_error_invalid_user_id">"Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
|
<string name="screen_login_error_unsupported_authentication">"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."</string>
|
||||||
<string name="screen_login_form_header">"Introduce tus datos"</string>
|
<string name="screen_login_form_header">"Introduce tus datos"</string>
|
||||||
<string name="screen_login_server_header">"Donde viven tus conversaciones"</string>
|
|
||||||
<string name="screen_login_title">"¡Hola de nuevo!"</string>
|
<string name="screen_login_title">"¡Hola de nuevo!"</string>
|
||||||
<string name="screen_change_server_submit">"Continuar"</string>
|
<string name="screen_change_server_submit">"Continuar"</string>
|
||||||
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
|
<string name="screen_change_server_title">"Selecciona tu servidor"</string>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string>
|
<string name="screen_login_error_invalid_user_id">"Il ne s\'agit pas d\'un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
|
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
|
||||||
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
|
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
|
||||||
<string name="screen_login_server_header">"Où se déroulent vos conversations"</string>
|
|
||||||
<string name="screen_login_title">"Heureux de vous revoir!"</string>
|
<string name="screen_login_title">"Heureux de vous revoir!"</string>
|
||||||
<string name="screen_change_server_submit">"Continuer"</string>
|
<string name="screen_change_server_submit">"Continuer"</string>
|
||||||
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>
|
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string>
|
<string name="screen_login_error_invalid_user_id">"Questo non è un identificatore utente valido. Formato previsto: \'@user:homeserver.org\'"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string>
|
<string name="screen_login_error_unsupported_authentication">"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."</string>
|
||||||
<string name="screen_login_form_header">"Inserisci i tuoi dati"</string>
|
<string name="screen_login_form_header">"Inserisci i tuoi dati"</string>
|
||||||
<string name="screen_login_server_header">"Dove vivono le tue conversazioni"</string>
|
|
||||||
<string name="screen_login_title">"Bentornato!"</string>
|
<string name="screen_login_title">"Bentornato!"</string>
|
||||||
<string name="screen_change_server_submit">"Continua"</string>
|
<string name="screen_change_server_submit">"Continua"</string>
|
||||||
<string name="screen_change_server_title">"Seleziona il tuo server"</string>
|
<string name="screen_change_server_title">"Seleziona il tuo server"</string>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
|
<string name="screen_login_error_invalid_user_id">"Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string>
|
<string name="screen_login_error_unsupported_authentication">"Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."</string>
|
||||||
<string name="screen_login_form_header">"Introduceți detaliile"</string>
|
<string name="screen_login_form_header">"Introduceți detaliile"</string>
|
||||||
<string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string>
|
|
||||||
<string name="screen_login_title">"Bine ați revenit!"</string>
|
<string name="screen_login_title">"Bine ați revenit!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
|
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
|
||||||
<string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string>
|
<string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string>
|
||||||
|
|
@ -33,6 +32,12 @@
|
||||||
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
|
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
|
||||||
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
|
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
|
||||||
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
|
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
|
||||||
|
<string name="screen_waitlist_message">"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
|
||||||
|
|
||||||
|
Vă mulțumim pentru răbdare!"</string>
|
||||||
|
<string name="screen_waitlist_message_success">"Bun venit la %1$s"</string>
|
||||||
|
<string name="screen_waitlist_title">"Sunteți pe lista de așteptare"</string>
|
||||||
|
<string name="screen_waitlist_title_success">"Sunteți conectat!"</string>
|
||||||
<string name="screen_change_server_submit">"Continuați"</string>
|
<string name="screen_change_server_submit">"Continuați"</string>
|
||||||
<string name="screen_change_server_title">"Selectați serverul"</string>
|
<string name="screen_change_server_title">"Selectați serverul"</string>
|
||||||
<string name="screen_login_password_hint">"Parola"</string>
|
<string name="screen_login_password_hint">"Parola"</string>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"</string>
|
<string name="screen_login_error_invalid_user_id">"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server."</string>
|
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server."</string>
|
||||||
<string name="screen_login_form_header">"Zadajte svoje údaje"</string>
|
<string name="screen_login_form_header">"Zadajte svoje údaje"</string>
|
||||||
<string name="screen_login_server_header">"Kde žijú vaše rozhovory"</string>
|
|
||||||
<string name="screen_login_title">"Vitajte späť!"</string>
|
<string name="screen_login_title">"Vitajte späť!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string>
|
<string name="screen_login_title_with_homeserver">"Prihlásiť sa do %1$s"</string>
|
||||||
<string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string>
|
<string name="screen_server_confirmation_change_server">"Zmeniť poskytovateľa účtu"</string>
|
||||||
|
|
@ -33,6 +32,12 @@
|
||||||
<string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
<string name="screen_server_confirmation_message_register">"Tu budú žiť vaše konverzácie - podobne ako používate poskytovateľa e-mailových služieb na uchovávanie e-mailov."</string>
|
||||||
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
|
<string name="screen_server_confirmation_title_login">"Chystáte sa prihlásiť do %1$s"</string>
|
||||||
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
|
<string name="screen_server_confirmation_title_register">"Chystáte sa vytvoriť účet na %1$s"</string>
|
||||||
|
<string name="screen_waitlist_message">"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
|
||||||
|
|
||||||
|
Ďakujeme za trpezlivosť!"</string>
|
||||||
|
<string name="screen_waitlist_message_success">"Vitajte v %1$s"</string>
|
||||||
|
<string name="screen_waitlist_title">"Ste na čakanej listine!"</string>
|
||||||
|
<string name="screen_waitlist_title_success">"Ste dnu!"</string>
|
||||||
<string name="screen_change_server_submit">"Pokračovať"</string>
|
<string name="screen_change_server_submit">"Pokračovať"</string>
|
||||||
<string name="screen_change_server_title">"Vyberte svoj server"</string>
|
<string name="screen_change_server_title">"Vyberte svoj server"</string>
|
||||||
<string name="screen_login_password_hint">"Heslo"</string>
|
<string name="screen_login_password_hint">"Heslo"</string>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"</string>
|
<string name="screen_login_error_invalid_user_id">"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"</string>
|
||||||
<string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string>
|
<string name="screen_login_error_unsupported_authentication">"The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."</string>
|
||||||
<string name="screen_login_form_header">"Enter your details"</string>
|
<string name="screen_login_form_header">"Enter your details"</string>
|
||||||
<string name="screen_login_server_header">"Where your conversations live"</string>
|
|
||||||
<string name="screen_login_title">"Welcome back!"</string>
|
<string name="screen_login_title">"Welcome back!"</string>
|
||||||
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
|
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
|
||||||
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
|
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
|
||||||
|
|
@ -33,9 +32,16 @@
|
||||||
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
|
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
|
||||||
<string name="screen_server_confirmation_title_login">"You’re about to sign in to %1$s"</string>
|
<string name="screen_server_confirmation_title_login">"You’re about to sign in to %1$s"</string>
|
||||||
<string name="screen_server_confirmation_title_register">"You’re about to create an account on %1$s"</string>
|
<string name="screen_server_confirmation_title_register">"You’re about to create an account on %1$s"</string>
|
||||||
|
<string name="screen_waitlist_message">"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
|
||||||
|
|
||||||
|
Thanks for your patience!"</string>
|
||||||
|
<string name="screen_waitlist_message_success">"Welcome to %1$s!"</string>
|
||||||
|
<string name="screen_waitlist_title">"You’re almost there."</string>
|
||||||
|
<string name="screen_waitlist_title_success">"You\'re in."</string>
|
||||||
<string name="screen_change_server_submit">"Continue"</string>
|
<string name="screen_change_server_submit">"Continue"</string>
|
||||||
<string name="screen_change_server_title">"Select your server"</string>
|
<string name="screen_change_server_title">"Select your server"</string>
|
||||||
<string name="screen_login_password_hint">"Password"</string>
|
<string name="screen_login_password_hint">"Password"</string>
|
||||||
<string name="screen_login_submit">"Continue"</string>
|
<string name="screen_login_submit">"Continue"</string>
|
||||||
|
<string name="screen_login_subtitle">"Matrix is an open network for secure, decentralised communication."</string>
|
||||||
<string name="screen_login_username_hint">"Username"</string>
|
<string name="screen_login_username_hint">"Username"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||||
import io.element.android.features.login.impl.util.defaultAccountProvider
|
import io.element.android.features.login.impl.util.defaultAccountProvider
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -38,9 +39,11 @@ class LoginPasswordPresenterTest {
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val accountProviderDataSource = AccountProviderDataSource()
|
val accountProviderDataSource = AccountProviderDataSource()
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
val presenter = LoginPasswordPresenter(
|
val presenter = LoginPasswordPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
accountProviderDataSource,
|
accountProviderDataSource,
|
||||||
|
loginUserStory,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -57,9 +60,11 @@ class LoginPasswordPresenterTest {
|
||||||
fun `present - enter login and password`() = runTest {
|
fun `present - enter login and password`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val accountProviderDataSource = AccountProviderDataSource()
|
val accountProviderDataSource = AccountProviderDataSource()
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
val presenter = LoginPasswordPresenter(
|
val presenter = LoginPasswordPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
accountProviderDataSource,
|
accountProviderDataSource,
|
||||||
|
loginUserStory,
|
||||||
)
|
)
|
||||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -81,14 +86,17 @@ class LoginPasswordPresenterTest {
|
||||||
fun `present - submit`() = runTest {
|
fun `present - submit`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val accountProviderDataSource = AccountProviderDataSource()
|
val accountProviderDataSource = AccountProviderDataSource()
|
||||||
|
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
|
||||||
val presenter = LoginPasswordPresenter(
|
val presenter = LoginPasswordPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
accountProviderDataSource,
|
accountProviderDataSource,
|
||||||
|
loginUserStory,
|
||||||
)
|
)
|
||||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
|
initialState.eventSink.invoke(LoginPasswordEvents.SetLogin(A_USER_NAME))
|
||||||
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
initialState.eventSink.invoke(LoginPasswordEvents.SetPassword(A_PASSWORD))
|
||||||
|
|
@ -99,6 +107,7 @@ class LoginPasswordPresenterTest {
|
||||||
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
|
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
|
||||||
val loggedInState = awaitItem()
|
val loggedInState = awaitItem()
|
||||||
assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID))
|
assertThat(loggedInState.loginAction).isEqualTo(Async.Success(A_SESSION_ID))
|
||||||
|
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,9 +115,11 @@ class LoginPasswordPresenterTest {
|
||||||
fun `present - submit with error`() = runTest {
|
fun `present - submit with error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val accountProviderDataSource = AccountProviderDataSource()
|
val accountProviderDataSource = AccountProviderDataSource()
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
val presenter = LoginPasswordPresenter(
|
val presenter = LoginPasswordPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
accountProviderDataSource,
|
accountProviderDataSource,
|
||||||
|
loginUserStory,
|
||||||
)
|
)
|
||||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
@ -132,9 +143,11 @@ class LoginPasswordPresenterTest {
|
||||||
fun `present - clear error`() = runTest {
|
fun `present - clear error`() = runTest {
|
||||||
val authenticationService = FakeAuthenticationService()
|
val authenticationService = FakeAuthenticationService()
|
||||||
val accountProviderDataSource = AccountProviderDataSource()
|
val accountProviderDataSource = AccountProviderDataSource()
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
val presenter = LoginPasswordPresenter(
|
val presenter = LoginPasswordPresenter(
|
||||||
authenticationService,
|
authenticationService,
|
||||||
accountProviderDataSource,
|
accountProviderDataSource,
|
||||||
|
loginUserStory,
|
||||||
)
|
)
|
||||||
authenticationService.givenHomeserver(A_HOMESERVER)
|
authenticationService.givenHomeserver(A_HOMESERVER)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.login.impl.screens.waitlistscreen
|
||||||
|
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.moleculeFlow
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||||
|
import io.element.android.features.login.impl.screens.loginpassword.LoginFormState
|
||||||
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.A_HOMESERVER
|
||||||
|
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||||
|
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
|
||||||
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class WaitListPresenterTest {
|
||||||
|
@Test
|
||||||
|
fun `present - initial state`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService().apply {
|
||||||
|
givenHomeserver(A_HOMESERVER)
|
||||||
|
}
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
|
val presenter = WaitListPresenter(
|
||||||
|
LoginFormState.Default,
|
||||||
|
aBuildMeta(applicationName = "Application Name"),
|
||||||
|
authenticationService,
|
||||||
|
loginUserStory,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.appName).isEqualTo("Application Name")
|
||||||
|
assertThat(initialState.serverName).isEqualTo(A_HOMESERVER_URL)
|
||||||
|
assertThat(initialState.loginAction).isEqualTo(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - attempt login with error`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService().apply {
|
||||||
|
givenLoginError(A_THROWABLE)
|
||||||
|
}
|
||||||
|
val loginUserStory = DefaultLoginUserStory()
|
||||||
|
val presenter = WaitListPresenter(
|
||||||
|
LoginFormState.Default,
|
||||||
|
aBuildMeta(),
|
||||||
|
authenticationService,
|
||||||
|
loginUserStory,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
// First usage of AttemptLogin, nothing should happen
|
||||||
|
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
expectNoEvents()
|
||||||
|
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
val submitState = awaitItem()
|
||||||
|
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
|
||||||
|
val errorState = awaitItem()
|
||||||
|
assertThat(errorState.loginAction).isEqualTo(Async.Failure<SessionId>(A_THROWABLE))
|
||||||
|
// Assert the error can be cleared
|
||||||
|
errorState.eventSink(WaitListEvents.ClearError)
|
||||||
|
val clearedState = awaitItem()
|
||||||
|
assertThat(clearedState.loginAction).isEqualTo(Async.Uninitialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - attempt login with success`() = runTest {
|
||||||
|
val authenticationService = FakeAuthenticationService()
|
||||||
|
val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) }
|
||||||
|
val presenter = WaitListPresenter(
|
||||||
|
LoginFormState.Default,
|
||||||
|
aBuildMeta(),
|
||||||
|
authenticationService,
|
||||||
|
loginUserStory,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
|
val initialState = awaitItem()
|
||||||
|
// First usage of AttemptLogin, nothing should happen
|
||||||
|
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
expectNoEvents()
|
||||||
|
initialState.eventSink.invoke(WaitListEvents.AttemptLogin)
|
||||||
|
val submitState = awaitItem()
|
||||||
|
assertThat(submitState.loginAction).isInstanceOf(Async.Loading::class.java)
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.loginAction).isEqualTo(Async.Success(A_USER_ID))
|
||||||
|
assertThat(loginUserStory.loginFlowIsDone.value).isFalse()
|
||||||
|
successState.eventSink.invoke(WaitListEvents.Continue)
|
||||||
|
assertThat(loginUserStory.loginFlowIsDone.value).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,8 @@ dependencies {
|
||||||
implementation(libs.accompanist.flowlayout)
|
implementation(libs.accompanist.flowlayout)
|
||||||
implementation(libs.androidx.recyclerview)
|
implementation(libs.androidx.recyclerview)
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.androidx.constraintlayout.compose)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.accompanist.systemui)
|
implementation(libs.accompanist.systemui)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.location.api.SendLocationEntryPoint
|
import io.element.android.features.location.api.SendLocationEntryPoint
|
||||||
|
import io.element.android.features.location.api.ShowLocationEntryPoint
|
||||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode
|
||||||
|
|
@ -41,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNo
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
|
|
@ -59,6 +62,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
private val sendLocationEntryPoint: SendLocationEntryPoint,
|
||||||
|
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||||
) : BackstackNode<MessagesFlowNode.NavTarget>(
|
) : BackstackNode<MessagesFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Messages,
|
initialElement = NavTarget.Messages,
|
||||||
|
|
@ -83,7 +87,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
data class AttachmentPreview(val attachment: Attachment) : NavTarget
|
data class AttachmentPreview(val attachment: Attachment) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class EventDebugInfo(val eventId: EventId, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
data class LocationViewer(val location: Location, val description: String?) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ForwardEvent(val eventId: EventId) : NavTarget
|
data class ForwardEvent(val eventId: EventId) : NavTarget
|
||||||
|
|
@ -117,7 +124,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
callback?.onUserDataClicked(userId)
|
callback?.onUserDataClicked(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +154,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
||||||
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
createNode<AttachmentsPreviewNode>(buildContext, listOf(inputs))
|
||||||
}
|
}
|
||||||
|
is NavTarget.LocationViewer -> {
|
||||||
|
val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description)
|
||||||
|
showLocationEntryPoint.createNode(this, buildContext, inputs)
|
||||||
|
}
|
||||||
is NavTarget.EventDebugInfo -> {
|
is NavTarget.EventDebugInfo -> {
|
||||||
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
val inputs = EventDebugInfoNode.Inputs(navTarget.eventId, navTarget.debugInfo)
|
||||||
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
createNode<EventDebugInfoNode>(buildContext, listOf(inputs))
|
||||||
|
|
@ -213,6 +224,13 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
backstack.push(navTarget)
|
backstack.push(navTarget)
|
||||||
}
|
}
|
||||||
|
is TimelineItemLocationContent -> {
|
||||||
|
val navTarget = NavTarget.LocationViewer(
|
||||||
|
location = event.content.location,
|
||||||
|
description = event.content.description,
|
||||||
|
)
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
|
|
||||||
interface MessagesNavigator {
|
interface MessagesNavigator {
|
||||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||||
fun onForwardEventClicked(eventId: EventId)
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
fun onReportContentClicked(eventId: EventId, senderId: UserId)
|
fun onReportContentClicked(eventId: EventId, senderId: UserId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
fun onEventClicked(event: TimelineItem.Event)
|
fun onEventClicked(event: TimelineItem.Event)
|
||||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||||
fun onUserDataClicked(userId: UserId)
|
fun onUserDataClicked(userId: UserId)
|
||||||
fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo)
|
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||||
fun onForwardEventClicked(eventId: EventId)
|
fun onForwardEventClicked(eventId: EventId)
|
||||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||||
fun onSendLocationClicked()
|
fun onSendLocationClicked()
|
||||||
|
|
@ -83,7 +83,7 @@ class MessagesNode @AssistedInject constructor(
|
||||||
private fun onUserDataClicked(userId: UserId) {
|
private fun onUserDataClicked(userId: UserId) {
|
||||||
callback?.onUserDataClicked(userId)
|
callback?.onUserDataClicked(userId)
|
||||||
}
|
}
|
||||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,15 +226,19 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||||
if (event.eventId == null) return
|
if (event.failedToSend) {
|
||||||
room.redactEvent(event.eventId)
|
// If the message hasn't been sent yet, just cancel it
|
||||||
|
event.transactionId?.let { room.cancelSend(it) }
|
||||||
|
} else if (event.eventId != null) {
|
||||||
|
room.redactEvent(event.eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
private fun handleActionEdit(targetEvent: TimelineItem.Event, composerState: MessageComposerState) {
|
||||||
if (targetEvent.eventId == null) return
|
|
||||||
val composerMode = MessageComposerMode.Edit(
|
val composerMode = MessageComposerMode.Edit(
|
||||||
targetEvent.eventId,
|
targetEvent.eventId,
|
||||||
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty()
|
(targetEvent.content as? TimelineItemTextBasedContent)?.body.orEmpty(),
|
||||||
|
targetEvent.transactionId,
|
||||||
)
|
)
|
||||||
composerState.eventSink(
|
composerState.eventSink(
|
||||||
MessageComposerEvents.SetMode(composerMode)
|
MessageComposerEvents.SetMode(composerMode)
|
||||||
|
|
@ -287,7 +291,6 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
private fun handleShowDebugInfoAction(event: TimelineItem.Event) {
|
||||||
if (event.eventId == null) return
|
|
||||||
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
|
navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||||
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
import io.element.android.libraries.designsystem.utils.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -126,6 +126,9 @@ fun MessagesView(
|
||||||
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
|
state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onMoreReactionsClicked(event: TimelineItem.Event): Unit =
|
||||||
|
state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
|
|
@ -150,12 +153,16 @@ fun MessagesView(
|
||||||
onMessageLongClicked = ::onMessageLongClicked,
|
onMessageLongClicked = ::onMessageLongClicked,
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
onTimestampClicked = { event ->
|
onTimestampClicked = { event ->
|
||||||
if (event.sendState is EventSendState.SendingFailed) {
|
if (event.localSendState is LocalEventSendState.SendingFailed) {
|
||||||
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
|
state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReactionClicked = ::onEmojiReactionClicked,
|
onReactionClicked = ::onEmojiReactionClicked,
|
||||||
|
onMoreReactionsClicked = ::onMoreReactionsClicked,
|
||||||
onSendLocationClicked = onSendLocationClicked,
|
onSendLocationClicked = onSendLocationClicked,
|
||||||
|
onSwipeToReply = { targetEvent ->
|
||||||
|
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
|
|
@ -237,10 +244,12 @@ fun MessagesViewContent(
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
onUserDataClicked: (UserId) -> Unit,
|
onUserDataClicked: (UserId) -> Unit,
|
||||||
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onSendLocationClicked: () -> Unit,
|
onSendLocationClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -258,6 +267,8 @@ fun MessagesViewContent(
|
||||||
onUserDataClicked = onUserDataClicked,
|
onUserDataClicked = onUserDataClicked,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onReactionClicked = onReactionClicked,
|
onReactionClicked = onReactionClicked,
|
||||||
|
onMoreReactionsClicked = onMoreReactionsClicked,
|
||||||
|
onSwipeToReply = onSwipeToReply,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.userHasPermissionToSendMessage) {
|
if (state.userHasPermissionToSendMessage) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
@ -28,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -45,6 +48,10 @@ class ActionListPresenter @Inject constructor(
|
||||||
mutableStateOf(ActionListState.Target.None)
|
mutableStateOf(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val displayEmojiReactions by remember {
|
||||||
|
derivedStateOf { (target.value as? ActionListState.Target.Success)?.event?.isRemote == true }
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: ActionListEvents) {
|
fun handleEvents(event: ActionListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
ActionListEvents.Clear -> target.value = ActionListState.Target.None
|
||||||
|
|
@ -54,6 +61,7 @@ class ActionListPresenter @Inject constructor(
|
||||||
|
|
||||||
return ActionListState(
|
return ActionListState(
|
||||||
target = target.value,
|
target = target.value,
|
||||||
|
displayEmojiReactions = displayEmojiReactions,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -62,21 +70,28 @@ class ActionListPresenter @Inject constructor(
|
||||||
target.value = ActionListState.Target.Loading(timelineItem)
|
target.value = ActionListState.Target.Loading(timelineItem)
|
||||||
val actions =
|
val actions =
|
||||||
when (timelineItem.content) {
|
when (timelineItem.content) {
|
||||||
is TimelineItemRedactedContent,
|
is TimelineItemRedactedContent -> {
|
||||||
|
if (buildMeta.isDebuggable) {
|
||||||
|
listOf(TimelineItemAction.Developer)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
is TimelineItemStateContent -> {
|
is TimelineItemStateContent -> {
|
||||||
buildList {
|
buildList {
|
||||||
if (timelineItem.content.canBeCopied()) {
|
add(TimelineItemAction.Copy)
|
||||||
add(TimelineItemAction.Copy)
|
|
||||||
}
|
|
||||||
if (buildMeta.isDebuggable) {
|
if (buildMeta.isDebuggable) {
|
||||||
add(TimelineItemAction.Developer)
|
add(TimelineItemAction.Developer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> buildList<TimelineItemAction> {
|
else -> buildList<TimelineItemAction> {
|
||||||
add(TimelineItemAction.Reply)
|
if (timelineItem.isRemote) {
|
||||||
add(TimelineItemAction.Forward)
|
// Can only reply or forward messages already uploaded to the server
|
||||||
if (timelineItem.isMine) {
|
add(TimelineItemAction.Reply)
|
||||||
|
add(TimelineItemAction.Forward)
|
||||||
|
}
|
||||||
|
if (timelineItem.isMine && timelineItem.isTextMessage) {
|
||||||
add(TimelineItemAction.Edit)
|
add(TimelineItemAction.Edit)
|
||||||
}
|
}
|
||||||
if (timelineItem.content.canBeCopied()) {
|
if (timelineItem.content.canBeCopied()) {
|
||||||
|
|
@ -93,6 +108,10 @@ class ActionListPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
if (actions.isNotEmpty()) {
|
||||||
|
target.value = ActionListState.Target.Success(timelineItem, actions.toImmutableList())
|
||||||
|
} else {
|
||||||
|
target.value = ActionListState.Target.None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
@Immutable
|
@Immutable
|
||||||
data class ActionListState(
|
data class ActionListState(
|
||||||
val target: Target,
|
val target: Target,
|
||||||
|
val displayEmojiReactions: Boolean,
|
||||||
val eventSink: (ActionListEvents) -> Unit,
|
val eventSink: (ActionListEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
sealed interface Target {
|
sealed interface Target {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.actionlist
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
|
||||||
|
|
@ -28,44 +29,67 @@ import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||||
override val values: Sequence<ActionListState>
|
override val values: Sequence<ActionListState>
|
||||||
get() = sequenceOf(
|
get() {
|
||||||
anActionListState(),
|
val reactionsState = aTimelineItemReactions(1, isHighlighted = true)
|
||||||
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
return sequenceOf(
|
||||||
anActionListState().copy(
|
anActionListState(),
|
||||||
target = ActionListState.Target.Success(
|
anActionListState().copy(target = ActionListState.Target.Loading(aTimelineItemEvent())),
|
||||||
event = aTimelineItemEvent(),
|
anActionListState().copy(
|
||||||
actions = aTimelineItemActionList(),
|
target = ActionListState.Target.Success(
|
||||||
)
|
event = aTimelineItemEvent().copy(
|
||||||
),
|
reactionsState = reactionsState
|
||||||
anActionListState().copy(
|
),
|
||||||
target = ActionListState.Target.Success(
|
actions = aTimelineItemActionList(),
|
||||||
event = aTimelineItemEvent(content = aTimelineItemImageContent()),
|
)
|
||||||
actions = aTimelineItemActionList(),
|
),
|
||||||
)
|
anActionListState().copy(
|
||||||
),
|
target = ActionListState.Target.Success(
|
||||||
anActionListState().copy(
|
event = aTimelineItemEvent(content = aTimelineItemImageContent()).copy(
|
||||||
target = ActionListState.Target.Success(
|
reactionsState = reactionsState
|
||||||
event = aTimelineItemEvent(content = aTimelineItemVideoContent()),
|
),
|
||||||
actions = aTimelineItemActionList(),
|
actions = aTimelineItemActionList(),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
anActionListState().copy(
|
anActionListState().copy(
|
||||||
target = ActionListState.Target.Success(
|
target = ActionListState.Target.Success(
|
||||||
event = aTimelineItemEvent(content = aTimelineItemFileContent()),
|
event = aTimelineItemEvent(content = aTimelineItemVideoContent()).copy(
|
||||||
actions = aTimelineItemActionList(),
|
reactionsState = reactionsState
|
||||||
)
|
),
|
||||||
),
|
actions = aTimelineItemActionList(),
|
||||||
anActionListState().copy(
|
)
|
||||||
target = ActionListState.Target.Success(
|
),
|
||||||
event = aTimelineItemEvent(content = aTimelineItemLocationContent()),
|
anActionListState().copy(
|
||||||
actions = aTimelineItemActionList(),
|
target = ActionListState.Target.Success(
|
||||||
)
|
event = aTimelineItemEvent(content = aTimelineItemFileContent()).copy(
|
||||||
),
|
reactionsState = reactionsState
|
||||||
)
|
),
|
||||||
|
actions = aTimelineItemActionList(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
anActionListState().copy(
|
||||||
|
target = ActionListState.Target.Success(
|
||||||
|
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
|
||||||
|
reactionsState = reactionsState
|
||||||
|
),
|
||||||
|
actions = aTimelineItemActionList(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
anActionListState().copy(
|
||||||
|
target = ActionListState.Target.Success(
|
||||||
|
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
|
||||||
|
reactionsState = reactionsState
|
||||||
|
),
|
||||||
|
actions = aTimelineItemActionList(),
|
||||||
|
),
|
||||||
|
displayEmojiReactions = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anActionListState() = ActionListState(
|
fun anActionListState() = ActionListState(
|
||||||
target = ActionListState.Target.None,
|
target = ActionListState.Target.None,
|
||||||
|
displayEmojiReactions = true,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.actionlist
|
package io.element.android.features.messages.impl.actionlist
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
|
@ -46,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -78,7 +80,9 @@ import io.element.android.libraries.designsystem.theme.components.hide
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -175,13 +179,16 @@ private fun SheetContent(
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
if (state.displayEmojiReactions) {
|
||||||
EmojiReactionsRow(
|
item {
|
||||||
onEmojiReactionClicked = onEmojiReactionClicked,
|
EmojiReactionsRow(
|
||||||
onCustomReactionClicked = onCustomReactionClicked,
|
highlightedEmojis = target.event.reactionsState.highlightedKeys,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onEmojiReactionClicked = onEmojiReactionClicked,
|
||||||
)
|
onCustomReactionClicked = onCustomReactionClicked,
|
||||||
Divider()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
items(
|
items(
|
||||||
items = actions,
|
items = actions,
|
||||||
|
|
@ -320,6 +327,7 @@ private val emojiRippleRadius = 24.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun EmojiReactionsRow(
|
internal fun EmojiReactionsRow(
|
||||||
|
highlightedEmojis: ImmutableList<String>,
|
||||||
onEmojiReactionClicked: (String) -> Unit,
|
onEmojiReactionClicked: (String) -> Unit,
|
||||||
onCustomReactionClicked: () -> Unit,
|
onCustomReactionClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|
@ -333,7 +341,8 @@ internal fun EmojiReactionsRow(
|
||||||
"👍", "👎", "🔥", "❤️", "👏"
|
"👍", "👎", "🔥", "❤️", "👏"
|
||||||
)
|
)
|
||||||
for (emoji in defaultEmojis) {
|
for (emoji in defaultEmojis) {
|
||||||
EmojiButton(emoji, onEmojiReactionClicked)
|
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||||
|
EmojiButton(emoji, isHighlighted, onEmojiReactionClicked)
|
||||||
}
|
}
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
|
|
@ -356,19 +365,34 @@ internal fun EmojiReactionsRow(
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmojiButton(
|
private fun EmojiButton(
|
||||||
emoji: String,
|
emoji: String,
|
||||||
|
isHighlighted: Boolean,
|
||||||
onClicked: (String) -> Unit,
|
onClicked: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
val backgroundColor = if (isHighlighted) {
|
||||||
emoji,
|
ElementTheme.colors.bgActionPrimaryRest
|
||||||
fontSize = 28.dp.toSp(),
|
} else {
|
||||||
modifier = modifier.clickable(
|
Color.Transparent
|
||||||
enabled = true,
|
}
|
||||||
onClick = { onClicked(emoji) },
|
Box(
|
||||||
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
|
modifier = modifier
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
.size(48.dp)
|
||||||
|
.background(backgroundColor, RoundedCornerShape(24.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
emoji,
|
||||||
|
fontSize = 28.dp.toSp(),
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
enabled = true,
|
||||||
|
onClick = { onClicked(emoji) },
|
||||||
|
indication = rememberRipple(bounded = false, radius = emojiRippleRadius),
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.messages.impl.R
|
import io.element.android.features.messages.impl.R
|
||||||
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
import io.element.android.libraries.androidutils.ui.hideKeyboard
|
||||||
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -129,3 +131,12 @@ internal fun AttachmentSourcePickerMenu(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DayNightPreviews
|
||||||
|
@Composable
|
||||||
|
internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
|
||||||
|
AttachmentSourcePickerMenu(
|
||||||
|
eventSink = {},
|
||||||
|
onSendLocationClicked = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,11 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
composerMode.setToNormal()
|
composerMode.setToNormal()
|
||||||
when (capturedMode) {
|
when (capturedMode) {
|
||||||
is MessageComposerMode.Normal -> room.sendMessage(text)
|
is MessageComposerMode.Normal -> room.sendMessage(text)
|
||||||
is MessageComposerMode.Edit -> room.editMessage(
|
is MessageComposerMode.Edit -> {
|
||||||
capturedMode.eventId,
|
val eventId = capturedMode.eventId
|
||||||
text
|
val transactionId = capturedMode.transactionId
|
||||||
)
|
room.editMessage(eventId, transactionId, text)
|
||||||
|
}
|
||||||
|
|
||||||
is MessageComposerMode.Quote -> TODO()
|
is MessageComposerMode.Quote -> TODO()
|
||||||
is MessageComposerMode.Reply -> room.replyMessage(
|
is MessageComposerMode.Reply -> room.replyMessage(
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
@ -44,7 +46,7 @@ private const val backPaginationPageSize = 50
|
||||||
|
|
||||||
class TimelinePresenter @Inject constructor(
|
class TimelinePresenter @Inject constructor(
|
||||||
private val timelineItemsFactory: TimelineItemsFactory,
|
private val timelineItemsFactory: TimelineItemsFactory,
|
||||||
room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
) : Presenter<TimelineState> {
|
) : Presenter<TimelineState> {
|
||||||
|
|
||||||
private val timeline = room.timeline
|
private val timeline = room.timeline
|
||||||
|
|
@ -62,6 +64,9 @@ class TimelinePresenter @Inject constructor(
|
||||||
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
val timelineItems by timelineItemsFactory.collectItemsAsState()
|
||||||
val paginationState by timeline.paginationState.collectAsState()
|
val paginationState by timeline.paginationState.collectAsState()
|
||||||
|
|
||||||
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
|
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
|
||||||
|
|
||||||
fun handleEvents(event: TimelineEvents) {
|
fun handleEvents(event: TimelineEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState)
|
TimelineEvents.LoadMore -> localCoroutineScope.loadMore(paginationState)
|
||||||
|
|
@ -92,6 +97,7 @@ class TimelinePresenter @Inject constructor(
|
||||||
|
|
||||||
return TimelineState(
|
return TimelineState(
|
||||||
highlightedEventId = highlightedEventId.value,
|
highlightedEventId = highlightedEventId.value,
|
||||||
|
canReply = userHasPermissionToSendMessage,
|
||||||
paginationState = paginationState,
|
paginationState = paginationState,
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
data class TimelineState(
|
data class TimelineState(
|
||||||
val timelineItems: ImmutableList<TimelineItem>,
|
val timelineItems: ImmutableList<TimelineItem>,
|
||||||
val highlightedEventId: EventId?,
|
val highlightedEventId: EventId?,
|
||||||
|
val canReply: Boolean,
|
||||||
val paginationState: MatrixTimeline.PaginationState,
|
val paginationState: MatrixTimeline.PaginationState,
|
||||||
val eventSink: (TimelineEvents) -> Unit
|
val eventSink: (TimelineEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -43,6 +43,7 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
|
||||||
timelineItems = timelineItems,
|
timelineItems = timelineItems,
|
||||||
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, canBackPaginate = true),
|
||||||
highlightedEventId = null,
|
highlightedEventId = null,
|
||||||
|
canReply = true,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
||||||
isMine = false,
|
isMine = false,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Middle,
|
groupPosition = TimelineItemGroupPosition.Middle,
|
||||||
sendState = EventSendState.SendingFailed("Message failed to send"),
|
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||||
),
|
),
|
||||||
aTimelineItemEvent(
|
aTimelineItemEvent(
|
||||||
isMine = false,
|
isMine = false,
|
||||||
|
|
@ -81,7 +82,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
||||||
isMine = true,
|
isMine = true,
|
||||||
content = content,
|
content = content,
|
||||||
groupPosition = TimelineItemGroupPosition.Middle,
|
groupPosition = TimelineItemGroupPosition.Middle,
|
||||||
sendState = EventSendState.SendingFailed("Message failed to send"),
|
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||||
),
|
),
|
||||||
aTimelineItemEvent(
|
aTimelineItemEvent(
|
||||||
isMine = true,
|
isMine = true,
|
||||||
|
|
@ -111,7 +112,7 @@ internal fun aTimelineItemEvent(
|
||||||
isMine: Boolean = false,
|
isMine: Boolean = false,
|
||||||
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
content: TimelineItemEventContent = aTimelineItemTextContent(),
|
||||||
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
sendState: EventSendState = EventSendState.Sent(eventId),
|
sendState: LocalEventSendState = LocalEventSendState.Sent(eventId),
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyTo? = null,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
timelineItemReactions: TimelineItemReactions = aTimelineItemReactions(),
|
||||||
|
|
@ -128,7 +129,7 @@ internal fun aTimelineItemEvent(
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
senderDisplayName = "Sender",
|
senderDisplayName = "Sender",
|
||||||
groupPosition = groupPosition,
|
groupPosition = groupPosition,
|
||||||
sendState = sendState,
|
localSendState = sendState,
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
debugInfo = debugInfo,
|
debugInfo = debugInfo,
|
||||||
)
|
)
|
||||||
|
|
@ -138,10 +139,12 @@ fun aTimelineItemReactions(
|
||||||
count: Int = 1,
|
count: Int = 1,
|
||||||
isHighlighted: Boolean = false,
|
isHighlighted: Boolean = false,
|
||||||
): TimelineItemReactions {
|
): TimelineItemReactions {
|
||||||
|
val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
|
||||||
return TimelineItemReactions(
|
return TimelineItemReactions(
|
||||||
reactions = buildList {
|
reactions = buildList {
|
||||||
repeat(count) {
|
repeat(count) { index ->
|
||||||
add(AggregatedReaction(key = "👍", count = 1 + it, isHighlighted = isHighlighted))
|
val key = emojis[index % emojis.size]
|
||||||
|
add(AggregatedReaction(key = key, count = 1 + index, isHighlighted = isHighlighted))
|
||||||
}
|
}
|
||||||
}.toPersistentList()
|
}.toPersistentList()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,9 @@ fun TimelineView(
|
||||||
onMessageClicked: (TimelineItem.Event) -> Unit,
|
onMessageClicked: (TimelineItem.Event) -> Unit,
|
||||||
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
onMessageLongClicked: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit,
|
||||||
|
onMoreReactionsClicked: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun onReachedLoadMore() {
|
fun onReachedLoadMore() {
|
||||||
|
|
@ -120,12 +122,15 @@ fun TimelineView(
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
highlightedItem = state.highlightedEventId?.value,
|
highlightedItem = state.highlightedEventId?.value,
|
||||||
|
canReply = state.canReply,
|
||||||
onClick = onMessageClicked,
|
onClick = onMessageClicked,
|
||||||
onLongClick = onMessageLongClicked,
|
onLongClick = onMessageLongClicked,
|
||||||
onUserDataClick = onUserDataClicked,
|
onUserDataClick = onUserDataClicked,
|
||||||
inReplyToClick = ::inReplyToClicked,
|
inReplyToClick = ::inReplyToClicked,
|
||||||
onReactionClick = onReactionClicked,
|
onReactionClick = onReactionClicked,
|
||||||
|
onMoreReactionsClick = onMoreReactionsClicked,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
|
onSwipeToReply = onSwipeToReply,
|
||||||
)
|
)
|
||||||
if (index == state.timelineItems.lastIndex) {
|
if (index == state.timelineItems.lastIndex) {
|
||||||
onReachedLoadMore()
|
onReachedLoadMore()
|
||||||
|
|
@ -145,12 +150,15 @@ fun TimelineView(
|
||||||
fun TimelineItemRow(
|
fun TimelineItemRow(
|
||||||
timelineItem: TimelineItem,
|
timelineItem: TimelineItem,
|
||||||
highlightedItem: String?,
|
highlightedItem: String?,
|
||||||
|
canReply: Boolean,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
onClick: (TimelineItem.Event) -> Unit,
|
onClick: (TimelineItem.Event) -> Unit,
|
||||||
onLongClick: (TimelineItem.Event) -> Unit,
|
onLongClick: (TimelineItem.Event) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
onReactionClick: (key: String, TimelineItem.Event) -> Unit,
|
||||||
|
onMoreReactionsClick: (TimelineItem.Event) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
when (timelineItem) {
|
when (timelineItem) {
|
||||||
|
|
@ -161,32 +169,27 @@ fun TimelineItemRow(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is TimelineItem.Event -> {
|
is TimelineItem.Event -> {
|
||||||
fun onClick() {
|
|
||||||
onClick(timelineItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onLongClick() {
|
|
||||||
onLongClick(timelineItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timelineItem.content is TimelineItemStateContent) {
|
if (timelineItem.content is TimelineItemStateContent) {
|
||||||
TimelineItemStateEventRow(
|
TimelineItemStateEventRow(
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||||
onClick = ::onClick,
|
onClick = { onClick(timelineItem) },
|
||||||
onLongClick = ::onLongClick,
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
isHighlighted = highlightedItem == timelineItem.identifier(),
|
isHighlighted = highlightedItem == timelineItem.identifier(),
|
||||||
onClick = ::onClick,
|
canReply = canReply,
|
||||||
onLongClick = ::onLongClick,
|
onClick = { onClick(timelineItem) },
|
||||||
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
|
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -215,12 +218,15 @@ fun TimelineItemRow(
|
||||||
TimelineItemRow(
|
TimelineItemRow(
|
||||||
timelineItem = subGroupEvent,
|
timelineItem = subGroupEvent,
|
||||||
highlightedItem = highlightedItem,
|
highlightedItem = highlightedItem,
|
||||||
|
canReply = false,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = inReplyToClick,
|
inReplyToClick = inReplyToClick,
|
||||||
onUserDataClick = onUserDataClick,
|
onUserDataClick = onUserDataClick,
|
||||||
onTimestampClicked = onTimestampClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onReactionClick = onReactionClick,
|
onReactionClick = onReactionClick,
|
||||||
|
onMoreReactionsClick = onMoreReactionsClick,
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,5 +328,7 @@ private fun ContentToPreview(content: TimelineItemEventContent) {
|
||||||
onUserDataClicked = {},
|
onUserDataClicked = {},
|
||||||
onMessageLongClicked = {},
|
onMessageLongClicked = {},
|
||||||
onReactionClicked = { _, _ -> },
|
onReactionClicked = { _, _ -> },
|
||||||
|
onMoreReactionsClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AddReaction
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MessagesMoreReactionsButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
val buttonColor = ElementTheme.colors.bgSubtleSecondary
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.background(Color.Transparent)
|
||||||
|
// Outer border, same colour as background
|
||||||
|
.border(
|
||||||
|
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(14.dp))
|
||||||
|
)
|
||||||
|
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||||
|
// Clip click indicator inside the outer border
|
||||||
|
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||||
|
color = buttonColor
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AddReaction,
|
||||||
|
contentDescription = "Add emoji",
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
// Same size as the line height of reaction emoji text
|
||||||
|
.size(with(LocalDensity.current) { 20.sp.toDp() })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun MessagesMoreReactionsButtonLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun MessagesMoreReactionsButtonDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
MessagesMoreReactionsButton(onClick = {})
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
|
@ -45,35 +46,47 @@ import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun MessagesReactionButton(reaction: AggregatedReaction, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
// First Surface is to render a border with the same background color as the background
|
val buttonColor = if (reaction.isHighlighted) {
|
||||||
|
ElementTheme.colors.bgSubtlePrimary
|
||||||
|
} else {
|
||||||
|
ElementTheme.colors.bgSubtleSecondary
|
||||||
|
}
|
||||||
|
val borderColor = if (reaction.isHighlighted) {
|
||||||
|
ElementTheme.colors.borderInteractivePrimary
|
||||||
|
} else {
|
||||||
|
buttonColor
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier.clickable(onClick = onClick::invoke),
|
modifier = modifier
|
||||||
// TODO Should use compound.bgSubtlePrimary
|
.background(Color.Transparent)
|
||||||
color = ElementTheme.legacyColors.gray300,
|
// Outer border, same colour as background
|
||||||
border = BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
.border(
|
||||||
shape = RoundedCornerShape(corner = CornerSize(14.dp)),
|
BorderStroke(2.dp, MaterialTheme.colorScheme.background),
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(14.dp))
|
||||||
|
)
|
||||||
|
.padding(vertical = 2.dp, horizontal = 2.dp)
|
||||||
|
// Clip click indicator inside the outer border
|
||||||
|
.clip(RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
// Inner border, to highlight when selected
|
||||||
|
.border(BorderStroke(1.dp, borderColor), RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
.background(buttonColor, RoundedCornerShape(corner = CornerSize(12.dp)))
|
||||||
|
.padding(vertical = 4.dp, horizontal = 10.dp),
|
||||||
|
color = buttonColor
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(2.dp)) {
|
Row(
|
||||||
val reactionModifier = if (reaction.isHighlighted) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Modifier
|
) {
|
||||||
// TODO Check the color, should use compound.borderInteractivePrimary
|
Text(
|
||||||
.border(BorderStroke(1.dp, Color(0xFF808994)), RoundedCornerShape(corner = CornerSize(12.dp)))
|
text = reaction.key, fontSize = 15.sp, lineHeight = 20.sp
|
||||||
} else {
|
)
|
||||||
Modifier
|
if (reaction.count > 1) {
|
||||||
}
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Row(
|
Text(
|
||||||
modifier = reactionModifier.padding(vertical = 4.dp, horizontal = 12.dp),
|
text = reaction.count.toString(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||||
) {
|
fontSize = 14.sp
|
||||||
Text(text = reaction.key, fontSize = 15.sp)
|
)
|
||||||
if (reaction.count > 1) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = reaction.count.toString(),
|
|
||||||
color = if (reaction.isHighlighted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
|
||||||
fontSize = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.libraries.designsystem.VectorIcons
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A swipe indicator that appears when swiping to reply to a message.
|
||||||
|
*
|
||||||
|
* @param swipeProgress the progress of the swipe, between 0 and X. When swipeProgress >= 1 the swipe will be detected.
|
||||||
|
* @param modifier the modifier to apply to this Composable root.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RowScope.ReplySwipeIndicator(
|
||||||
|
swipeProgress: () -> Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = modifier
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = 36.dp.toPx() * swipeProgress().coerceAtMost(1f)
|
||||||
|
alpha = swipeProgress()
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
resourceId = VectorIcons.Reply,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun ReplySwipeIndicatorLightPreview() =
|
||||||
|
ElementPreviewLight { ContentToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun ReplySwipeIndicatorDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentToPreview() {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
for (i in 0..8) {
|
||||||
|
Row { ReplySwipeIndicator(swipeProgress = { i / 8f }) }
|
||||||
|
}
|
||||||
|
Row { ReplySwipeIndicator(swipeProgress = { 1.5f }) }
|
||||||
|
Row { ReplySwipeIndicator(swipeProgress = { 2f }) }
|
||||||
|
Row { ReplySwipeIndicator(swipeProgress = { 3f }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|
@ -55,7 +55,7 @@ fun TimelineEventTimestampView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val formattedTime = event.sentTime
|
val formattedTime = event.sentTime
|
||||||
val hasMessageSendingFailed = event.sendState is EventSendState.SendingFailed
|
val hasMessageSendingFailed = event.localSendState is LocalEventSendState.SendingFailed
|
||||||
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
val isMessageEdited = (event.content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||||
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
|
val tint = if (hasMessageSendingFailed) MaterialTheme.colorScheme.error else null
|
||||||
val clickModifier = if (hasMessageSendingFailed) {
|
val clickModifier = if (hasMessageSendingFailed) {
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,19 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
|
|
||||||
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
|
class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<TimelineItem.Event> {
|
||||||
override val values: Sequence<TimelineItem.Event>
|
override val values: Sequence<TimelineItem.Event>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemEvent(),
|
aTimelineItemEvent(),
|
||||||
// Sending failed
|
// Sending failed
|
||||||
aTimelineItemEvent().copy(sendState = EventSendState.SendingFailed("AN_ERROR")),
|
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")),
|
||||||
// Edited
|
// Edited
|
||||||
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
|
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
|
||||||
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
|
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
|
||||||
aTimelineItemEvent().copy(
|
aTimelineItemEvent().copy(
|
||||||
sendState = EventSendState.SendingFailed("AN_ERROR"),
|
localSendState = LocalEventSendState.SendingFailed("AN_ERROR"),
|
||||||
content = aTimelineItemTextContent().copy(isEdited = true),
|
content = aTimelineItemTextContent().copy(isEdited = true),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components
|
package io.element.android.features.messages.impl.timeline.components
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
|
|
@ -33,7 +35,13 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DismissDirection
|
||||||
|
import androidx.compose.material3.DismissState
|
||||||
|
import androidx.compose.material3.DismissValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismiss
|
||||||
|
import androidx.compose.material3.rememberDismissState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -46,8 +54,13 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.constraintlayout.compose.ConstrainScope
|
||||||
|
import androidx.constraintlayout.compose.ConstraintLayout
|
||||||
|
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||||
|
|
@ -86,12 +99,15 @@ import org.jsoup.Jsoup
|
||||||
fun TimelineItemEventRow(
|
fun TimelineItemEventRow(
|
||||||
event: TimelineItem.Event,
|
event: TimelineItem.Event,
|
||||||
isHighlighted: Boolean,
|
isHighlighted: Boolean,
|
||||||
|
canReply: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onUserDataClick: (UserId) -> Unit,
|
onUserDataClick: (UserId) -> Unit,
|
||||||
inReplyToClick: (EventId) -> Unit,
|
inReplyToClick: (EventId) -> Unit,
|
||||||
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit,
|
||||||
|
onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit,
|
||||||
|
onSwipeToReply: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
@ -100,77 +116,55 @@ fun TimelineItemEventRow(
|
||||||
onUserDataClick(event.senderId)
|
onUserDataClick(event.senderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onReactionClicked(emoji: String) =
|
|
||||||
onReactionClick(emoji, event)
|
|
||||||
|
|
||||||
fun inReplyToClicked() {
|
fun inReplyToClicked() {
|
||||||
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
val inReplyToEventId = (event.inReplyTo as? InReplyTo.Ready)?.eventId ?: return
|
||||||
inReplyToClick(inReplyToEventId)
|
inReplyToClick(inReplyToEventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// To avoid using negative offset, we display in this Box a column with:
|
if (canReply) {
|
||||||
// - Spacer to give room to the Sender information if they must be displayed;
|
val dismissState = rememberDismissState(
|
||||||
// - The message bubble;
|
confirmValueChange = {
|
||||||
// - Spacer for the reactions if there are some.
|
if (it == DismissValue.DismissedToEnd) {
|
||||||
// Then the Sender information and the reactions are displayed on top of it.
|
onSwipeToReply()
|
||||||
// This fixes some clickable issue and some unexpected margin on top and bottom of each message row
|
}
|
||||||
Box(
|
// Do not dismiss the message, return false!
|
||||||
modifier = modifier
|
false
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentHeight(),
|
|
||||||
contentAlignment = if (event.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
if (event.showSenderInformation) {
|
|
||||||
Spacer(modifier = Modifier.height(event.senderAvatar.size.dp - 8.dp))
|
|
||||||
}
|
}
|
||||||
val bubbleState = BubbleState(
|
)
|
||||||
groupPosition = event.groupPosition,
|
SwipeToDismiss(
|
||||||
isMine = event.isMine,
|
state = dismissState,
|
||||||
isHighlighted = isHighlighted,
|
background = {
|
||||||
)
|
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
|
||||||
MessageEventBubble(
|
},
|
||||||
state = bubbleState,
|
directions = setOf(DismissDirection.StartToEnd),
|
||||||
interactionSource = interactionSource,
|
dismissContent = {
|
||||||
onClick = onClick,
|
TimelineItemEventRowContent(
|
||||||
onLongClick = onLongClick,
|
|
||||||
) {
|
|
||||||
MessageEventBubbleContent(
|
|
||||||
event = event,
|
event = event,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
onMessageClick = onClick,
|
onClick = onClick,
|
||||||
onMessageLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
inReplyToClick = ::inReplyToClicked,
|
onTimestampClicked = onTimestampClicked,
|
||||||
onTimestampClicked = {
|
inReplyToClicked = ::inReplyToClicked,
|
||||||
onTimestampClicked(event)
|
onUserDataClicked = ::onUserDataClicked,
|
||||||
}
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
)
|
||||||
Spacer(modifier = Modifier.height(28.dp))
|
} else {
|
||||||
}
|
TimelineItemEventRowContent(
|
||||||
}
|
event = event,
|
||||||
// Align to the top of the box
|
isHighlighted = isHighlighted,
|
||||||
if (event.showSenderInformation) {
|
interactionSource = interactionSource,
|
||||||
MessageSenderInformation(
|
onClick = onClick,
|
||||||
event.safeSenderName,
|
onLongClick = onLongClick,
|
||||||
event.senderAvatar,
|
onTimestampClicked = onTimestampClicked,
|
||||||
Modifier
|
inReplyToClicked = ::inReplyToClicked,
|
||||||
.padding(horizontal = 16.dp)
|
onUserDataClicked = ::onUserDataClicked,
|
||||||
.align(Alignment.TopStart)
|
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
|
||||||
.clickable(onClick = ::onUserDataClicked)
|
onMoreReactionsClicked = { onMoreReactionsClick(event) },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
// Align to the bottom of the box
|
|
||||||
if (event.reactionsState.reactions.isNotEmpty()) {
|
|
||||||
TimelineItemReactionsView(
|
|
||||||
reactionsState = event.reactionsState,
|
|
||||||
onReactionClicked = ::onReactionClicked,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(if (event.isMine) Alignment.BottomEnd else Alignment.BottomStart)
|
|
||||||
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
|
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
|
||||||
if (event.groupPosition.isNew()) {
|
if (event.groupPosition.isNew()) {
|
||||||
|
|
@ -180,13 +174,113 @@ fun TimelineItemEventRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimelineItemEventRowContent(
|
||||||
|
event: TimelineItem.Event,
|
||||||
|
isHighlighted: Boolean,
|
||||||
|
interactionSource: MutableInteractionSource,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
onTimestampClicked: (TimelineItem.Event) -> Unit,
|
||||||
|
inReplyToClicked: () -> Unit,
|
||||||
|
onUserDataClicked: () -> Unit,
|
||||||
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
|
onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
fun ConstrainScope.linkStartOrEnd(event: TimelineItem.Event) = if (event.isMine) {
|
||||||
|
end.linkTo(parent.end)
|
||||||
|
} else {
|
||||||
|
start.linkTo(parent.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
ConstraintLayout(
|
||||||
|
modifier = modifier
|
||||||
|
.wrapContentHeight()
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
val (sender, message, reactions) = createRefs()
|
||||||
|
|
||||||
|
// Sender
|
||||||
|
val avatarStrokeSize = 3.dp
|
||||||
|
if (event.showSenderInformation) {
|
||||||
|
MessageSenderInformation(
|
||||||
|
event.safeSenderName,
|
||||||
|
event.senderAvatar,
|
||||||
|
avatarStrokeSize,
|
||||||
|
Modifier
|
||||||
|
.constrainAs(sender) {
|
||||||
|
top.linkTo(parent.top)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.zIndex(1f)
|
||||||
|
.clickable(onClick = onUserDataClicked)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message bubble
|
||||||
|
val bubbleState = BubbleState(
|
||||||
|
groupPosition = event.groupPosition,
|
||||||
|
isMine = event.isMine,
|
||||||
|
isHighlighted = isHighlighted,
|
||||||
|
)
|
||||||
|
MessageEventBubble(
|
||||||
|
modifier = Modifier
|
||||||
|
.constrainAs(message) {
|
||||||
|
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp)
|
||||||
|
this.linkStartOrEnd(event)
|
||||||
|
},
|
||||||
|
state = bubbleState,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
) {
|
||||||
|
MessageEventBubbleContent(
|
||||||
|
event = event,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
onMessageClick = onClick,
|
||||||
|
onMessageLongClick = onLongClick,
|
||||||
|
inReplyToClick = inReplyToClicked,
|
||||||
|
onTimestampClicked = {
|
||||||
|
onTimestampClicked(event)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
if (event.reactionsState.reactions.isNotEmpty()) {
|
||||||
|
TimelineItemReactionsView(
|
||||||
|
reactionsState = event.reactionsState,
|
||||||
|
mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start,
|
||||||
|
onReactionClicked = onReactionClicked,
|
||||||
|
onMoreReactionsClicked = { onMoreReactionsClicked(event) },
|
||||||
|
modifier = Modifier
|
||||||
|
.constrainAs(reactions) {
|
||||||
|
top.linkTo(message.bottom, margin = (-4).dp)
|
||||||
|
this.linkStartOrEnd(event)
|
||||||
|
}
|
||||||
|
.zIndex(1f)
|
||||||
|
.padding(start = if (event.isMine) 16.dp else 36.dp, end = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DismissState.toSwipeProgress(): Float {
|
||||||
|
return when (targetValue) {
|
||||||
|
DismissValue.Default -> 0f
|
||||||
|
DismissValue.DismissedToEnd -> progress * 3
|
||||||
|
DismissValue.DismissedToStart -> progress * 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageSenderInformation(
|
private fun MessageSenderInformation(
|
||||||
sender: String,
|
sender: String,
|
||||||
senderAvatar: AvatarData,
|
senderAvatar: AvatarData,
|
||||||
|
avatarStrokeSize: Dp,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val avatarStrokeSize = 3.dp
|
|
||||||
val avatarStrokeColor = MaterialTheme.colorScheme.background
|
val avatarStrokeColor = MaterialTheme.colorScheme.background
|
||||||
val avatarSize = senderAvatar.size.dp
|
val avatarSize = senderAvatar.size.dp
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -449,30 +543,36 @@ private fun ContentToPreview() {
|
||||||
content = aTimelineItemTextContent().copy(
|
content = aTimelineItemTextContent().copy(
|
||||||
body = "A long text which will be displayed on several lines and" +
|
body = "A long text which will be displayed on several lines and" +
|
||||||
" hopefully can be manually adjusted to test different behaviors."
|
" hopefully can be manually adjusted to test different behaviors."
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
isMine = it,
|
isMine = it,
|
||||||
content = aTimelineItemImageContent().copy(
|
content = aTimelineItemImageContent().copy(
|
||||||
aspectRatio = 5f
|
aspectRatio = 5f
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -492,7 +592,7 @@ internal fun TimelineItemEventRowWithReplyDarkPreview() =
|
||||||
private fun ContentToPreviewWithReply() {
|
private fun ContentToPreviewWithReply() {
|
||||||
Column {
|
Column {
|
||||||
sequenceOf(false, true).forEach {
|
sequenceOf(false, true).forEach {
|
||||||
val replyContent = if(it) {
|
val replyContent = if (it) {
|
||||||
// Short
|
// Short
|
||||||
"Message which are being replied."
|
"Message which are being replied."
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -509,12 +609,15 @@ private fun ContentToPreviewWithReply() {
|
||||||
inReplyTo = aInReplyToReady(replyContent)
|
inReplyTo = aInReplyToReady(replyContent)
|
||||||
),
|
),
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
TimelineItemEventRow(
|
TimelineItemEventRow(
|
||||||
event = aTimelineItemEvent(
|
event = aTimelineItemEvent(
|
||||||
|
|
@ -525,12 +628,15 @@ private fun ContentToPreviewWithReply() {
|
||||||
inReplyTo = aInReplyToReady(replyContent)
|
inReplyTo = aInReplyToReady(replyContent)
|
||||||
),
|
),
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -578,14 +684,56 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
|
||||||
senderDisplayName = if (useDocument) "Document case" else "Text case",
|
senderDisplayName = if (useDocument) "Document case" else "Text case",
|
||||||
),
|
),
|
||||||
isHighlighted = false,
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
onClick = {},
|
onClick = {},
|
||||||
onLongClick = {},
|
onLongClick = {},
|
||||||
onUserDataClick = {},
|
onUserDataClick = {},
|
||||||
inReplyToClick = {},
|
inReplyToClick = {},
|
||||||
onReactionClick = { _, _ -> },
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
onTimestampClicked = {},
|
onTimestampClicked = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =
|
||||||
|
ElementPreviewLight { ContentWithManyReactionsToPreview() }
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
internal fun TimelineItemEventRowWithManyReactionsDarkPreview() =
|
||||||
|
ElementPreviewDark { ContentWithManyReactionsToPreview() }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentWithManyReactionsToPreview() {
|
||||||
|
Column {
|
||||||
|
listOf(false, true).forEach { isMine ->
|
||||||
|
TimelineItemEventRow(
|
||||||
|
event = aTimelineItemEvent(
|
||||||
|
isMine = isMine,
|
||||||
|
content = aTimelineItemTextContent().copy(
|
||||||
|
body = "A couple of multi-line messages with many reactions attached." +
|
||||||
|
" One sent by me and another from someone else."
|
||||||
|
),
|
||||||
|
timelineItemReactions = aTimelineItemReactions(count = 20),
|
||||||
|
),
|
||||||
|
isHighlighted = false,
|
||||||
|
canReply = true,
|
||||||
|
onClick = {},
|
||||||
|
onLongClick = {},
|
||||||
|
onUserDataClick = {},
|
||||||
|
inReplyToClick = {},
|
||||||
|
onReactionClick = { _, _ -> },
|
||||||
|
onMoreReactionsClick = {},
|
||||||
|
onSwipeToReply = {},
|
||||||
|
onTimestampClicked = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
|
||||||
import com.google.accompanist.flowlayout.FlowRow
|
import com.google.accompanist.flowlayout.FlowRow
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
|
||||||
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
||||||
|
|
@ -29,13 +30,16 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemReactionsView(
|
fun TimelineItemReactionsView(
|
||||||
reactionsState: TimelineItemReactions,
|
reactionsState: TimelineItemReactions,
|
||||||
|
mainAxisAlignment: FlowMainAxisAlignment,
|
||||||
|
onReactionClicked: (emoji: String) -> Unit,
|
||||||
|
onMoreReactionsClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onReactionClicked: (emoji: String) -> Unit
|
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
mainAxisSpacing = 2.dp,
|
mainAxisSpacing = 4.dp,
|
||||||
crossAxisSpacing = 8.dp,
|
crossAxisSpacing = 4.dp,
|
||||||
|
mainAxisAlignment = mainAxisAlignment,
|
||||||
) {
|
) {
|
||||||
reactionsState.reactions.forEach { reaction ->
|
reactionsState.reactions.forEach { reaction ->
|
||||||
MessagesReactionButton(
|
MessagesReactionButton(
|
||||||
|
|
@ -43,6 +47,9 @@ fun TimelineItemReactionsView(
|
||||||
onClick = { onReactionClicked(reaction.key) }
|
onClick = { onReactionClicked(reaction.key) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
MessagesMoreReactionsButton(
|
||||||
|
onClick = onMoreReactionsClicked
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,6 +67,8 @@ internal fun TimelineItemReactionsViewDarkPreview() =
|
||||||
private fun ContentToPreview() {
|
private fun ContentToPreview() {
|
||||||
TimelineItemReactionsView(
|
TimelineItemReactionsView(
|
||||||
reactionsState = aTimelineItemReactions(),
|
reactionsState = aTimelineItemReactions(),
|
||||||
onReactionClicked = { }
|
mainAxisAlignment = FlowMainAxisAlignment.Center,
|
||||||
|
onReactionClicked = {},
|
||||||
|
onMoreReactionsClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.unit.TextUnit
|
||||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ val noExtraPadding = ExtraPadding(0)
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
|
||||||
val formattedTime = sentTime
|
val formattedTime = sentTime
|
||||||
val hasMessageSendingFailed = sendState is EventSendState.SendingFailed
|
val hasMessageSendingFailed = localSendState is LocalEventSendState.SendingFailed
|
||||||
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
val isMessageEdited = (content as? TimelineItemTextBasedContent)?.isEdited.orFalse()
|
||||||
|
|
||||||
var strLen = 6
|
var strLen = 6
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -28,21 +30,31 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemLocationView(
|
fun TimelineItemLocationView(
|
||||||
content: TimelineItemLocationContent,
|
content: TimelineItemLocationContent,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
StaticMapView(
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
modifier = modifier
|
content.description?.let {
|
||||||
.fillMaxWidth()
|
Text(
|
||||||
.heightIn(max = 188.dp),
|
text = it,
|
||||||
lat = content.location.lat,
|
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
lon = content.location.lon,
|
)
|
||||||
zoom = 15.0,
|
}
|
||||||
contentDescription = content.body
|
|
||||||
)
|
StaticMapView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 188.dp),
|
||||||
|
lat = content.location.lat,
|
||||||
|
lon = content.location.lon,
|
||||||
|
zoom = 15.0,
|
||||||
|
contentDescription = content.body
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class EventDebugInfoNode @AssistedInject constructor(
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val eventId: EventId,
|
val eventId: EventId?,
|
||||||
val timelineItemDebugInfo: TimelineItemDebugInfo,
|
val timelineItemDebugInfo: TimelineItemDebugInfo,
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EventDebugInfoView(
|
fun EventDebugInfoView(
|
||||||
eventId: EventId,
|
eventId: EventId?,
|
||||||
model: String,
|
model: String,
|
||||||
originalJson: String?,
|
originalJson: String?,
|
||||||
latestEditedJson: String?,
|
latestEditedJson: String?,
|
||||||
|
|
@ -99,7 +99,7 @@ fun EventDebugInfoView(
|
||||||
item {
|
item {
|
||||||
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(text = "Event ID:")
|
Text(text = "Event ID:")
|
||||||
CopyableText(text = eventId.value)
|
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -142,7 +142,7 @@ private fun CollapsibleSection(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
|
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
|
||||||
CopyableText(text = text)
|
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.factories.event
|
package io.element.android.features.messages.impl.timeline.factories.event
|
||||||
|
|
||||||
import io.element.android.features.location.api.parseGeoUri
|
import io.element.android.features.location.api.Location
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||||
|
|
@ -68,7 +68,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is LocationMessageType -> {
|
is LocationMessageType -> {
|
||||||
val location = parseGeoUri(messageType.geoUri)
|
val location = Location.fromGeoUri(messageType.geoUri)
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
|
|
@ -79,6 +79,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
|
||||||
TimelineItemLocationContent(
|
TimelineItemLocationContent(
|
||||||
body = messageType.body,
|
body = messageType.body,
|
||||||
location = location,
|
location = location,
|
||||||
|
description = messageType.description
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
|
|
@ -83,7 +82,7 @@ class TimelineItemEventFactory @Inject constructor(
|
||||||
sentTime = sentTime,
|
sentTime = sentTime,
|
||||||
groupPosition = groupPosition,
|
groupPosition = groupPosition,
|
||||||
reactionsState = currentTimelineItem.computeReactionsState(),
|
reactionsState = currentTimelineItem.computeReactionsState(),
|
||||||
sendState = currentTimelineItem.event.localSendState ?: EventSendState.NotSentYet,
|
localSendState = currentTimelineItem.event.localSendState,
|
||||||
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
inReplyTo = currentTimelineItem.event.inReplyTo(),
|
||||||
debugInfo = currentTimelineItem.event.debugInfo,
|
debugInfo = currentTimelineItem.event.debugInfo,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
|
@ -61,7 +62,7 @@ sealed interface TimelineItem {
|
||||||
val isMine: Boolean = false,
|
val isMine: Boolean = false,
|
||||||
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
val groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
|
||||||
val reactionsState: TimelineItemReactions,
|
val reactionsState: TimelineItemReactions,
|
||||||
val sendState: EventSendState,
|
val localSendState: LocalEventSendState?,
|
||||||
val inReplyTo: InReplyTo?,
|
val inReplyTo: InReplyTo?,
|
||||||
val debugInfo: TimelineItemDebugInfo,
|
val debugInfo: TimelineItemDebugInfo,
|
||||||
) : TimelineItem {
|
) : TimelineItem {
|
||||||
|
|
@ -69,6 +70,12 @@ sealed interface TimelineItem {
|
||||||
val showSenderInformation = groupPosition.isNew() && !isMine
|
val showSenderInformation = groupPosition.isNew() && !isMine
|
||||||
|
|
||||||
val safeSenderName: String = senderDisplayName ?: senderId.value
|
val safeSenderName: String = senderDisplayName ?: senderId.value
|
||||||
|
|
||||||
|
val failedToSend: Boolean = localSendState is LocalEventSendState.SendingFailed
|
||||||
|
|
||||||
|
val isTextMessage: Boolean = content is TimelineItemTextBasedContent
|
||||||
|
|
||||||
|
val isRemote = eventId != null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,14 @@
|
||||||
package io.element.android.features.messages.impl.timeline.model
|
package io.element.android.features.messages.impl.timeline.model
|
||||||
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
data class TimelineItemReactions(
|
data class TimelineItemReactions(
|
||||||
val reactions: ImmutableList<AggregatedReaction>
|
val reactions: ImmutableList<AggregatedReaction>
|
||||||
)
|
) {
|
||||||
|
val highlightedKeys: ImmutableList<String>
|
||||||
|
get() = reactions
|
||||||
|
.filter { it.isHighlighted }
|
||||||
|
.map { it.key }
|
||||||
|
.toPersistentList()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
|
||||||
aTimelineItemFileContent("A file.pdf"),
|
aTimelineItemFileContent("A file.pdf"),
|
||||||
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
|
aTimelineItemLocationContent("Location description"),
|
||||||
aTimelineItemNoticeContent(),
|
aTimelineItemNoticeContent(),
|
||||||
aTimelineItemRedactedContent(),
|
aTimelineItemRedactedContent(),
|
||||||
aTimelineItemTextContent(),
|
aTimelineItemTextContent(),
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,16 @@ open class TimelineItemLocationContentProvider : PreviewParameterProvider<Timeli
|
||||||
override val values: Sequence<TimelineItemLocationContent>
|
override val values: Sequence<TimelineItemLocationContent>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aTimelineItemLocationContent(),
|
aTimelineItemLocationContent(),
|
||||||
|
aTimelineItemLocationContent("This is a description!"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aTimelineItemLocationContent() = TimelineItemLocationContent(
|
fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent(
|
||||||
body = "User location geo:52.2445,0.7186;u=5000",
|
body = "User location geo:52.2445,0.7186;u=5000",
|
||||||
location = Location(
|
location = Location(
|
||||||
lat = 52.2445,
|
lat = 52.2445,
|
||||||
lon = 0.7186,
|
lon = 0.7186,
|
||||||
accuracy = 5000f,
|
accuracy = 5000f,
|
||||||
)
|
),
|
||||||
|
description = description,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,14 @@
|
||||||
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
<string name="screen_room_attachment_source_files">"Attachment"</string>
|
||||||
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
<string name="screen_room_attachment_source_gallery">"Photo & Video Library"</string>
|
||||||
<string name="screen_room_attachment_source_location">"Location"</string>
|
<string name="screen_room_attachment_source_location">"Location"</string>
|
||||||
|
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable in this room"</string>
|
||||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||||
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
<string name="screen_room_invite_again_alert_message">"Would you like to invite them back?"</string>
|
||||||
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
|
||||||
<string name="screen_room_message_copied">"Message copied"</string>
|
<string name="screen_room_message_copied">"Message copied"</string>
|
||||||
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
|
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
|
||||||
|
<string name="screen_room_reactions_show_less">"Show less"</string>
|
||||||
|
<string name="screen_room_reactions_show_more">"Show more"</string>
|
||||||
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
||||||
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
||||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class FakeMessagesNavigator : MessagesNavigator {
|
||||||
var onReportContentClickedCount = 0
|
var onReportContentClickedCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override fun onShowEventDebugInfoClicked(eventId: EventId, debugInfo: TimelineItemDebugInfo) {
|
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||||
onShowEventDebugInfoClickedCount++
|
onShowEventDebugInfoClickedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,12 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||||
|
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -62,7 +65,6 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
TimelineItemAction.Copy,
|
|
||||||
TimelineItemAction.Developer,
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -88,7 +90,6 @@ class ActionListPresenterTest {
|
||||||
ActionListState.Target.Success(
|
ActionListState.Target.Success(
|
||||||
messageEvent,
|
messageEvent,
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
TimelineItemAction.Copy,
|
|
||||||
TimelineItemAction.Developer,
|
TimelineItemAction.Developer,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -184,7 +185,6 @@ class ActionListPresenterTest {
|
||||||
persistentListOf(
|
persistentListOf(
|
||||||
TimelineItemAction.Reply,
|
TimelineItemAction.Reply,
|
||||||
TimelineItemAction.Forward,
|
TimelineItemAction.Forward,
|
||||||
TimelineItemAction.Edit,
|
|
||||||
TimelineItemAction.Developer,
|
TimelineItemAction.Developer,
|
||||||
TimelineItemAction.Redact,
|
TimelineItemAction.Redact,
|
||||||
)
|
)
|
||||||
|
|
@ -195,6 +195,63 @@ class ActionListPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for a state item in debug build`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = true)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val stateEvent = aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemStateEventContent(),
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
|
||||||
|
// val loadingState = awaitItem()
|
||||||
|
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
stateEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Developer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||||
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute for a state item in non-debuggable build`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val stateEvent = aTimelineItemEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = aTimelineItemStateEventContent(),
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(stateEvent))
|
||||||
|
// val loadingState = awaitItem()
|
||||||
|
// assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
stateEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||||
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - compute message in non-debuggable build`() = runTest {
|
fun `present - compute message in non-debuggable build`() = runTest {
|
||||||
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
|
@ -226,6 +283,62 @@ class ActionListPresenterTest {
|
||||||
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute message with no actions`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
|
||||||
|
)
|
||||||
|
val redactedEvent = aMessageEvent(
|
||||||
|
isMine = true,
|
||||||
|
content = TimelineItemRedactedContent,
|
||||||
|
)
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||||
|
assertThat(awaitItem().target).isInstanceOf(ActionListState.Target.Success::class.java)
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(redactedEvent))
|
||||||
|
awaitItem().run {
|
||||||
|
assertThat(target).isEqualTo(ActionListState.Target.None)
|
||||||
|
assertThat(displayEmojiReactions).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - compute not sent message`() = runTest {
|
||||||
|
val presenter = anActionListPresenter(isBuildDebuggable = false)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
val messageEvent = aMessageEvent(
|
||||||
|
eventId = null, // No event id, so it's not sent yet
|
||||||
|
isMine = true,
|
||||||
|
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent))
|
||||||
|
val successState = awaitItem()
|
||||||
|
assertThat(successState.target).isEqualTo(
|
||||||
|
ActionListState.Target.Success(
|
||||||
|
messageEvent,
|
||||||
|
persistentListOf(
|
||||||
|
TimelineItemAction.Edit,
|
||||||
|
TimelineItemAction.Copy,
|
||||||
|
TimelineItemAction.Redact,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(successState.displayEmojiReactions).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
|
|
@ -38,6 +38,7 @@ internal fun aMessageEvent(
|
||||||
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
|
||||||
inReplyTo: InReplyTo? = null,
|
inReplyTo: InReplyTo? = null,
|
||||||
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),
|
||||||
|
sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||||
) = TimelineItem.Event(
|
) = TimelineItem.Event(
|
||||||
id = eventId?.value.orEmpty(),
|
id = eventId?.value.orEmpty(),
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
|
|
@ -48,7 +49,7 @@ internal fun aMessageEvent(
|
||||||
sentTime = "",
|
sentTime = "",
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
localSendState = sendState,
|
||||||
inReplyTo = inReplyTo,
|
inReplyTo = inReplyTo,
|
||||||
debugInfo = debugInfo,
|
debugInfo = debugInfo,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -43,6 +44,7 @@ import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_REPLY
|
import io.element.android.libraries.matrix.test.A_REPLY
|
||||||
|
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||||
|
|
@ -193,7 +195,7 @@ class MessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - edit message`() = runTest {
|
fun `present - edit sent message`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
|
|
@ -219,7 +221,38 @@ class MessageComposerPresenterTest {
|
||||||
val messageSentState = awaitItem()
|
val messageSentState = awaitItem()
|
||||||
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||||
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
assertThat(fakeMatrixRoom.editMessageParameter).isEqualTo(ANOTHER_MESSAGE)
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - edit not sent message`() = runTest {
|
||||||
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
|
val presenter = createPresenter(
|
||||||
|
this,
|
||||||
|
fakeMatrixRoom,
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
|
||||||
|
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
|
||||||
|
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
|
||||||
|
skipItems(1)
|
||||||
|
val withMessageState = awaitItem()
|
||||||
|
assertThat(withMessageState.mode).isEqualTo(mode)
|
||||||
|
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
|
||||||
|
assertThat(withMessageState.isSendButtonVisible).isTrue()
|
||||||
|
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
|
||||||
|
val withEditedMessageState = awaitItem()
|
||||||
|
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
|
||||||
|
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
|
||||||
|
skipItems(1)
|
||||||
|
val messageSentState = awaitItem()
|
||||||
|
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
|
||||||
|
assertThat(messageSentState.isSendButtonVisible).isFalse()
|
||||||
|
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,6 +507,10 @@ class MessageComposerPresenterTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
fun anEditMode(
|
||||||
|
eventId: EventId? = AN_EVENT_ID,
|
||||||
|
message: String = A_MESSAGE,
|
||||||
|
transactionId: String? = null,
|
||||||
|
) = MessageComposerMode.Edit(eventId, message, transactionId)
|
||||||
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
|
||||||
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
|
||||||
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
|
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
|
@ -42,7 +42,7 @@ class TimelineItemGrouperTest {
|
||||||
senderDisplayName = "",
|
senderDisplayName = "",
|
||||||
content = TimelineItemStateEventContent(body = "a state event"),
|
content = TimelineItemStateEventContent(body = "a state event"),
|
||||||
reactionsState = aTimelineItemReactions(count = 0),
|
reactionsState = aTimelineItemReactions(count = 0),
|
||||||
sendState = EventSendState.Sent(AN_EVENT_ID),
|
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
|
||||||
inReplyTo = null,
|
inReplyTo = null,
|
||||||
debugInfo = aTimelineItemDebugInfo(),
|
debugInfo = aTimelineItemDebugInfo(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,5 @@ android {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
|
api(projects.libraries.matrix.api)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.api
|
package io.element.android.features.preferences.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface CacheService {
|
interface CacheService {
|
||||||
/**
|
/**
|
||||||
* Returns a flow of the current cache index, can let the app to know when the
|
* A flow of [SessionId], can let the app to know when the
|
||||||
* cache has been cleared, for instance to restart the app.
|
* cache has been cleared for a given session, for instance to restart the app.
|
||||||
* Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared.
|
|
||||||
*/
|
*/
|
||||||
fun cacheIndex(): Flow<Int>
|
val clearedCacheEventFlow: Flow<SessionId>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue