Merge develop into feature/fga/permalink_timeline
This commit is contained in:
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@11.3.1
|
||||
uses: danger/danger-js@12.1.0
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
|
||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@11.3.1
|
||||
uses: danger/danger-js@12.1.0
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
|
||||
17
CHANGES.md
17
CHANGES.md
@@ -1,3 +1,20 @@
|
||||
Changes in Element X v0.4.10 (2024-04-17)
|
||||
=========================================
|
||||
|
||||
Matrix Rust SDK 0.2.14
|
||||
|
||||
Features ✨
|
||||
----------
|
||||
- Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695))
|
||||
|
||||
Other changes
|
||||
-------------
|
||||
- Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703))
|
||||
- Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708))
|
||||
- Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709))
|
||||
- Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698))
|
||||
|
||||
|
||||
Changes in Element X v0.4.9 (2024-04-12)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -45,11 +45,4 @@ class IntentProviderImpl @Inject constructor(
|
||||
data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getInviteListIntent(sessionId: SessionId): Intent {
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
data = deepLinkCreator.inviteList(sessionId).toUri()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,16 +67,6 @@ class IntentProviderImplTest {
|
||||
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getInviteListIntent`() {
|
||||
val sut = createIntentProviderImpl()
|
||||
val result = sut.getInviteListIntent(
|
||||
sessionId = A_SESSION_ID,
|
||||
)
|
||||
result.commonAssertions()
|
||||
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/invites")
|
||||
}
|
||||
|
||||
private fun createIntentProviderImpl(): IntentProviderImpl {
|
||||
return IntentProviderImpl(
|
||||
context = RuntimeEnvironment.getApplication() as Context,
|
||||
|
||||
@@ -33,7 +33,6 @@ import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
@@ -48,7 +47,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.invite.api.InviteListEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.lockscreen.api.LockScreenLockState
|
||||
import io.element.android.features.lockscreen.api.LockScreenService
|
||||
@@ -62,17 +60,16 @@ import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.waitForChildAttached
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -81,7 +78,6 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.util.Optional
|
||||
@@ -95,11 +91,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val secureBackupEntryPoint: SecureBackupEntryPoint,
|
||||
private val inviteListEntryPoint: InviteListEntryPoint,
|
||||
private val ftueEntryPoint: FtueEntryPoint,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val notificationDrawerManager: NotificationDrawerManager,
|
||||
private val ftueService: FtueService,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
private val lockScreenStateService: LockScreenService,
|
||||
@@ -160,23 +154,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
)
|
||||
observeSyncStateAndNetworkStatus()
|
||||
observeInvitesLoadingState()
|
||||
}
|
||||
|
||||
private fun observeInvitesLoadingState() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
matrixClient.roomListService.invites.loadingState
|
||||
.collect { inviteState ->
|
||||
when (inviteState) {
|
||||
is RoomList.LoadingState.Loaded -> if (inviteState.numberOfRooms == 0) {
|
||||
backstack.removeLast(NavTarget.InviteList)
|
||||
}
|
||||
RoomList.LoadingState.NotLoaded -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@@ -215,9 +192,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data class Room(
|
||||
val roomId: RoomId,
|
||||
val roomIdOrAlias: RoomIdOrAlias,
|
||||
val roomDescription: RoomDescription? = null,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages()
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
@@ -233,9 +210,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object InviteList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Ftue : NavTarget
|
||||
|
||||
@@ -257,7 +231,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
NavTarget.RoomList -> {
|
||||
val callback = object : RoomListEntryPoint.Callback {
|
||||
override fun onRoomClicked(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
}
|
||||
|
||||
override fun onSettingsClicked() {
|
||||
@@ -272,12 +246,8 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
|
||||
}
|
||||
|
||||
override fun onInvitesClicked() {
|
||||
backstack.push(NavTarget.InviteList)
|
||||
}
|
||||
|
||||
override fun onRoomSettingsClicked(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.Details))
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details))
|
||||
}
|
||||
|
||||
override fun onReportBugClicked() {
|
||||
@@ -296,19 +266,41 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
is NavTarget.Room -> {
|
||||
val callback = object : JoinedRoomLoadedFlowNode.Callback {
|
||||
override fun onOpenRoom(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
}
|
||||
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
coroutineScope.launch { attachRoom(roomId) }
|
||||
}
|
||||
|
||||
override fun onPermalinkClicked(data: PermalinkData) {
|
||||
when (data) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// FIXME Add a user profile screen.
|
||||
Timber.e("User link clicked: ${data.userId}. TODO Add a user profile screen")
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
backstack.push(
|
||||
NavTarget.Room(
|
||||
data.roomIdOrAlias,
|
||||
initialElement = RoomNavigationTarget.Messages(data.eventId),
|
||||
// TODO Use the viaParameters
|
||||
)
|
||||
)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
// Should not happen (handled by MessagesNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenGlobalNotificationSettings() {
|
||||
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
|
||||
}
|
||||
}
|
||||
val inputs = RoomFlowNode.Inputs(
|
||||
roomId = navTarget.roomId,
|
||||
roomIdOrAlias = navTarget.roomIdOrAlias,
|
||||
roomDescription = Optional.ofNullable(navTarget.roomDescription),
|
||||
initialElement = navTarget.initialElement
|
||||
)
|
||||
@@ -325,7 +317,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.NotificationSettings))
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
|
||||
}
|
||||
}
|
||||
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
|
||||
@@ -337,7 +329,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
NavTarget.CreateRoom -> {
|
||||
val callback = object : CreateRoomEntryPoint.Callback {
|
||||
override fun onSuccess(roomId: RoomId) {
|
||||
backstack.replace(NavTarget.Room(roomId))
|
||||
backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,25 +343,6 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
|
||||
.build()
|
||||
}
|
||||
NavTarget.InviteList -> {
|
||||
val callback = object : InviteListEntryPoint.Callback {
|
||||
override fun onBackClicked() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onInviteClicked(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
|
||||
override fun onInviteAccepted(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
}
|
||||
|
||||
inviteListEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
NavTarget.Ftue -> {
|
||||
ftueEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(object : FtueEntryPoint.Callback {
|
||||
@@ -383,11 +356,11 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(object : RoomDirectoryEntryPoint.Callback {
|
||||
override fun onRoomJoined(roomId: RoomId) {
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
}
|
||||
|
||||
override fun onResultClicked(roomDescription: RoomDescription) {
|
||||
backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription))
|
||||
backstack.push(NavTarget.Room(roomDescription.roomId.toRoomIdOrAlias(), roomDescription))
|
||||
}
|
||||
})
|
||||
.build()
|
||||
@@ -406,17 +379,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
if (!canShowRoomList()) return
|
||||
attachChild<RoomFlowNode> {
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
backstack.push(NavTarget.Room(roomId))
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) {
|
||||
if (!canShowRoomList()) return@withContext
|
||||
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId)
|
||||
backstack.singleTop(NavTarget.RoomList)
|
||||
backstack.push(NavTarget.InviteList)
|
||||
waitForChildAttached<Node, NavTarget> { navTarget ->
|
||||
navTarget is NavTarget.InviteList
|
||||
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ class RootFlowNode @AssistedInject constructor(
|
||||
when (deeplinkData) {
|
||||
is DeeplinkData.Root -> attachRoomList()
|
||||
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
|
||||
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
|
||||
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
|
||||
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
|
||||
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
@@ -46,12 +47,15 @@ import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.util.Optional
|
||||
@@ -64,6 +68,7 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
private val joinRoomEntryPoint: JoinRoomEntryPoint,
|
||||
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
|
||||
) : BaseFlowNode<RoomFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Loading,
|
||||
@@ -73,9 +78,9 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
plugins = plugins
|
||||
) {
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val roomIdOrAlias: RoomIdOrAlias,
|
||||
val roomDescription: Optional<RoomDescription>,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
@@ -85,29 +90,51 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
data object Loading : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object JoinRoom : NavTarget
|
||||
data class Resolving(val roomAlias: RoomAlias) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object JoinedRoom : NavTarget
|
||||
data class JoinRoom(val roomId: RoomId) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class JoinedRoom(val roomId: RoomId) : NavTarget
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
client.getRoomInfoFlow(
|
||||
inputs.roomId
|
||||
).onEach { roomInfo ->
|
||||
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
|
||||
if (roomInfo.getOrNull()?.currentUserMembership == CurrentUserMembership.JOINED) {
|
||||
backstack.newRoot(NavTarget.JoinedRoom)
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.JoinRoom)
|
||||
resolveRoomId()
|
||||
}
|
||||
|
||||
private fun resolveRoomId() {
|
||||
lifecycleScope.launch {
|
||||
when (val i = inputs.roomIdOrAlias) {
|
||||
is RoomIdOrAlias.Alias -> {
|
||||
backstack.newRoot(NavTarget.Resolving(i.roomAlias))
|
||||
}
|
||||
is RoomIdOrAlias.Id -> {
|
||||
subscribeToRoomInfoFlow(i.roomId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToRoomInfoFlow(roomId: RoomId) {
|
||||
client.getRoomInfoFlow(
|
||||
roomId = roomId
|
||||
)
|
||||
.onEach { roomInfo ->
|
||||
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
|
||||
val info = roomInfo.getOrNull()
|
||||
if (info?.currentUserMembership == CurrentUserMembership.JOINED) {
|
||||
backstack.newRoot(NavTarget.JoinedRoom(roomId))
|
||||
} else {
|
||||
backstack.newRoot(NavTarget.JoinRoom(roomId))
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
// When leaving the room from this session only, navigate up.
|
||||
roomMembershipObserver.updates
|
||||
.filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom }
|
||||
.filter { update -> update.roomId == roomId && !update.isUserInRoom }
|
||||
.onEach {
|
||||
navigateUp()
|
||||
}
|
||||
@@ -116,14 +143,30 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Loading -> loadingNode(buildContext)
|
||||
NavTarget.JoinRoom -> {
|
||||
val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription)
|
||||
is NavTarget.Loading -> loadingNode(buildContext)
|
||||
is NavTarget.Resolving -> {
|
||||
val callback = object : RoomAliasResolverEntryPoint.Callback {
|
||||
override fun onAliasResolved(roomId: RoomId) {
|
||||
subscribeToRoomInfoFlow(roomId)
|
||||
}
|
||||
}
|
||||
val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias)
|
||||
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(callback)
|
||||
.params(params)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.JoinRoom -> {
|
||||
val inputs = JoinRoomEntryPoint.Inputs(
|
||||
roomId = navTarget.roomId,
|
||||
roomIdOrAlias = inputs.roomIdOrAlias,
|
||||
roomDescription = inputs.roomDescription,
|
||||
)
|
||||
joinRoomEntryPoint.createNode(this, buildContext, inputs)
|
||||
}
|
||||
NavTarget.JoinedRoom -> {
|
||||
is NavTarget.JoinedRoom -> {
|
||||
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
|
||||
val inputs = JoinedRoomFlowNode.Inputs(inputs.roomId, initialElement = inputs.initialElement)
|
||||
val inputs = JoinedRoomFlowNode.Inputs(navTarget.roomId, initialElement = inputs.initialElement)
|
||||
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,17 @@
|
||||
|
||||
package io.element.android.appnav.room
|
||||
|
||||
enum class RoomNavigationTarget {
|
||||
Messages,
|
||||
Details,
|
||||
NotificationSettings,
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface RoomNavigationTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Messages(val focusedEventId: EventId? = null) : RoomNavigationTarget
|
||||
|
||||
@Parcelize
|
||||
data object Details : RoomNavigationTarget
|
||||
|
||||
@Parcelize
|
||||
data object NotificationSettings : RoomNavigationTarget
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
|
||||
) {
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
@@ -42,8 +42,10 @@ import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DaggerComponentOwner
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -63,8 +65,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
roomComponentFactory: RoomComponentFactory,
|
||||
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
|
||||
RoomNavigationTarget.Messages -> NavTarget.Messages
|
||||
initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
|
||||
is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
|
||||
RoomNavigationTarget.Details -> NavTarget.RoomDetails
|
||||
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
|
||||
},
|
||||
@@ -75,13 +77,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
), DaggerComponentOwner {
|
||||
interface Callback : Plugin {
|
||||
fun onOpenRoom(roomId: RoomId)
|
||||
fun onPermalinkClicked(data: PermalinkData)
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
fun onOpenGlobalNotificationSettings()
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
val room: MatrixRoom,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages,
|
||||
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
@@ -139,7 +142,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Messages -> {
|
||||
is NavTarget.Messages -> {
|
||||
val callback = object : MessagesEntryPoint.Callback {
|
||||
override fun onRoomDetailsClicked() {
|
||||
backstack.push(NavTarget.RoomDetails)
|
||||
@@ -149,11 +152,18 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||
}
|
||||
|
||||
override fun onPermalinkClicked(data: PermalinkData) {
|
||||
callbacks.forEach { it.onPermalinkClicked(data) }
|
||||
}
|
||||
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
|
||||
}
|
||||
}
|
||||
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||
messagesEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(MessagesEntryPoint.Params(navTarget.focusedEventId))
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
NavTarget.RoomDetails -> {
|
||||
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
|
||||
@@ -169,7 +179,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Messages : NavTarget
|
||||
data class Messages(val focusedEventId: EventId? = null) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object RoomDetails : NavTarget
|
||||
|
||||
@@ -47,14 +47,30 @@ class JoinRoomLoadedFlowNodeTest {
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private class FakeMessagesEntryPoint : MessagesEntryPoint {
|
||||
private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
|
||||
var buildContext: BuildContext? = null
|
||||
var nodeId: String? = null
|
||||
var parameters: MessagesEntryPoint.Params? = null
|
||||
var callback: MessagesEntryPoint.Callback? = null
|
||||
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node {
|
||||
return node(buildContext) {}.also {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
|
||||
this.buildContext = buildContext
|
||||
return this
|
||||
}
|
||||
|
||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||
parameters = params
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
|
||||
this.callback = callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return node(buildContext!!) {}.also {
|
||||
nodeId = it.id
|
||||
this.callback = callback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,9 +134,9 @@ class JoinRoomLoadedFlowNodeTest {
|
||||
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
|
||||
|
||||
// THEN
|
||||
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages)
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED)
|
||||
val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!!
|
||||
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
|
||||
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
|
||||
val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
|
||||
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Fix compile for forks that use the `noop` analytics module
|
||||
@@ -1 +0,0 @@
|
||||
Encrypt new session data with a passphrase
|
||||
@@ -1 +0,0 @@
|
||||
Use sdk API to build permalinks
|
||||
@@ -1 +0,0 @@
|
||||
Parse permalink using parseMatrixEntityFrom from the SDK
|
||||
1
changelog.d/2721.misc
Normal file
1
changelog.d/2721.misc
Normal file
@@ -0,0 +1 @@
|
||||
RoomMember screen: fallback to userProfile data, if the member is not a user of the room.
|
||||
2
fastlane/metadata/android/en-US/changelogs/40004100.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40004100.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: Prepare navigation with permalink.
|
||||
Full changelog: https://github.com/element-hq/element-x-android/releases
|
||||
@@ -50,7 +50,6 @@ dependencies {
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_seeninvites")
|
||||
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSeenInvitesStore @Inject constructor(
|
||||
@ApplicationContext context: Context
|
||||
) : SeenInvitesStore {
|
||||
private val store = context.dataStore
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> =
|
||||
store.data.map { prefs ->
|
||||
prefs[seenInvitesKey]
|
||||
.orEmpty()
|
||||
.map { RoomId(it) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
|
||||
store.edit { prefs ->
|
||||
prefs[seenInvitesKey] = roomIds.map { it.value }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.invite.impl.R
|
||||
import io.element.android.features.invite.impl.model.InviteListInviteSummary
|
||||
import io.element.android.features.invite.impl.model.InviteListInviteSummaryProvider
|
||||
import io.element.android.features.invite.impl.model.InviteSender
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
private val minHeight = 72.dp
|
||||
|
||||
@Composable
|
||||
internal fun InviteSummaryRow(
|
||||
invite: InviteListInviteSummary,
|
||||
onAcceptClicked: () -> Unit,
|
||||
onDeclineClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = minHeight)
|
||||
) {
|
||||
DefaultInviteSummaryRow(
|
||||
invite = invite,
|
||||
onAcceptClicked = onAcceptClicked,
|
||||
onDeclineClicked = onDeclineClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultInviteSummaryRow(
|
||||
invite: InviteListInviteSummary,
|
||||
onAcceptClicked: () -> Unit,
|
||||
onDeclineClicked: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Avatar(
|
||||
invite.roomAvatarData,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 4.dp)
|
||||
.alignByBaseline()
|
||||
.weight(1f)
|
||||
) {
|
||||
val bonusPadding = if (invite.isNew) 12.dp else 0.dp
|
||||
|
||||
// Name
|
||||
Text(
|
||||
text = invite.roomName,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
modifier = Modifier.padding(end = bonusPadding),
|
||||
)
|
||||
|
||||
// ID or Alias
|
||||
invite.roomAlias?.let {
|
||||
Text(
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = bonusPadding),
|
||||
)
|
||||
}
|
||||
|
||||
// Sender
|
||||
invite.sender?.let { sender ->
|
||||
SenderRow(sender = sender)
|
||||
}
|
||||
|
||||
// CTAs
|
||||
Row(Modifier.padding(top = 12.dp)) {
|
||||
OutlinedButton(
|
||||
text = stringResource(CommonStrings.action_decline),
|
||||
onClick = onDeclineClicked,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_accept),
|
||||
onClick = onAcceptClicked,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UnreadIndicatorAtom(isVisible = invite.isNew)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SenderRow(sender: InviteSender) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = sender.avatarData,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->
|
||||
val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
|
||||
AnnotatedString(
|
||||
text = text,
|
||||
spanStyles = listOf(
|
||||
AnnotatedString.Range(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
start = senderNameStart,
|
||||
end = senderNameStart + sender.displayName.length
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InviteSummaryRowPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = ElementPreview {
|
||||
InviteSummaryRow(
|
||||
invite = data,
|
||||
onAcceptClicked = {},
|
||||
onDeclineClicked = {},
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.invite.impl.model.InviteListInviteSummary
|
||||
import io.element.android.features.invite.impl.model.InviteSender
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
class InviteListPresenter @Inject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val store: SeenInvitesStore,
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
) : Presenter<InviteListState> {
|
||||
@Composable
|
||||
override fun present(): InviteListState {
|
||||
val invites by client
|
||||
.roomListService
|
||||
.invites
|
||||
.summaries
|
||||
.collectAsState(initial = emptyList())
|
||||
|
||||
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
seenInvites = store.seenRoomIds().first()
|
||||
}
|
||||
|
||||
LaunchedEffect(invites) {
|
||||
store.markAsSeen(
|
||||
invites
|
||||
.filterIsInstance<RoomSummary.Filled>()
|
||||
.map { it.details.roomId }
|
||||
.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
|
||||
fun handleEvent(event: InviteListEvents) {
|
||||
when (event) {
|
||||
is InviteListEvents.AcceptInvite -> {
|
||||
acceptDeclineInviteState.eventSink(
|
||||
AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData())
|
||||
)
|
||||
}
|
||||
|
||||
is InviteListEvents.DeclineInvite -> {
|
||||
acceptDeclineInviteState.eventSink(
|
||||
AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inviteList = remember(seenInvites, invites) {
|
||||
invites
|
||||
.filterIsInstance<RoomSummary.Filled>()
|
||||
.map {
|
||||
it.toInviteSummary(seenInvites.contains(it.details.roomId))
|
||||
}
|
||||
.toPersistentList()
|
||||
}
|
||||
|
||||
return InviteListState(
|
||||
inviteList = inviteList,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
eventSink = ::handleEvent
|
||||
)
|
||||
}
|
||||
|
||||
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
|
||||
val i = inviter
|
||||
val avatarData = if (isDirect && i != null) {
|
||||
AvatarData(
|
||||
id = i.userId.value,
|
||||
name = i.displayName,
|
||||
url = i.avatarUrl,
|
||||
size = AvatarSize.RoomInviteItem,
|
||||
)
|
||||
} else {
|
||||
AvatarData(
|
||||
id = roomId.value,
|
||||
name = name,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.RoomInviteItem,
|
||||
)
|
||||
}
|
||||
|
||||
val alias = if (isDirect) {
|
||||
inviter?.userId?.value
|
||||
} else {
|
||||
canonicalAlias
|
||||
}
|
||||
|
||||
InviteListInviteSummary(
|
||||
roomId = roomId,
|
||||
roomName = name,
|
||||
roomAlias = alias,
|
||||
roomAvatarData = avatarData,
|
||||
isDirect = isDirect,
|
||||
isNew = !seen,
|
||||
sender = inviter
|
||||
?.takeIf { !isDirect }
|
||||
?.run {
|
||||
InviteSender(
|
||||
userId = userId,
|
||||
displayName = displayName ?: "",
|
||||
avatarData = AvatarData(
|
||||
id = userId.value,
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.InviteSender,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun InviteListInviteSummary.toInviteData() = InviteData(
|
||||
roomId = roomId,
|
||||
roomName = roomName,
|
||||
isDirect = isDirect,
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.invite.impl.model.InviteListInviteSummary
|
||||
import io.element.android.features.invite.impl.model.InviteSender
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
|
||||
private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider()
|
||||
|
||||
override val values: Sequence<InviteListState>
|
||||
get() = sequenceOf(
|
||||
anInviteListState(),
|
||||
anInviteListState(inviteList = persistentListOf()),
|
||||
) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState ->
|
||||
anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun anInviteListState(
|
||||
inviteList: ImmutableList<InviteListInviteSummary> = aInviteListInviteSummaryList(),
|
||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||
eventSink: (InviteListEvents) -> Unit = {}
|
||||
) = InviteListState(
|
||||
inviteList = inviteList,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {
|
||||
return persistentListOf(
|
||||
InviteListInviteSummary(
|
||||
roomId = RoomId("!id1:example.com"),
|
||||
roomName = "Room 1",
|
||||
roomAlias = "#room:example.org",
|
||||
sender = InviteSender(
|
||||
userId = UserId("@alice:example.org"),
|
||||
displayName = "Alice"
|
||||
),
|
||||
),
|
||||
InviteListInviteSummary(
|
||||
roomId = RoomId("!id2:example.com"),
|
||||
roomName = "Room 2",
|
||||
sender = InviteSender(
|
||||
userId = UserId("@bob:example.org"),
|
||||
displayName = "Bob"
|
||||
),
|
||||
),
|
||||
InviteListInviteSummary(
|
||||
roomId = RoomId("!id3:example.com"),
|
||||
roomName = "Alice",
|
||||
roomAlias = "@alice:example.com"
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.invite.impl.R
|
||||
import io.element.android.features.invite.impl.components.InviteSummaryRow
|
||||
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun InviteListView(
|
||||
state: InviteListState,
|
||||
onBackClicked: () -> Unit,
|
||||
onInviteAccepted: (RoomId) -> Unit,
|
||||
onInviteDeclined: (RoomId) -> Unit,
|
||||
onInviteClicked: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
InviteListContent(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onInviteClicked = onInviteClicked,
|
||||
onBackClicked = onBackClicked,
|
||||
)
|
||||
AcceptDeclineInviteView(
|
||||
state = state.acceptDeclineInviteState,
|
||||
onInviteAccepted = onInviteAccepted,
|
||||
onInviteDeclined = onInviteDeclined,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun InviteListContent(
|
||||
state: InviteListState,
|
||||
onBackClicked: () -> Unit,
|
||||
onInviteClicked: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackClicked)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.action_invites_list),
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
content = { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
) {
|
||||
if (state.inviteList.isEmpty()) {
|
||||
Spacer(Modifier.size(80.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.screen_invites_empty_list),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = state.inviteList,
|
||||
) { index, invite ->
|
||||
InviteSummaryRow(
|
||||
modifier = Modifier.clickable(
|
||||
onClick = { onInviteClicked(invite.roomId) }
|
||||
),
|
||||
invite = invite,
|
||||
onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
|
||||
onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
|
||||
)
|
||||
|
||||
if (index != state.inviteList.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreview {
|
||||
InviteListView(
|
||||
state = state,
|
||||
onBackClicked = {},
|
||||
onInviteAccepted = {},
|
||||
onInviteDeclined = {},
|
||||
onInviteClicked = {},
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@Immutable
|
||||
data class InviteListInviteSummary(
|
||||
val roomId: RoomId,
|
||||
val roomName: String = "",
|
||||
val roomAlias: String? = null,
|
||||
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
|
||||
val sender: InviteSender? = null,
|
||||
val isDirect: Boolean = false,
|
||||
val isNew: Boolean = false,
|
||||
)
|
||||
|
||||
data class InviteSender(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.model
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteListInviteSummary> {
|
||||
override val values: Sequence<InviteListInviteSummary>
|
||||
get() = sequenceOf(
|
||||
aInviteListInviteSummary(),
|
||||
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"),
|
||||
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true),
|
||||
aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
|
||||
aInviteListInviteSummary().copy(isNew = true)
|
||||
)
|
||||
}
|
||||
|
||||
fun aInviteListInviteSummary() = InviteListInviteSummary(
|
||||
roomId = RoomId("!room1:example.com"),
|
||||
roomName = "Some room with a long name that will truncate",
|
||||
sender = InviteSender(
|
||||
userId = UserId("@alice-with-a-long-mxid:example.org"),
|
||||
displayName = "Alice with a long name"
|
||||
),
|
||||
)
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
|
||||
@@ -29,6 +28,7 @@ import io.element.android.features.invite.impl.R
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
@@ -102,9 +102,9 @@ private fun DeclineConfirmationDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
|
||||
internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
|
||||
ElementPreview {
|
||||
AcceptDeclineInviteView(
|
||||
state = state,
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.invite.test.FakeSeenInvitesStore
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class InviteListPresenterTests {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - starts empty, adds invites when received`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val presenter = createInviteListPresenter(
|
||||
FakeMatrixClient(roomListService = roomListService)
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.inviteList).isEmpty()
|
||||
|
||||
roomListService.postInviteRooms(listOf(aRoomSummary()))
|
||||
|
||||
val withInviteState = awaitItem()
|
||||
assertThat(withInviteState.inviteList.size).isEqualTo(1)
|
||||
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - uses user ID and avatar for direct invites`() = runTest {
|
||||
val roomListService = FakeRoomListService().withDirectChatInvitation()
|
||||
val presenter = createInviteListPresenter(
|
||||
FakeMatrixClient(roomListService = roomListService)
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val withInviteState = awaitInitialItem()
|
||||
assertThat(withInviteState.inviteList.size).isEqualTo(1)
|
||||
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value)
|
||||
assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
|
||||
assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo(
|
||||
AvatarData(
|
||||
id = A_USER_ID.value,
|
||||
name = A_USER_NAME,
|
||||
url = AN_AVATAR_URL,
|
||||
size = AvatarSize.RoomInviteItem,
|
||||
)
|
||||
)
|
||||
assertThat(withInviteState.inviteList[0].sender).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - includes sender details for room invites`() = runTest {
|
||||
val roomListService = FakeRoomListService().withRoomInvitation()
|
||||
val presenter = createInviteListPresenter(
|
||||
FakeMatrixClient(roomListService = roomListService)
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val withInviteState = awaitInitialItem()
|
||||
assertThat(withInviteState.inviteList.size).isEqualTo(1)
|
||||
assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME)
|
||||
assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID)
|
||||
assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo(
|
||||
AvatarData(
|
||||
id = A_USER_ID.value,
|
||||
name = A_USER_NAME,
|
||||
url = AN_AVATAR_URL,
|
||||
size = AvatarSize.InviteSender,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - stores seen invites when received`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val store = FakeSeenInvitesStore()
|
||||
val presenter = createInviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
),
|
||||
store,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem()
|
||||
|
||||
// When one invite is received, that ID is saved
|
||||
roomListService.postInviteRooms(listOf(aRoomSummary()))
|
||||
|
||||
awaitItem()
|
||||
assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
|
||||
|
||||
// When a second is added, both are saved
|
||||
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
|
||||
|
||||
awaitItem()
|
||||
assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
|
||||
|
||||
// When they're both dismissed, an empty set is saved
|
||||
roomListService.postInviteRooms(listOf())
|
||||
|
||||
awaitItem()
|
||||
assertThat(store.getProvidedRoomIds()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - marks invite as new if they're unseen`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val store = FakeSeenInvitesStore()
|
||||
store.publishRoomIds(setOf(A_ROOM_ID))
|
||||
val presenter = createInviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
),
|
||||
store,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem()
|
||||
|
||||
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
|
||||
skipItems(1)
|
||||
|
||||
val withInviteState = awaitItem()
|
||||
assertThat(withInviteState.inviteList.size).isEqualTo(2)
|
||||
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
|
||||
assertThat(withInviteState.inviteList[0].isNew).isFalse()
|
||||
assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
|
||||
assertThat(withInviteState.inviteList[1].isNew).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService {
|
||||
postInviteRooms(
|
||||
listOf(
|
||||
RoomSummary.Filled(
|
||||
aRoomSummaryDetails(
|
||||
roomId = A_ROOM_ID,
|
||||
name = A_ROOM_NAME,
|
||||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
lastMessage = null,
|
||||
inviter = aRoomMember(
|
||||
userId = A_USER_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService {
|
||||
postInviteRooms(
|
||||
listOf(
|
||||
RoomSummary.Filled(
|
||||
aRoomSummaryDetails(
|
||||
roomId = A_ROOM_ID,
|
||||
name = A_ROOM_NAME,
|
||||
avatarUrl = null,
|
||||
isDirect = true,
|
||||
lastMessage = null,
|
||||
inviter = aRoomMember(
|
||||
userId = A_USER_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
membership = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous = false,
|
||||
powerLevel = 0,
|
||||
normalizedPowerLevel = 0,
|
||||
isIgnored = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
|
||||
aRoomSummaryDetails(
|
||||
roomId = id,
|
||||
name = A_ROOM_NAME,
|
||||
avatarUrl = null,
|
||||
isDirect = false,
|
||||
lastMessage = null,
|
||||
)
|
||||
)
|
||||
|
||||
private suspend fun TurbineTestContext<InviteListState>.awaitInitialItem(): InviteListState {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun createInviteListPresenter(
|
||||
client: MatrixClient,
|
||||
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
) = InviteListPresenter(
|
||||
client,
|
||||
seenInvitesStore,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.test
|
||||
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeSeenInvitesStore : SeenInvitesStore {
|
||||
private val existing = MutableStateFlow(emptySet<RoomId>())
|
||||
private var provided: Set<RoomId>? = null
|
||||
|
||||
fun publishRoomIds(invites: Set<RoomId>) {
|
||||
existing.value = invites
|
||||
}
|
||||
|
||||
fun getProvidedRoomIds() = provided
|
||||
|
||||
override fun seenRoomIds(): Flow<Set<RoomId>> = existing
|
||||
|
||||
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
|
||||
provided = roomIds.toSet()
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import java.util.Optional
|
||||
|
||||
interface JoinRoomEntryPoint : FeatureEntryPoint {
|
||||
@@ -29,6 +30,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint {
|
||||
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val roomIdOrAlias: RoomIdOrAlias,
|
||||
val roomDescription: Optional<RoomDescription>,
|
||||
) : NodeInputs
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.joinroom.impl
|
||||
|
||||
sealed interface JoinRoomEvents {
|
||||
data object RetryFetchingContent : JoinRoomEvents
|
||||
data object JoinRoom : JoinRoomEvents
|
||||
data object AcceptInvite : JoinRoomEvents
|
||||
data object DeclineInvite : JoinRoomEvents
|
||||
|
||||
@@ -37,7 +37,11 @@ class JoinRoomNode @AssistedInject constructor(
|
||||
private val acceptDeclineInviteView: AcceptDeclineInviteView,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private val inputs: JoinRoomEntryPoint.Inputs = inputs()
|
||||
private val presenter = presenterFactory.create(inputs.roomId, inputs.roomDescription)
|
||||
private val presenter = presenterFactory.create(
|
||||
inputs.roomId,
|
||||
inputs.roomIdOrAlias,
|
||||
inputs.roomDescription,
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
|
||||
package io.element.android.features.joinroom.impl
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
@@ -29,34 +33,57 @@ import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
|
||||
import java.util.Optional
|
||||
|
||||
class JoinRoomPresenter @AssistedInject constructor(
|
||||
@Assisted private val roomId: RoomId,
|
||||
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
|
||||
@Assisted private val roomDescription: Optional<RoomDescription>,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
) : Presenter<JoinRoomState> {
|
||||
interface Factory {
|
||||
fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter
|
||||
fun create(
|
||||
roomId: RoomId,
|
||||
roomIdOrAlias: RoomIdOrAlias,
|
||||
roomDescription: Optional<RoomDescription>,
|
||||
): JoinRoomPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): JoinRoomState {
|
||||
var retryCount by remember { mutableIntStateOf(0) }
|
||||
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
|
||||
val contentState by produceState<ContentState>(initialValue = ContentState.Loading(roomId), key1 = roomInfo) {
|
||||
value = when {
|
||||
val contentState by produceState<ContentState>(
|
||||
initialValue = ContentState.Loading(roomIdOrAlias),
|
||||
key1 = roomInfo,
|
||||
key2 = retryCount,
|
||||
) {
|
||||
when {
|
||||
roomInfo.isPresent -> {
|
||||
roomInfo.get().toContentState()
|
||||
value = roomInfo.get().toContentState()
|
||||
}
|
||||
roomDescription.isPresent -> {
|
||||
roomDescription.get().toContentState()
|
||||
value = roomDescription.get().toContentState()
|
||||
}
|
||||
else -> {
|
||||
ContentState.Loading(roomId)
|
||||
value = ContentState.Loading(roomIdOrAlias)
|
||||
val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias())
|
||||
value = result.fold(
|
||||
onSuccess = { it.toContentState() },
|
||||
onFailure = { throwable ->
|
||||
if (throwable.message?.contains("403") == true) {
|
||||
ContentState.UnknownRoom(roomIdOrAlias)
|
||||
} else {
|
||||
ContentState.Failure(roomIdOrAlias, throwable)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +91,8 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
|
||||
fun handleEvents(event: JoinRoomEvents) {
|
||||
when (event) {
|
||||
JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> {
|
||||
JoinRoomEvents.AcceptInvite,
|
||||
JoinRoomEvents.JoinRoom -> {
|
||||
val inviteData = contentState.toInviteData() ?: return
|
||||
acceptDeclineInviteState.eventSink(
|
||||
AcceptDeclineInviteEvents.AcceptInvite(inviteData)
|
||||
@@ -76,6 +104,9 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
AcceptDeclineInviteEvents.DeclineInvite(inviteData)
|
||||
)
|
||||
}
|
||||
JoinRoomEvents.RetryFetchingContent -> {
|
||||
retryCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +118,24 @@ class JoinRoomPresenter @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomPreview.toContentState(): ContentState {
|
||||
return ContentState.Loaded(
|
||||
roomId = roomId,
|
||||
name = name,
|
||||
topic = topic,
|
||||
alias = canonicalAlias,
|
||||
numberOfMembers = numberOfJoinedMembers,
|
||||
isDirect = false,
|
||||
roomAvatarUrl = avatarUrl,
|
||||
joinAuthorisationStatus = when {
|
||||
isInvited -> JoinAuthorisationStatus.IsInvited
|
||||
canKnock -> JoinAuthorisationStatus.CanKnock
|
||||
isPublic -> JoinAuthorisationStatus.CanJoin
|
||||
else -> JoinAuthorisationStatus.Unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun RoomDescription.toContentState(): ContentState {
|
||||
return ContentState.Loaded(
|
||||
@@ -108,7 +157,7 @@ internal fun RoomDescription.toContentState(): ContentState {
|
||||
@VisibleForTesting
|
||||
internal fun MatrixRoomInfo.toContentState(): ContentState {
|
||||
return ContentState.Loaded(
|
||||
roomId = RoomId(id),
|
||||
roomId = id,
|
||||
name = name,
|
||||
topic = topic,
|
||||
alias = canonicalAlias,
|
||||
|
||||
@@ -20,7 +20,9 @@ import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
|
||||
@Immutable
|
||||
data class JoinRoomState(
|
||||
@@ -35,13 +37,14 @@ data class JoinRoomState(
|
||||
}
|
||||
|
||||
sealed interface ContentState {
|
||||
data class Loading(val roomId: RoomId) : ContentState
|
||||
data class UnknownRoom(val roomId: RoomId) : ContentState
|
||||
data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState
|
||||
data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState
|
||||
data class UnknownRoom(val roomIdOrAlias: RoomIdOrAlias) : ContentState
|
||||
data class Loaded(
|
||||
val roomId: RoomId,
|
||||
val name: String?,
|
||||
val topic: String?,
|
||||
val alias: String?,
|
||||
val alias: RoomAlias?,
|
||||
val numberOfMembers: Long?,
|
||||
val isDirect: Boolean,
|
||||
val roomAvatarUrl: String?,
|
||||
@@ -50,7 +53,7 @@ sealed interface ContentState {
|
||||
val computedTitle = name ?: roomId.value
|
||||
|
||||
val computedSubtitle = when {
|
||||
alias != null -> alias
|
||||
alias != null -> alias.value
|
||||
name == null -> ""
|
||||
else -> roomId.value
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ package io.element.android.features.joinroom.impl
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
|
||||
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
override val values: Sequence<JoinRoomState>
|
||||
@@ -34,22 +37,45 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock)
|
||||
contentState = aLoadedContentState(
|
||||
joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
|
||||
topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
|
||||
" ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
|
||||
" laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
|
||||
" voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
|
||||
" non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
|
||||
numberOfMembers = 888,
|
||||
)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited)
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aFailureContentState()
|
||||
),
|
||||
aJoinRoomState(
|
||||
contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias())
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId)
|
||||
fun aFailureContentState(
|
||||
roomIdOrAlias: RoomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias()
|
||||
): ContentState {
|
||||
return ContentState.Failure(
|
||||
roomIdOrAlias = roomIdOrAlias,
|
||||
error = Exception("Error"),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId)
|
||||
fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId.toRoomIdOrAlias())
|
||||
|
||||
fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId.toRoomIdOrAlias())
|
||||
|
||||
fun aLoadedContentState(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String = "Element X android",
|
||||
alias: String? = "#exa:matrix.org",
|
||||
alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
|
||||
topic: String? = "Element X is a secure, private and decentralized messenger.",
|
||||
numberOfMembers: Long? = null,
|
||||
isDirect: Boolean = false,
|
||||
@@ -77,3 +103,4 @@ fun aJoinRoomState(
|
||||
)
|
||||
|
||||
private val A_ROOM_ID = RoomId("!exa:matrix.org")
|
||||
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")
|
||||
|
||||
@@ -16,42 +16,36 @@
|
||||
|
||||
package io.element.android.features.joinroom.impl
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
@@ -71,7 +65,7 @@ fun JoinRoomView(
|
||||
},
|
||||
footer = {
|
||||
JoinRoomFooter(
|
||||
joinAuthorisationStatus = state.joinAuthorisationStatus,
|
||||
state = state,
|
||||
onAcceptInvite = {
|
||||
state.eventSink(JoinRoomEvents.AcceptInvite)
|
||||
},
|
||||
@@ -81,6 +75,9 @@ fun JoinRoomView(
|
||||
onJoinRoom = {
|
||||
state.eventSink(JoinRoomEvents.JoinRoom)
|
||||
},
|
||||
onRetry = {
|
||||
state.eventSink(JoinRoomEvents.RetryFetchingContent)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -88,46 +85,57 @@ fun JoinRoomView(
|
||||
|
||||
@Composable
|
||||
private fun JoinRoomFooter(
|
||||
joinAuthorisationStatus: JoinAuthorisationStatus,
|
||||
state: JoinRoomState,
|
||||
onAcceptInvite: () -> Unit,
|
||||
onDeclineInvite: () -> Unit,
|
||||
onJoinRoom: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (joinAuthorisationStatus) {
|
||||
JoinAuthorisationStatus.IsInvited -> {
|
||||
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
OutlinedButton(
|
||||
text = stringResource(CommonStrings.action_decline),
|
||||
onClick = onDeclineInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
if (state.contentState is ContentState.Failure) {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_retry),
|
||||
onClick = onRetry,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
} else {
|
||||
val joinAuthorisationStatus = state.joinAuthorisationStatus
|
||||
when (joinAuthorisationStatus) {
|
||||
JoinAuthorisationStatus.IsInvited -> {
|
||||
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
OutlinedButton(
|
||||
text = stringResource(CommonStrings.action_decline),
|
||||
onClick = onDeclineInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_accept),
|
||||
onClick = onAcceptInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
JoinAuthorisationStatus.CanJoin -> {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_accept),
|
||||
onClick = onAcceptInvite,
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(R.string.screen_join_room_join_action),
|
||||
onClick = onJoinRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
JoinAuthorisationStatus.CanKnock -> {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_join_room_knock_action),
|
||||
onClick = onJoinRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
JoinAuthorisationStatus.Unknown -> Unit
|
||||
}
|
||||
JoinAuthorisationStatus.CanJoin -> {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_join_room_join_action),
|
||||
onClick = onJoinRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
JoinAuthorisationStatus.CanKnock -> {
|
||||
Button(
|
||||
text = stringResource(R.string.screen_join_room_knock_action),
|
||||
onClick = onJoinRoom,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
JoinAuthorisationStatus.Unknown -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,43 +146,43 @@ private fun JoinRoomContent(
|
||||
) {
|
||||
when (contentState) {
|
||||
is ContentState.Loaded -> {
|
||||
ContentScaffold(
|
||||
RoomPreviewOrganism(
|
||||
modifier = modifier,
|
||||
avatar = {
|
||||
Avatar(contentState.avatarData(AvatarSize.RoomHeader))
|
||||
},
|
||||
title = {
|
||||
Title(contentState.computedTitle)
|
||||
RoomPreviewTitleAtom(contentState.computedTitle)
|
||||
},
|
||||
subtitle = {
|
||||
Subtitle(contentState.computedSubtitle)
|
||||
RoomPreviewSubtitleAtom(contentState.computedSubtitle)
|
||||
},
|
||||
description = {
|
||||
Description(contentState.topic ?: "")
|
||||
RoomPreviewDescriptionAtom(contentState.topic ?: "")
|
||||
},
|
||||
memberCount = {
|
||||
if (contentState.showMemberCount) {
|
||||
MembersCount(memberCount = contentState.numberOfMembers ?: 0)
|
||||
RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is ContentState.UnknownRoom -> {
|
||||
ContentScaffold(
|
||||
RoomPreviewOrganism(
|
||||
modifier = modifier,
|
||||
avatar = {
|
||||
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
|
||||
},
|
||||
title = {
|
||||
Title(stringResource(R.string.screen_join_room_title_no_preview))
|
||||
RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
|
||||
},
|
||||
subtitle = {
|
||||
Subtitle(stringResource(R.string.screen_join_room_subtitle_no_preview))
|
||||
RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview))
|
||||
},
|
||||
)
|
||||
}
|
||||
is ContentState.Loading -> {
|
||||
ContentScaffold(
|
||||
RoomPreviewOrganism(
|
||||
modifier = modifier,
|
||||
avatar = {
|
||||
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
|
||||
@@ -187,94 +195,31 @@ private fun JoinRoomContent(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentScaffold(
|
||||
avatar: @Composable () -> Unit,
|
||||
title: @Composable () -> Unit,
|
||||
subtitle: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
description: @Composable (() -> Unit)? = null,
|
||||
memberCount: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
avatar()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
title()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
subtitle()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (memberCount != null) {
|
||||
memberCount()
|
||||
is ContentState.Failure -> {
|
||||
RoomPreviewOrganism(
|
||||
modifier = modifier,
|
||||
avatar = {
|
||||
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
|
||||
},
|
||||
title = {
|
||||
when (contentState.roomIdOrAlias) {
|
||||
is RoomIdOrAlias.Alias -> {
|
||||
RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier)
|
||||
}
|
||||
is RoomIdOrAlias.Id -> {
|
||||
PlaceholderAtom(width = 200.dp, height = 22.dp)
|
||||
}
|
||||
}
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "Failed to get information about the room",
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (description != null) {
|
||||
description()
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Title(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Subtitle(subtitle: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Description(description: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
modifier = modifier,
|
||||
text = description,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
textAlign = TextAlign.Center,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MembersCount(memberCount: Long) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
|
||||
.widthIn(min = 48.dp)
|
||||
.padding(all = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.UserProfile(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
)
|
||||
Text(
|
||||
text = "$memberCount",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +236,7 @@ private fun JoinRoomTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
|
||||
JoinRoomView(
|
||||
|
||||
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import java.util.Optional
|
||||
|
||||
@Module
|
||||
@@ -37,9 +38,14 @@ object JoinRoomModule {
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
): JoinRoomPresenter.Factory {
|
||||
return object : JoinRoomPresenter.Factory {
|
||||
override fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter {
|
||||
override fun create(
|
||||
roomId: RoomId,
|
||||
roomIdOrAlias: RoomIdOrAlias,
|
||||
roomDescription: Optional<RoomDescription>,
|
||||
): JoinRoomPresenter {
|
||||
return JoinRoomPresenter(
|
||||
roomId = roomId,
|
||||
roomIdOrAlias = roomIdOrAlias,
|
||||
roomDescription = roomDescription,
|
||||
matrixClient = client,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
|
||||
@@ -23,8 +23,12 @@ import io.element.android.features.invite.api.response.anAcceptDeclineInviteStat
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -49,9 +53,10 @@ class JoinRoomPresenterTest {
|
||||
val presenter = createJoinRoomPresenter()
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID))
|
||||
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
|
||||
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
|
||||
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +242,110 @@ class JoinRoomPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is not known RoomPreview is loaded`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
getRoomPreviewResult = {
|
||||
Result.success(
|
||||
RoomPreview(
|
||||
roomId = A_ROOM_ID,
|
||||
canonicalAlias = RoomAlias("#alias:matrix.org"),
|
||||
name = "Room name",
|
||||
topic = "Room topic",
|
||||
avatarUrl = "avatarUrl",
|
||||
numberOfJoinedMembers = 2,
|
||||
roomType = null,
|
||||
isHistoryWorldReadable = false,
|
||||
isJoined = false,
|
||||
isInvited = false,
|
||||
isPublic = true,
|
||||
canKnock = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = client
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(
|
||||
ContentState.Loaded(
|
||||
roomId = A_ROOM_ID,
|
||||
name = "Room name",
|
||||
topic = "Room topic",
|
||||
alias = RoomAlias("#alias:matrix.org"),
|
||||
numberOfMembers = 2,
|
||||
isDirect = false,
|
||||
roomAvatarUrl = "avatarUrl",
|
||||
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is not known RoomPreview is loaded with error`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
getRoomPreviewResult = {
|
||||
Result.failure(AN_EXCEPTION)
|
||||
}
|
||||
)
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = client
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(
|
||||
ContentState.Failure(
|
||||
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
|
||||
error = AN_EXCEPTION
|
||||
)
|
||||
)
|
||||
state.eventSink(JoinRoomEvents.RetryFetchingContent)
|
||||
}
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(
|
||||
ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias())
|
||||
)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(
|
||||
ContentState.Failure(
|
||||
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
|
||||
error = AN_EXCEPTION
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
getRoomPreviewResult = {
|
||||
Result.failure(Exception("403"))
|
||||
}
|
||||
)
|
||||
val presenter = createJoinRoomPresenter(
|
||||
matrixClient = client
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(1)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.contentState).isEqualTo(
|
||||
ContentState.UnknownRoom(
|
||||
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createJoinRoomPresenter(
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
roomDescription: Optional<RoomDescription> = Optional.empty(),
|
||||
@@ -245,6 +354,7 @@ class JoinRoomPresenterTest {
|
||||
): JoinRoomPresenter {
|
||||
return JoinRoomPresenter(
|
||||
roomId = roomId,
|
||||
roomIdOrAlias = roomId.toRoomIdOrAlias(),
|
||||
roomDescription = roomDescription,
|
||||
matrixClient = matrixClient,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
|
||||
@@ -255,7 +365,7 @@ class JoinRoomPresenterTest {
|
||||
roomId: RoomId = A_ROOM_ID,
|
||||
name: String? = A_ROOM_NAME,
|
||||
topic: String? = "A room about something",
|
||||
alias: String? = "#alias:matrix.org",
|
||||
alias: RoomAlias? = RoomAlias("#alias:matrix.org"),
|
||||
avatarUrl: String? = null,
|
||||
joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
|
||||
numberOfMembers: Long = 2L
|
||||
|
||||
@@ -20,19 +20,28 @@ import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
|
||||
interface MessagesEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
callback: Callback,
|
||||
): Node
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val focusedEventId: EventId?,
|
||||
)
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onRoomDetailsClicked()
|
||||
fun onUserDataClicked(userId: UserId)
|
||||
fun onPermalinkClicked(data: PermalinkData)
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.messages.api.MessagesEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -26,11 +27,23 @@ import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
callback: MessagesEntryPoint.Callback
|
||||
): Node {
|
||||
return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback))
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : MessagesEntryPoint.NodeBuilder {
|
||||
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
|
||||
plugins += MessagesNode.Inputs(focusedEventId = params.focusedEventId)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<MessagesFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
@@ -62,6 +63,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
@@ -79,7 +81,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
overlay = Overlay(
|
||||
@@ -88,12 +90,16 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Empty : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Messages : NavTarget
|
||||
data class Messages(
|
||||
val focusedEventId: EventId? = null,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
@@ -149,6 +155,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
callback?.onUserDataClicked(userId)
|
||||
}
|
||||
|
||||
override fun onPermalinkClicked(data: PermalinkData) {
|
||||
callback?.onPermalinkClicked(data)
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
|
||||
}
|
||||
@@ -181,7 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
ElementCallActivity.start(context, inputs)
|
||||
}
|
||||
}
|
||||
createNode<MessagesNode>(buildContext, listOf(callback))
|
||||
val params = MessagesNode.Inputs(
|
||||
focusedEventId = navTarget.focusedEventId,
|
||||
)
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, params))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val inputs = MediaViewerNode.Inputs(
|
||||
|
||||
@@ -34,7 +34,10 @@ import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPr
|
||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
|
||||
import io.element.android.libraries.androidutils.system.toast
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -42,6 +45,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.alias.matches
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -58,15 +62,21 @@ class MessagesNode @AssistedInject constructor(
|
||||
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
private val presenter = presenterFactory.create(this)
|
||||
private val callback = plugins<Callback>().firstOrNull()
|
||||
|
||||
// TODO Handle navigation to the Event
|
||||
data class Inputs(val focusedEventId: EventId?) : NodeInputs
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onRoomDetailsClicked()
|
||||
fun onEventClicked(event: TimelineItem.Event): Boolean
|
||||
fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
|
||||
fun onUserDataClicked(userId: UserId)
|
||||
fun onPermalinkClicked(data: PermalinkData)
|
||||
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
|
||||
fun onForwardEventClicked(eventId: EventId)
|
||||
fun onReportMessage(eventId: EventId, senderId: UserId)
|
||||
@@ -109,16 +119,12 @@ class MessagesNode @AssistedInject constructor(
|
||||
) {
|
||||
when (val permalink = permalinkParser.parse(url)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
// Open the room member profile, it will fallback to
|
||||
// the user profile if the user is not in the room
|
||||
callback?.onUserDataClicked(permalink.userId)
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
// TODO Implement room link handling
|
||||
}
|
||||
is PermalinkData.EventIdAliasLink -> {
|
||||
// TODO Implement room and Event link handling
|
||||
}
|
||||
is PermalinkData.EventIdLink -> {
|
||||
// TODO Implement room and Event link handling
|
||||
handleRoomLinkClicked(permalink)
|
||||
}
|
||||
is PermalinkData.FallbackLink,
|
||||
is PermalinkData.RoomEmailInviteLink -> {
|
||||
@@ -127,6 +133,20 @@ class MessagesNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink) {
|
||||
if (room.matches(roomLink.roomIdOrAlias)) {
|
||||
if (roomLink.eventId != null) {
|
||||
// TODO Handle navigation to the Event
|
||||
context.toast("TODO Handle navigation to the Event ${roomLink.eventId}")
|
||||
} else {
|
||||
// Click on the same room, ignore
|
||||
context.toast("Already viewing this room!")
|
||||
}
|
||||
} else {
|
||||
callback?.onPermalinkClicked(roomLink)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
|
||||
callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||
|
||||
private fun MatrixRoomInfo.avatarData(): AvatarData {
|
||||
return AvatarData(
|
||||
id = id,
|
||||
id = id.value,
|
||||
name = name,
|
||||
url = avatarUrl ?: room.avatarUrl,
|
||||
size = AvatarSize.TimelineRoom
|
||||
|
||||
@@ -720,7 +720,7 @@ class MessagesPresenterTest {
|
||||
private fun TestScope.createMessagesPresenter(
|
||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
|
||||
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
|
||||
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
||||
},
|
||||
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
|
||||
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
|
||||
|
||||
@@ -201,7 +201,7 @@ class TypingNotificationPresenterTest {
|
||||
|
||||
private fun createPresenter(
|
||||
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
|
||||
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
|
||||
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
|
||||
},
|
||||
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(
|
||||
isRenderTypingNotificationsEnabled = true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -19,11 +19,10 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.invite.test"
|
||||
namespace = "io.element.android.features.roomaliasresolver.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
api(projects.features.invite.api)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,25 +14,30 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.api
|
||||
package io.element.android.features.roomaliasesolver.api
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
interface InviteListEntryPoint : FeatureEntryPoint {
|
||||
interface RoomAliasResolverEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun params(params: Params): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onBackClicked()
|
||||
fun onInviteClicked(roomId: RoomId)
|
||||
fun onInviteAccepted(roomId: RoomId)
|
||||
fun onAliasResolved(roomId: RoomId)
|
||||
}
|
||||
|
||||
data class Params(
|
||||
val roomAlias: RoomAlias
|
||||
) : NodeInputs
|
||||
}
|
||||
53
features/roomaliasresolver/impl/build.gradle.kts
Normal file
53
features/roomaliasresolver/impl/build.gradle.kts
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.roomaliasresolver.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
api(projects.features.roomaliasresolver.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,31 +14,35 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.invite.api.InviteListEntryPoint
|
||||
import io.element.android.features.invite.impl.invitelist.InviteListNode
|
||||
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder {
|
||||
class DefaultRoomAliasResolverEntryPoint @Inject constructor() : RoomAliasResolverEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomAliasResolverEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : InviteListEntryPoint.NodeBuilder {
|
||||
override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder {
|
||||
return object : RoomAliasResolverEntryPoint.NodeBuilder {
|
||||
override fun callback(callback: RoomAliasResolverEntryPoint.Callback): RoomAliasResolverEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun params(params: RoomAliasResolverEntryPoint.Params): RoomAliasResolverEntryPoint.NodeBuilder {
|
||||
plugins += params
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<InviteListNode>(buildContext, plugins)
|
||||
return parentNode.createNode<RoomAliasResolverNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,12 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.datasource
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.features.roomlist.impl.InvitesState
|
||||
|
||||
interface InviteStateDataSource {
|
||||
@Composable
|
||||
fun inviteState(): InvitesState
|
||||
sealed interface RoomAliasResolverEvents {
|
||||
data object Retry : RoomAliasResolverEvents
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -25,37 +25,35 @@ 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.invite.api.InviteListEntryPoint
|
||||
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class InviteListNode @AssistedInject constructor(
|
||||
class RoomAliasResolverNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: InviteListPresenter,
|
||||
presenterFactory: RoomAliasResolverPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private fun onBackClicked() {
|
||||
plugins<InviteListEntryPoint.Callback>().forEach { it.onBackClicked() }
|
||||
}
|
||||
private val inputs = inputs<RoomAliasResolverEntryPoint.Params>()
|
||||
|
||||
private fun onInviteAccepted(roomId: RoomId) {
|
||||
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) }
|
||||
}
|
||||
private val presenter = presenterFactory.create(
|
||||
inputs.roomAlias
|
||||
)
|
||||
|
||||
private fun onInviteClicked(roomId: RoomId) {
|
||||
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteClicked(roomId) }
|
||||
private fun onAliasResolved(roomId: RoomId) {
|
||||
plugins<RoomAliasResolverEntryPoint.Callback>().forEach { it.onAliasResolved(roomId) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
InviteListView(
|
||||
RoomAliasResolverView(
|
||||
state = state,
|
||||
onBackClicked = ::onBackClicked,
|
||||
onInviteAccepted = ::onInviteAccepted,
|
||||
onInviteDeclined = {},
|
||||
onInviteClicked = ::onInviteClicked,
|
||||
onAliasResolved = ::onAliasResolved,
|
||||
onBackPressed = ::navigateUp,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.AssistedInject
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RoomAliasResolverPresenter @AssistedInject constructor(
|
||||
@Assisted private val roomAlias: RoomAlias,
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<RoomAliasResolverState> {
|
||||
interface Factory {
|
||||
fun create(
|
||||
roomAlias: RoomAlias,
|
||||
): RoomAliasResolverPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomAliasResolverState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val resolveState: MutableState<AsyncData<RoomId>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
LaunchedEffect(Unit) {
|
||||
resolveAlias(resolveState)
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomAliasResolverEvents) {
|
||||
when (event) {
|
||||
RoomAliasResolverEvents.Retry -> coroutineScope.resolveAlias(resolveState)
|
||||
}
|
||||
}
|
||||
|
||||
return RoomAliasResolverState(
|
||||
roomAlias = roomAlias,
|
||||
resolveState = resolveState.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.resolveAlias(resolveState: MutableState<AsyncData<RoomId>>) = launch {
|
||||
suspend {
|
||||
matrixClient.resolveRoomAlias(roomAlias).getOrThrow()
|
||||
}.runCatchingUpdatingState(resolveState)
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.impl.invitelist
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.impl.model.InviteListInviteSummary
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
@Immutable
|
||||
data class InviteListState(
|
||||
val inviteList: ImmutableList<InviteListInviteSummary>,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val eventSink: (InviteListEvents) -> Unit
|
||||
data class RoomAliasResolverState(
|
||||
val roomAlias: RoomAlias,
|
||||
val resolveState: AsyncData<RoomId>,
|
||||
val eventSink: (RoomAliasResolverEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
open class RoomAliasResolverStateProvider : PreviewParameterProvider<RoomAliasResolverState> {
|
||||
override val values: Sequence<RoomAliasResolverState>
|
||||
get() = sequenceOf(
|
||||
aRoomAliasResolverState(),
|
||||
aRoomAliasResolverState(
|
||||
resolveState = AsyncData.Loading(),
|
||||
),
|
||||
aRoomAliasResolverState(
|
||||
resolveState = AsyncData.Failure(Exception("Error")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomAliasResolverState(
|
||||
roomAlias: RoomAlias = A_ROOM_ALIAS,
|
||||
resolveState: AsyncData<RoomId> = AsyncData.Uninitialized,
|
||||
eventSink: (RoomAliasResolverEvents) -> Unit = {}
|
||||
) = RoomAliasResolverState(
|
||||
roomAlias = roomAlias,
|
||||
resolveState = resolveState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun RoomAliasResolverView(
|
||||
state: RoomAliasResolverState,
|
||||
onBackPressed: () -> Unit,
|
||||
onAliasResolved: (RoomId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val latestOnAliasResolved by rememberUpdatedState(onAliasResolved)
|
||||
LaunchedEffect(state.resolveState) {
|
||||
if (state.resolveState is AsyncData.Success) {
|
||||
latestOnAliasResolved(state.resolveState.data)
|
||||
}
|
||||
}
|
||||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
paddingValues = PaddingValues(16.dp),
|
||||
topBar = {
|
||||
RoomAliasResolverTopBar(onBackClicked = onBackPressed)
|
||||
},
|
||||
content = {
|
||||
RoomAliasResolverContent(state = state)
|
||||
},
|
||||
footer = {
|
||||
RoomAliasResolverFooter(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomAliasResolverFooter(
|
||||
state: RoomAliasResolverState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state.resolveState) {
|
||||
is AsyncData.Failure -> {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_retry),
|
||||
onClick = {
|
||||
state.eventSink(RoomAliasResolverEvents.Retry)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
size = ButtonSize.Medium,
|
||||
)
|
||||
}
|
||||
is AsyncData.Loading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
AsyncData.Uninitialized,
|
||||
is AsyncData.Success -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomAliasResolverContent(
|
||||
state: RoomAliasResolverState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
RoomPreviewOrganism(
|
||||
modifier = modifier,
|
||||
avatar = {
|
||||
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
|
||||
},
|
||||
title = {
|
||||
RoomPreviewTitleAtom(state.roomAlias.value)
|
||||
},
|
||||
subtitle = {
|
||||
},
|
||||
description = {
|
||||
if (state.resolveState.isFailure()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_room_alias_resolver_resolve_alias_failure),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
memberCount = {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoomAliasResolverTopBar(
|
||||
onBackClicked: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackClicked)
|
||||
},
|
||||
title = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview {
|
||||
RoomAliasResolverView(
|
||||
state = state,
|
||||
onAliasResolved = { },
|
||||
onBackPressed = { }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomaliasresolver.impl.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.features.roomaliasresolver.impl.RoomAliasResolverPresenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
||||
@Module
|
||||
@ContributesTo(SessionScope::class)
|
||||
object RoomAliasResolverModule {
|
||||
@Provides
|
||||
fun providesJoinRoomPresenterFactory(
|
||||
client: MatrixClient,
|
||||
): RoomAliasResolverPresenter.Factory {
|
||||
return object : RoomAliasResolverPresenter.Factory {
|
||||
override fun create(roomAlias: RoomAlias): RoomAliasResolverPresenter {
|
||||
return RoomAliasResolverPresenter(
|
||||
roomAlias = roomAlias,
|
||||
matrixClient = client,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_room_alias_resolver_resolve_alias_failure">"Failed to resolve room alias."</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomaliasresolver.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class RoomAliasResolverPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - resolve alias to roomId`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
resolveRoomAliasResult = { Result.success(A_ROOM_ID) }
|
||||
)
|
||||
val presenter = createPresenter(matrixClient = client)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
|
||||
assertThat(awaitItem().resolveState.isLoading()).isTrue()
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS)
|
||||
assertThat(resultState.resolveState.dataOrNull()).isEqualTo(A_ROOM_ID)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - resolve alias error and retry`() = runTest {
|
||||
val client = FakeMatrixClient(
|
||||
resolveRoomAliasResult = { Result.failure(AN_EXCEPTION) }
|
||||
)
|
||||
val presenter = createPresenter(matrixClient = client)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
assertThat(awaitItem().resolveState.isUninitialized()).isTrue()
|
||||
assertThat(awaitItem().resolveState.isLoading()).isTrue()
|
||||
val resultState = awaitItem()
|
||||
assertThat(resultState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION)
|
||||
resultState.eventSink(RoomAliasResolverEvents.Retry)
|
||||
val retryLoadingState = awaitItem()
|
||||
assertThat(retryLoadingState.resolveState.isLoading()).isTrue()
|
||||
val retryState = awaitItem()
|
||||
assertThat(retryState.resolveState.errorOrNull()).isEqualTo(AN_EXCEPTION)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
roomAlias: RoomAlias = A_ROOM_ALIAS,
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
) = RoomAliasResolverPresenter(
|
||||
roomAlias = roomAlias,
|
||||
matrixClient = matrixClient,
|
||||
)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
|
||||
|
||||
return RoomDetailsState(
|
||||
roomId = room.roomId.value,
|
||||
roomId = room.roomId,
|
||||
roomName = roomName,
|
||||
roomAlias = room.alias,
|
||||
roomAvatarUrl = roomAvatar,
|
||||
|
||||
@@ -18,13 +18,15 @@ package io.element.android.features.roomdetails.impl
|
||||
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
|
||||
data class RoomDetailsState(
|
||||
val roomId: String,
|
||||
val roomId: RoomId,
|
||||
val roomName: String,
|
||||
val roomAlias: String?,
|
||||
val roomAlias: RoomAlias?,
|
||||
val roomAvatarUrl: String?,
|
||||
val roomTopic: RoomTopicState,
|
||||
val memberCount: Long,
|
||||
|
||||
@@ -21,6 +21,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
|
||||
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
@@ -71,9 +73,9 @@ fun aDmRoomMember(
|
||||
)
|
||||
|
||||
fun aRoomDetailsState(
|
||||
roomId: String = "a room id",
|
||||
roomId: RoomId = RoomId("!aRoomId:domain.com"),
|
||||
roomName: String = "Marketing",
|
||||
roomAlias: String? = "#marketing:domain.com",
|
||||
roomAlias: RoomAlias? = RoomAlias("#marketing:domain.com"),
|
||||
roomAvatarUrl: String? = null,
|
||||
roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
|
||||
"Welcome to #marketing, home of the Marketing team " +
|
||||
|
||||
@@ -78,6 +78,8 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.getBestName
|
||||
@@ -302,9 +304,9 @@ private fun MainActionsSection(
|
||||
@Composable
|
||||
private fun RoomHeaderSection(
|
||||
avatarUrl: String?,
|
||||
roomId: String,
|
||||
roomId: RoomId,
|
||||
roomName: String,
|
||||
roomAlias: String?,
|
||||
roomAlias: RoomAlias?,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -314,7 +316,7 @@ private fun RoomHeaderSection(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader),
|
||||
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
|
||||
modifier = Modifier
|
||||
.size(70.dp)
|
||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
@@ -329,7 +331,7 @@ private fun RoomHeaderSection(
|
||||
if (roomAlias != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = roomAlias,
|
||||
text = roomAlias.value,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
textAlign = TextAlign.Center,
|
||||
|
||||
@@ -37,8 +37,13 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
@@ -56,20 +61,24 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
|
||||
val roomMember by room.getRoomMemberAsState(roomMemberId)
|
||||
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
|
||||
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
// the room member is not really live...
|
||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember(roomMember) {
|
||||
val isIgnored = roomMember?.isIgnored
|
||||
if (isIgnored == null) {
|
||||
mutableStateOf(AsyncData.Uninitialized)
|
||||
} else {
|
||||
mutableStateOf(AsyncData.Success(isIgnored))
|
||||
}
|
||||
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
|
||||
LaunchedEffect(Unit) {
|
||||
client.ignoredUsersFlow
|
||||
.map { ignoredUsers -> roomMemberId in ignoredUsers }
|
||||
.distinctUntilChanged()
|
||||
.onEach { isBlocked.value = AsyncData.Success(it) }
|
||||
.launchIn(this)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
// Update room member info when opening this screen
|
||||
// We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState`
|
||||
room.getUpdatedMember(roomMemberId)
|
||||
.onFailure {
|
||||
// Not a member of the room, try to get the user profile
|
||||
userProfile = client.getProfile(roomMemberId).getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomMemberDetailsEvents) {
|
||||
@@ -105,16 +114,34 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val userName by produceState(initialValue = roomMember?.displayName) {
|
||||
room.userDisplayName(roomMemberId).onSuccess { displayName ->
|
||||
if (displayName != null) value = displayName
|
||||
}
|
||||
val userName: String? by produceState(
|
||||
initialValue = roomMember?.displayName ?: userProfile?.displayName,
|
||||
key1 = roomMember,
|
||||
key2 = userProfile,
|
||||
) {
|
||||
value = room.userDisplayName(roomMemberId)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
// Fallback to user profile
|
||||
userProfile?.displayName
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val userAvatar by produceState(initialValue = roomMember?.avatarUrl) {
|
||||
room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl ->
|
||||
if (avatarUrl != null) value = avatarUrl
|
||||
}
|
||||
val userAvatar: String? by produceState(
|
||||
initialValue = roomMember?.avatarUrl ?: userProfile?.avatarUrl,
|
||||
key1 = roomMember,
|
||||
key2 = userProfile,
|
||||
) {
|
||||
value = room.userAvatarUrl(roomMemberId)
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
// Fallback to user profile
|
||||
userProfile?.avatarUrl
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return RoomMemberDetailsState(
|
||||
@@ -124,7 +151,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
isBlocked = isBlocked.value,
|
||||
startDmActionState = startDmActionState.value,
|
||||
displayConfirmationDialog = confirmationDialog,
|
||||
isCurrentUser = client.isMe(roomMember?.userId),
|
||||
isCurrentUser = client.isMe(roomMemberId),
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
@@ -132,28 +159,18 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch {
|
||||
isBlockedState.value = AsyncData.Loading(false)
|
||||
client.ignoreUser(userId)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
isBlockedState.value = AsyncData.Success(true)
|
||||
room.getUpdatedMember(userId)
|
||||
},
|
||||
onFailure = {
|
||||
isBlockedState.value = AsyncData.Failure(it, false)
|
||||
}
|
||||
)
|
||||
.onFailure {
|
||||
isBlockedState.value = AsyncData.Failure(it, false)
|
||||
}
|
||||
// Note: on success, ignoredUserList will be updated.
|
||||
}
|
||||
|
||||
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch {
|
||||
isBlockedState.value = AsyncData.Loading(true)
|
||||
client.unignoreUser(userId)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
isBlockedState.value = AsyncData.Success(false)
|
||||
room.getUpdatedMember(userId)
|
||||
},
|
||||
onFailure = {
|
||||
isBlockedState.value = AsyncData.Failure(it, true)
|
||||
}
|
||||
)
|
||||
.onFailure {
|
||||
isBlockedState.value = AsyncData.Failure(it, true)
|
||||
}
|
||||
// Note: on success, ignoredUserList will be updated.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ class RoomDetailsPresenterTests {
|
||||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId.value)
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||
assertThat(initialState.roomName).isEqualTo(room.name)
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
|
||||
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.roomdetails.members.details
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.createroom.api.StartDMAction
|
||||
@@ -30,12 +31,13 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -58,12 +60,12 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
val presenter = createRoomMemberDetailsPresenter(
|
||||
room = room,
|
||||
roomMember = roomMember
|
||||
roomMemberId = roomMember.userId
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.userId).isEqualTo(roomMember.userId.value)
|
||||
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
@@ -85,12 +87,12 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
val presenter = createRoomMemberDetailsPresenter(
|
||||
room = room,
|
||||
roomMember = roomMember
|
||||
roomMemberId = roomMember.userId
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
|
||||
@@ -108,12 +110,12 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
val presenter = createRoomMemberDetailsPresenter(
|
||||
room = room,
|
||||
roomMember = roomMember
|
||||
roomMemberId = roomMember.userId
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
|
||||
@@ -121,13 +123,40 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - will fallback to user profile if user is not a member of the room`() = runTest {
|
||||
val bobProfile = aMatrixUser("@bob:server.org", "Bob", avatarUrl = "anAvatarUrl")
|
||||
val room = aMatrixRoom().apply {
|
||||
givenUserDisplayNameResult(Result.failure(Exception("Not a member!")))
|
||||
givenUserAvatarUrlResult(Result.failure(Exception("Not a member!")))
|
||||
}
|
||||
val client = FakeMatrixClient().apply {
|
||||
givenGetProfileResult(bobProfile.userId, Result.success(bobProfile))
|
||||
}
|
||||
val presenter = createRoomMemberDetailsPresenter(
|
||||
client = client,
|
||||
room = room,
|
||||
roomMemberId = UserId("@bob:server.org")
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(2)
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.userName).isEqualTo("Bob")
|
||||
assertThat(initialState.avatarUrl).isEqualTo("anAvatarUrl")
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
|
||||
val presenter = createRoomMemberDetailsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true))
|
||||
|
||||
val dialogState = awaitItem()
|
||||
@@ -142,17 +171,24 @@ class RoomMemberDetailsPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
|
||||
val presenter = createRoomMemberDetailsPresenter()
|
||||
val client = FakeMatrixClient()
|
||||
val roomMember = aRoomMember()
|
||||
val presenter = createRoomMemberDetailsPresenter(
|
||||
client = client,
|
||||
roomMemberId = roomMember.userId
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
|
||||
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
|
||||
client.emitIgnoreUserList(listOf(roomMember.userId))
|
||||
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
|
||||
|
||||
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
|
||||
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
|
||||
client.emitIgnoreUserList(listOf())
|
||||
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -165,7 +201,7 @@ class RoomMemberDetailsPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
|
||||
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
|
||||
val errorState = awaitItem()
|
||||
@@ -176,13 +212,32 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UnblockUser with error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE))
|
||||
val presenter = createRoomMemberDetailsPresenter(client = matrixClient)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
|
||||
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
|
||||
// Clear error
|
||||
initialState.eventSink(RoomMemberDetailsEvents.ClearBlockUserError)
|
||||
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
|
||||
val presenter = createRoomMemberDetailsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true))
|
||||
|
||||
val dialogState = awaitItem()
|
||||
@@ -202,7 +257,7 @@ class RoomMemberDetailsPresenterTests {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
|
||||
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE)
|
||||
@@ -229,14 +284,19 @@ class RoomMemberDetailsPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
|
||||
private fun createRoomMemberDetailsPresenter(
|
||||
client: MatrixClient = FakeMatrixClient(),
|
||||
room: MatrixRoom = aMatrixRoom(),
|
||||
roomMember: RoomMember = aRoomMember(),
|
||||
roomMemberId: UserId = UserId("@alice:server.org"),
|
||||
startDMAction: StartDMAction = FakeStartDMAction()
|
||||
): RoomMemberDetailsPresenter {
|
||||
return RoomMemberDetailsPresenter(
|
||||
roomMemberId = roomMember.userId,
|
||||
roomMemberId = roomMemberId,
|
||||
client = client,
|
||||
room = room,
|
||||
startDMAction = startDMAction
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -29,7 +30,7 @@ import kotlinx.parcelize.Parcelize
|
||||
data class RoomDescription(
|
||||
val roomId: RoomId,
|
||||
val name: String?,
|
||||
val alias: String?,
|
||||
val alias: RoomAlias?,
|
||||
val topic: String?,
|
||||
val avatarUrl: String?,
|
||||
val joinRule: JoinRule,
|
||||
@@ -42,14 +43,14 @@ data class RoomDescription(
|
||||
}
|
||||
|
||||
@IgnoredOnParcel
|
||||
val computedName = name ?: alias ?: roomId.value
|
||||
val computedName = name ?: alias?.value ?: roomId.value
|
||||
|
||||
@IgnoredOnParcel
|
||||
val computedDescription: String
|
||||
get() {
|
||||
return when {
|
||||
topic != null -> topic
|
||||
name != null && alias != null -> alias
|
||||
name != null && alias != null -> alias.value
|
||||
name == null && alias == null -> ""
|
||||
else -> roomId.value
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ package io.element.android.features.roomdirectory.impl.root
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -69,7 +70,7 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
|
||||
roomId = RoomId("!exa:matrix.org"),
|
||||
name = "Element X Android",
|
||||
topic = "Element X is a secure, private and decentralized messenger.",
|
||||
alias = "#element-x-android:matrix.org",
|
||||
alias = RoomAlias("#element-x-android:matrix.org"),
|
||||
avatarUrl = null,
|
||||
joinRule = RoomDescription.JoinRule.PUBLIC,
|
||||
numberOfMembers = 2765,
|
||||
@@ -78,7 +79,7 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
|
||||
roomId = RoomId("!exi:matrix.org"),
|
||||
name = "Element X iOS",
|
||||
topic = "Element X is a secure, private and decentralized messenger.",
|
||||
alias = "#element-x-ios:matrix.org",
|
||||
alias = RoomAlias("#element-x-ios:matrix.org"),
|
||||
avatarUrl = null,
|
||||
joinRule = RoomDescription.JoinRule.UNKNOWN,
|
||||
numberOfMembers = 356,
|
||||
|
||||
@@ -34,7 +34,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
||||
fun onCreateRoomClicked()
|
||||
fun onSettingsClicked()
|
||||
fun onSessionConfirmRecoveryKeyClicked()
|
||||
fun onInvitesClicked()
|
||||
fun onRoomSettingsClicked(roomId: RoomId)
|
||||
fun onReportBugClicked()
|
||||
fun onRoomDirectorySearchClicked()
|
||||
|
||||
@@ -75,7 +75,6 @@ dependencies {
|
||||
testImplementation(projects.libraries.indicator.impl)
|
||||
testImplementation(projects.libraries.permissions.noop)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.services.analytics.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun InvitesEntryPointView(
|
||||
onInvitesClicked: () -> Unit,
|
||||
state: InvitesState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(role = Role.Button, onClick = onInvitesClicked)
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
.align(Alignment.CenterEnd)
|
||||
.heightIn(min = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(CommonStrings.action_invites_list),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
|
||||
if (state == InvitesState.NewInvites) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
UnreadIndicatorAtom()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun InvitesEntryPointViewPreview(@PreviewParameter(InvitesStateProvider::class) state: InvitesState) = ElementPreview {
|
||||
InvitesEntryPointView(
|
||||
onInvitesClicked = {},
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
@@ -33,11 +33,9 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
|
||||
}
|
||||
|
||||
internal fun aRoomsContentState(
|
||||
invitesState: InvitesState = InvitesState.NoInvites,
|
||||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||
) = RoomListContentState.Rooms(
|
||||
invitesState = invitesState,
|
||||
securityBannerState = securityBannerState,
|
||||
summaries = summaries,
|
||||
)
|
||||
@@ -46,6 +44,4 @@ internal fun aMigrationContentState() = RoomListContentState.Migration
|
||||
|
||||
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
|
||||
|
||||
internal fun anEmptyContentState(
|
||||
invitesState: InvitesState = InvitesState.NoInvites,
|
||||
) = RoomListContentState.Empty(invitesState)
|
||||
internal fun anEmptyContentState() = RoomListContentState.Empty
|
||||
|
||||
@@ -24,6 +24,8 @@ sealed interface RoomListEvents {
|
||||
data object DismissRequestVerificationPrompt : RoomListEvents
|
||||
data object DismissRecoveryKeyPrompt : RoomListEvents
|
||||
data object ToggleSearchResults : RoomListEvents
|
||||
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
|
||||
|
||||
sealed interface ContextMenuEvents : RoomListEvents
|
||||
|
||||
@@ -29,6 +29,7 @@ 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.invite.api.response.AcceptDeclineInviteView
|
||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
|
||||
@@ -43,6 +44,7 @@ class RoomListNode @AssistedInject constructor(
|
||||
private val presenter: RoomListPresenter,
|
||||
private val inviteFriendsUseCase: InviteFriendsUseCase,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val acceptDeclineInviteView: AcceptDeclineInviteView,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
@@ -68,10 +70,6 @@ class RoomListNode @AssistedInject constructor(
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
|
||||
}
|
||||
|
||||
private fun onInvitesClicked() {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
|
||||
}
|
||||
|
||||
private fun onRoomSettingsClicked(roomId: RoomId) {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) }
|
||||
}
|
||||
@@ -101,11 +99,17 @@ class RoomListNode @AssistedInject constructor(
|
||||
onSettingsClicked = this::onOpenSettings,
|
||||
onCreateRoomClicked = this::onCreateRoomClicked,
|
||||
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
|
||||
onInvitesClicked = this::onInvitesClicked,
|
||||
onRoomSettingsClicked = this::onRoomSettingsClicked,
|
||||
onMenuActionClicked = { onMenuActionClicked(activity, it) },
|
||||
onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked,
|
||||
modifier = modifier,
|
||||
)
|
||||
) {
|
||||
acceptDeclineInviteView.Render(
|
||||
state = state.acceptDeclineInviteState,
|
||||
onInviteAccepted = this::onRoomClicked,
|
||||
onInviteDeclined = { },
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -32,15 +33,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.InviteData
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.migration.MigrationScreenState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
@@ -79,7 +83,6 @@ class RoomListPresenter @Inject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val inviteStateDataSource: InviteStateDataSource,
|
||||
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
@@ -89,6 +92,7 @@ class RoomListPresenter @Inject constructor(
|
||||
private val migrationScreenPresenter: Presenter<MigrationScreenState>,
|
||||
private val sessionPreferencesStore: SessionPreferencesStore,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService: EncryptionService = client.encryptionService()
|
||||
private val syncService: SyncService = client.syncService()
|
||||
@@ -101,6 +105,7 @@ class RoomListPresenter @Inject constructor(
|
||||
val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||
val filtersState = filtersPresenter.present()
|
||||
val searchState = searchPresenter.present()
|
||||
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
roomListDataSource.launchIn(this)
|
||||
@@ -131,6 +136,16 @@ class RoomListPresenter @Inject constructor(
|
||||
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
|
||||
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
|
||||
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
|
||||
is RoomListEvents.AcceptInvite -> {
|
||||
acceptDeclineInviteState.eventSink(
|
||||
AcceptDeclineInviteEvents.AcceptInvite(event.roomListRoomSummary.toInviteData())
|
||||
)
|
||||
}
|
||||
is RoomListEvents.DeclineInvite -> {
|
||||
acceptDeclineInviteState.eventSink(
|
||||
AcceptDeclineInviteEvents.DeclineInvite(event.roomListRoomSummary.toInviteData())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +163,7 @@ class RoomListPresenter @Inject constructor(
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
@@ -192,16 +208,11 @@ class RoomListPresenter @Inject constructor(
|
||||
}
|
||||
return when {
|
||||
showMigration -> RoomListContentState.Migration
|
||||
showEmpty -> {
|
||||
val invitesState = inviteStateDataSource.inviteState()
|
||||
RoomListContentState.Empty(invitesState)
|
||||
}
|
||||
showEmpty -> RoomListContentState.Empty
|
||||
showSkeleton -> RoomListContentState.Skeleton(count = 16)
|
||||
else -> {
|
||||
val invitesState = inviteStateDataSource.inviteState()
|
||||
val securityBannerState by securityBannerState(securityBannerDismissed)
|
||||
RoomListContentState.Rooms(
|
||||
invitesState = invitesState,
|
||||
securityBannerState = securityBannerState,
|
||||
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
|
||||
)
|
||||
@@ -283,3 +294,10 @@ class RoomListPresenter @Inject constructor(
|
||||
client.roomListService.updateAllRoomsVisibleRange(extendedRange)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun RoomListRoomSummary.toInviteData() = InviteData(
|
||||
roomId = roomId,
|
||||
roomName = name,
|
||||
isDirect = isDirect,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
@@ -37,6 +38,7 @@ data class RoomListState(
|
||||
val filtersState: RoomListFiltersState,
|
||||
val searchState: RoomListSearchState,
|
||||
val contentState: RoomListContentState,
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val eventSink: (RoomListEvents) -> Unit,
|
||||
) {
|
||||
val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
|
||||
@@ -70,9 +72,8 @@ enum class SecurityBannerState {
|
||||
sealed interface RoomListContentState {
|
||||
data object Migration : RoomListContentState
|
||||
data class Skeleton(val count: Int) : RoomListContentState
|
||||
data class Empty(val invitesState: InvitesState) : RoomListContentState
|
||||
data object Empty : RoomListContentState
|
||||
data class Rooms(
|
||||
val invitesState: InvitesState,
|
||||
val securityBannerState: SecurityBannerState,
|
||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||
) : RoomListContentState
|
||||
|
||||
@@ -17,11 +17,15 @@
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.leaveroom.api.aLeaveRoomState
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
|
||||
import io.element.android.features.roomlist.impl.model.InviteSender
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.search.RoomListSearchState
|
||||
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
|
||||
@@ -41,8 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState(),
|
||||
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
|
||||
aRoomListState(hasNetworkConnection = false),
|
||||
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
|
||||
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
|
||||
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
|
||||
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
|
||||
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
|
||||
@@ -64,6 +66,7 @@ internal fun aRoomListState(
|
||||
searchState: RoomListSearchState = aRoomListSearchState(),
|
||||
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
|
||||
contentState: RoomListContentState = aRoomsContentState(),
|
||||
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
|
||||
eventSink: (RoomListEvents) -> Unit = {}
|
||||
) = RoomListState(
|
||||
matrixUser = matrixUser,
|
||||
@@ -75,11 +78,23 @@ internal fun aRoomListState(
|
||||
filtersState = filtersState,
|
||||
searchState = searchState,
|
||||
contentState = contentState,
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
|
||||
return persistentListOf(
|
||||
aRoomListRoomSummary(
|
||||
name = "Room Invited",
|
||||
avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem),
|
||||
id = "!roomId:domain",
|
||||
inviteSender = InviteSender(
|
||||
userId = UserId("@bob:domain"),
|
||||
displayName = "Bob",
|
||||
avatarData = AvatarData("@bob:domain", "Bob", size = AvatarSize.InviteSender),
|
||||
),
|
||||
displayType = RoomSummaryDisplayType.INVITE,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = "Room",
|
||||
numberOfUnreadMessages = 1,
|
||||
@@ -98,11 +113,11 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
id = "!roomId3:domain",
|
||||
isPlaceholder = true,
|
||||
displayType = RoomSummaryDisplayType.PLACEHOLDER,
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
id = "!roomId4:domain",
|
||||
isPlaceholder = true,
|
||||
displayType = RoomSummaryDisplayType.PLACEHOLDER,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,23 +55,17 @@ fun RoomListView(
|
||||
onSettingsClicked: () -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onCreateRoomClicked: () -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
onRoomSettingsClicked: (roomId: RoomId) -> Unit,
|
||||
onMenuActionClicked: (RoomListMenuAction) -> Unit,
|
||||
onRoomDirectorySearchClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
acceptDeclineInviteView: @Composable () -> Unit,
|
||||
) {
|
||||
ConnectivityIndicatorContainer(
|
||||
modifier = modifier,
|
||||
isOnline = state.hasNetworkConnection,
|
||||
) { topPadding ->
|
||||
Box {
|
||||
fun onRoomLongClicked(
|
||||
roomListRoomSummary: RoomListRoomSummary
|
||||
) {
|
||||
state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary))
|
||||
}
|
||||
|
||||
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
|
||||
RoomListContextMenu(
|
||||
contextMenu = state.contextMenu,
|
||||
@@ -83,21 +77,19 @@ fun RoomListView(
|
||||
LeaveRoomView(state = state.leaveRoomState)
|
||||
|
||||
RoomListScaffold(
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
state = state,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||
onOpenSettings = onSettingsClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
)
|
||||
// This overlaid view will only be visible when state.displaySearchResults is true
|
||||
RoomListSearchView(
|
||||
state = state.searchState,
|
||||
eventSink = state.eventSink,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = { onRoomLongClicked(it) },
|
||||
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
@@ -105,6 +97,7 @@ fun RoomListView(
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
)
|
||||
acceptDeclineInviteView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,10 +108,8 @@ private fun RoomListScaffold(
|
||||
state: RoomListState,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onCreateRoomClicked: () -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
onMenuActionClicked: (RoomListMenuAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -153,9 +144,7 @@ private fun RoomListScaffold(
|
||||
eventSink = state.eventSink,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = ::onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
@@ -180,7 +169,7 @@ private fun RoomListScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||
internal fun RoomListRoomSummary.contentType() = displayType.ordinal
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
@@ -191,9 +180,9 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
|
||||
onSettingsClicked = {},
|
||||
onConfirmRecoveryKeyClicked = {},
|
||||
onCreateRoomClicked = {},
|
||||
onInvitesClicked = {},
|
||||
onRoomSettingsClicked = {},
|
||||
onMenuActionClicked = {},
|
||||
onRoomDirectorySearchClicked = {},
|
||||
acceptDeclineInviteView = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomlist.impl.InvitesEntryPointView
|
||||
import io.element.android.features.roomlist.impl.InvitesState
|
||||
import io.element.android.features.roomlist.impl.R
|
||||
import io.element.android.features.roomlist.impl.RoomListContentState
|
||||
import io.element.android.features.roomlist.impl.RoomListContentStateProvider
|
||||
@@ -75,9 +73,7 @@ fun RoomListContentView(
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onCreateRoomClicked: () -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -92,8 +88,6 @@ fun RoomListContentView(
|
||||
}
|
||||
is RoomListContentState.Empty -> {
|
||||
EmptyView(
|
||||
state = contentState,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
)
|
||||
}
|
||||
@@ -104,8 +98,6 @@ fun RoomListContentView(
|
||||
eventSink = eventSink,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -128,30 +120,21 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
private fun EmptyView(
|
||||
state: RoomListContentState.Empty,
|
||||
onCreateRoomClicked: () -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
EmptyScaffold(
|
||||
title = R.string.screen_roomlist_empty_title,
|
||||
subtitle = R.string.screen_roomlist_empty_message,
|
||||
action = {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_start_chat),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
|
||||
onClick = onCreateRoomClicked,
|
||||
)
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
) {
|
||||
if (state.invitesState != InvitesState.NoInvites) {
|
||||
InvitesEntryPointView(onInvitesClicked, state.invitesState)
|
||||
}
|
||||
EmptyScaffold(
|
||||
title = R.string.screen_roomlist_empty_title,
|
||||
subtitle = R.string.screen_roomlist_empty_message,
|
||||
action = {
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_start_chat),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
|
||||
onClick = onCreateRoomClicked,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -161,8 +144,6 @@ private fun RoomsView(
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
|
||||
@@ -176,8 +157,6 @@ private fun RoomsView(
|
||||
eventSink = eventSink,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -189,8 +168,6 @@ private fun RoomsViewList(
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onConfirmRecoveryKeyClicked: () -> Unit,
|
||||
onRoomClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onInvitesClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -228,11 +205,6 @@ private fun RoomsViewList(
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
if (state.invitesState != InvitesState.NoInvites) {
|
||||
item {
|
||||
InvitesEntryPointView(onInvitesClicked, state.invitesState)
|
||||
}
|
||||
}
|
||||
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
|
||||
// is moved to the top of the list.
|
||||
itemsIndexed(
|
||||
@@ -242,7 +214,7 @@ private fun RoomsViewList(
|
||||
RoomSummaryRow(
|
||||
room = room,
|
||||
onClick = onRoomClicked,
|
||||
onLongClick = onRoomLongClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
if (index != state.summaries.lastIndex) {
|
||||
HorizontalDivider()
|
||||
@@ -305,8 +277,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
|
||||
eventSink = {},
|
||||
onConfirmRecoveryKeyClicked = {},
|
||||
onRoomClicked = {},
|
||||
onRoomLongClicked = {},
|
||||
onCreateRoomClicked = {},
|
||||
onInvitesClicked = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,15 +20,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -36,26 +39,36 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomlist.impl.RoomListEvents
|
||||
import io.element.android.features.roomlist.impl.model.InviteSender
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
|
||||
import io.element.android.libraries.designsystem.theme.roomListRoomName
|
||||
import io.element.android.libraries.designsystem.theme.unreadIndicator
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import timber.log.Timber
|
||||
|
||||
internal val minHeight = 84.dp
|
||||
|
||||
@@ -63,30 +76,67 @@ internal val minHeight = 84.dp
|
||||
internal fun RoomSummaryRow(
|
||||
room: RoomListRoomSummary,
|
||||
onClick: (RoomListRoomSummary) -> Unit,
|
||||
onLongClick: (RoomListRoomSummary) -> Unit,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (room.isPlaceholder) {
|
||||
RoomSummaryPlaceholderRow(
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
RoomSummaryRealRow(
|
||||
room = room,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier = modifier
|
||||
)
|
||||
when (room.displayType) {
|
||||
RoomSummaryDisplayType.PLACEHOLDER -> {
|
||||
RoomSummaryPlaceholderRow(modifier = modifier)
|
||||
}
|
||||
RoomSummaryDisplayType.INVITE -> {
|
||||
RoomSummaryScaffoldRow(
|
||||
room = room,
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
Timber.d("Long click on invite room")
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
InviteNameAndIndicatorRow(name = room.name)
|
||||
InviteSubtitle(isDirect = room.isDirect, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
|
||||
if (!room.isDirect && room.inviteSender != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
InviteSenderRow(sender = room.inviteSender)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
InviteButtonsRow(
|
||||
onAcceptClicked = {
|
||||
eventSink(RoomListEvents.AcceptInvite(room))
|
||||
},
|
||||
onDeclineClicked = {
|
||||
eventSink(RoomListEvents.DeclineInvite(room))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
RoomSummaryDisplayType.ROOM -> {
|
||||
RoomSummaryScaffoldRow(
|
||||
room = room,
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
eventSink(RoomListEvents.ShowContextMenu(room))
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
NameAndTimestampRow(
|
||||
name = room.name,
|
||||
timestamp = room.timestamp,
|
||||
isHighlighted = room.isHighlighted
|
||||
)
|
||||
LastMessageAndIndicatorRow(room = room)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RoomSummaryRealRow(
|
||||
private fun RoomSummaryScaffoldRow(
|
||||
room: RoomListRoomSummary,
|
||||
onClick: (RoomListRoomSummary) -> Unit,
|
||||
onLongClick: (RoomListRoomSummary) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val clickModifier = Modifier.combinedClickable(
|
||||
onClick = { onClick(room) },
|
||||
@@ -100,94 +150,186 @@ private fun RoomSummaryRealRow(
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = minHeight)
|
||||
.then(clickModifier)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 11.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
Avatar(
|
||||
room
|
||||
.avatarData,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
Avatar(room.avatarData)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameAndTimestampRow(
|
||||
name: String,
|
||||
timestamp: String?,
|
||||
isHighlighted: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(16.dp)
|
||||
) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
text = name,
|
||||
color = MaterialTheme.roomListRoomName(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Timestamp
|
||||
Text(
|
||||
text = timestamp ?: "",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = if (isHighlighted) {
|
||||
ElementTheme.colors.unreadIndicator
|
||||
} else {
|
||||
MaterialTheme.roomListRoomMessageDate()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteSubtitle(
|
||||
isDirect: Boolean,
|
||||
inviteSender: InviteSender?,
|
||||
canonicalAlias: RoomAlias?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val subtitle = if (isDirect) {
|
||||
inviteSender?.userId?.value
|
||||
} else {
|
||||
canonicalAlias?.value
|
||||
}
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.roomListRoomMessage(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LastMessageAndIndicatorRow(
|
||||
room: RoomListRoomSummary,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(28.dp)
|
||||
) {
|
||||
// Last Message
|
||||
val attributedLastMessage = room.lastMessage as? AnnotatedString
|
||||
?: AnnotatedString(room.lastMessage.orEmpty().toString())
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = attributedLastMessage,
|
||||
color = MaterialTheme.roomListRoomMessage(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Call and unread
|
||||
Row(
|
||||
modifier = Modifier.height(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
NameAndTimestampRow(room = room)
|
||||
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
|
||||
if (room.hasRoomCall) {
|
||||
OnGoingCallIcon(
|
||||
color = tint,
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
LastMessageAndIndicatorRow(room = room)
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
NotificationOffIndicatorAtom()
|
||||
} else if (room.numberOfUnreadMentions > 0) {
|
||||
MentionIndicatorAtom()
|
||||
}
|
||||
if (room.hasNewContent) {
|
||||
UnreadIndicatorAtom(
|
||||
color = tint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 16.dp),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
text = room.name,
|
||||
color = MaterialTheme.roomListRoomName(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Timestamp
|
||||
Text(
|
||||
text = room.timestamp ?: "",
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = if (room.isHighlighted) {
|
||||
ElementTheme.colors.unreadIndicator
|
||||
} else {
|
||||
MaterialTheme.roomListRoomMessageDate()
|
||||
},
|
||||
)
|
||||
private fun InviteNameAndIndicatorRow(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
text = name,
|
||||
color = MaterialTheme.roomListRoomName(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
UnreadIndicatorAtom(
|
||||
color = ElementTheme.colors.unreadIndicator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
|
||||
// Last Message
|
||||
val attributedLastMessage = room.lastMessage as? AnnotatedString
|
||||
?: AnnotatedString(room.lastMessage.orEmpty().toString())
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 28.dp),
|
||||
text = attributedLastMessage,
|
||||
color = MaterialTheme.roomListRoomMessage(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Call and unread
|
||||
private fun InviteSenderRow(
|
||||
sender: InviteSender,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.height(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
|
||||
if (room.hasRoomCall) {
|
||||
OnGoingCallIcon(
|
||||
color = tint,
|
||||
)
|
||||
}
|
||||
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
|
||||
NotificationOffIndicatorAtom()
|
||||
} else if (room.numberOfUnreadMentions > 0) {
|
||||
MentionIndicatorAtom()
|
||||
}
|
||||
if (room.hasNewContent) {
|
||||
UnreadIndicatorAtom(
|
||||
color = tint
|
||||
)
|
||||
}
|
||||
Avatar(avatarData = sender.avatarData)
|
||||
Text(
|
||||
text = sender.annotatedString(),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteButtonsRow(
|
||||
onAcceptClicked: () -> Unit,
|
||||
onDeclineClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.padding(),
|
||||
horizontalArrangement = spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
text = stringResource(CommonStrings.action_decline),
|
||||
onClick = onDeclineClicked,
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_accept),
|
||||
onClick = onAcceptClicked,
|
||||
size = ButtonSize.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +371,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider
|
||||
RoomSummaryRow(
|
||||
room = data,
|
||||
onClick = {},
|
||||
onLongClick = {}
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.invite.api.SeenInvitesStore
|
||||
import io.element.android.features.roomlist.impl.InvitesState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultInviteStateDataSource @Inject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val seenInvitesStore: SeenInvitesStore,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : InviteStateDataSource {
|
||||
@Composable
|
||||
override fun inviteState(): InvitesState {
|
||||
val invites by client
|
||||
.roomListService
|
||||
.invites
|
||||
.summaries
|
||||
.collectAsState(initial = emptyList())
|
||||
|
||||
val seenInvites by seenInvitesStore
|
||||
.seenRoomIds()
|
||||
.collectAsState(initial = emptySet())
|
||||
|
||||
var state by remember { mutableStateOf(InvitesState.NoInvites) }
|
||||
|
||||
LaunchedEffect(invites, seenInvites) {
|
||||
withContext(coroutineDispatchers.computation) {
|
||||
state = when {
|
||||
invites.isEmpty() -> InvitesState.NoInvites
|
||||
seenInvites.containsAll(invites.roomIds) -> InvitesState.SeenInvites
|
||||
else -> InvitesState.NewInvites
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
private val List<RoomSummary>.roomIds: Collection<RoomId>
|
||||
get() = filterIsInstance<RoomSummary.Filled>().map { it.details.roomId }
|
||||
@@ -16,13 +16,16 @@
|
||||
|
||||
package io.element.android.features.roomlist.impl.datasource
|
||||
|
||||
import io.element.android.features.roomlist.impl.model.InviteSender
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.libraries.core.extensions.orEmpty
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -35,7 +38,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
|
||||
return RoomListRoomSummary(
|
||||
id = id,
|
||||
roomId = RoomId(id),
|
||||
isPlaceholder = true,
|
||||
displayType = RoomSummaryDisplayType.PLACEHOLDER,
|
||||
name = "Short name",
|
||||
timestamp = "hh:mm",
|
||||
lastMessage = "Last message for placeholder",
|
||||
@@ -46,8 +49,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
|
||||
isMarkedUnread = false,
|
||||
userDefinedNotificationMode = null,
|
||||
hasRoomCall = false,
|
||||
isDm = false,
|
||||
isDirect = false,
|
||||
isFavorite = false,
|
||||
inviteSender = null,
|
||||
isDm = false,
|
||||
canonicalAlias = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -73,11 +79,29 @@ class RoomListRoomSummaryFactory @Inject constructor(
|
||||
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
|
||||
}.orEmpty(),
|
||||
avatarData = avatarData,
|
||||
isPlaceholder = false,
|
||||
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
|
||||
hasRoomCall = roomSummary.details.hasRoomCall,
|
||||
isDm = roomSummary.details.isDm,
|
||||
isDirect = roomSummary.details.isDirect,
|
||||
isFavorite = roomSummary.details.isFavorite,
|
||||
inviteSender = roomSummary.details.inviter?.run {
|
||||
InviteSender(
|
||||
userId = userId,
|
||||
displayName = displayName ?: "",
|
||||
avatarData = AvatarData(
|
||||
id = userId.value,
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = AvatarSize.InviteSender,
|
||||
),
|
||||
)
|
||||
},
|
||||
isDm = roomSummary.details.isDm,
|
||||
canonicalAlias = roomSummary.details.canonicalAlias,
|
||||
displayType = if (roomSummary.details.currentUserMembership == CurrentUserMembership.INVITED) {
|
||||
RoomSummaryDisplayType.INVITE
|
||||
} else {
|
||||
RoomSummaryDisplayType.ROOM
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,15 @@ enum class RoomListFilter(val stringResource: Int) {
|
||||
Unread(R.string.screen_roomlist_filter_unreads),
|
||||
People(R.string.screen_roomlist_filter_people),
|
||||
Rooms(R.string.screen_roomlist_filter_rooms),
|
||||
Favourites(R.string.screen_roomlist_filter_favourites);
|
||||
Favourites(R.string.screen_roomlist_filter_favourites),
|
||||
Invites(R.string.screen_roomlist_filter_invites);
|
||||
|
||||
val oppositeFilter: RoomListFilter?
|
||||
val incompatibleFilters: Set<RoomListFilter>
|
||||
get() = when (this) {
|
||||
Rooms -> People
|
||||
People -> Rooms
|
||||
Unread -> null
|
||||
Favourites -> null
|
||||
Rooms -> setOf(People, Invites)
|
||||
People -> setOf(Rooms, Invites)
|
||||
Unread -> setOf(Invites)
|
||||
Favourites -> setOf(Invites)
|
||||
Invites -> setOf(Rooms, People, Unread, Favourites)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ data class RoomListFiltersEmptyStateResources(
|
||||
title = R.string.screen_roomlist_filter_favourites_empty_state_title,
|
||||
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle
|
||||
)
|
||||
RoomListFilter.Invites -> RoomListFiltersEmptyStateResources(
|
||||
title = R.string.screen_roomlist_filter_invites_empty_state_title,
|
||||
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> RoomListFiltersEmptyStateResources(
|
||||
|
||||
@@ -66,6 +66,7 @@ class RoomListFiltersPresenter @Inject constructor(
|
||||
RoomListFilter.People -> MatrixRoomListFilter.Category.People
|
||||
RoomListFilter.Unread -> MatrixRoomListFilter.Unread
|
||||
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
|
||||
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStra
|
||||
isSelected = true
|
||||
)
|
||||
}
|
||||
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
|
||||
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
|
||||
val unselectedFilterStates = unselectedFilters.map {
|
||||
FilterSelectionState(
|
||||
filter = it,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.model
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import io.element.android.features.roomlist.impl.R
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@Immutable
|
||||
data class InviteSender(
|
||||
val userId: UserId,
|
||||
val displayName: String,
|
||||
val avatarData: AvatarData,
|
||||
) {
|
||||
@Composable
|
||||
fun annotatedString(): AnnotatedString {
|
||||
return stringResource(R.string.screen_invites_invited_you, displayName, userId.value).let { text ->
|
||||
val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
|
||||
AnnotatedString(
|
||||
text = text,
|
||||
spanStyles = listOf(
|
||||
AnnotatedString.Range(
|
||||
SpanStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
start = senderNameStart,
|
||||
end = senderNameStart + displayName.length
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,17 @@ package io.element.android.features.roomlist.impl.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
||||
@Immutable
|
||||
data class RoomListRoomSummary(
|
||||
val id: String,
|
||||
val displayType: RoomSummaryDisplayType,
|
||||
val roomId: RoomId,
|
||||
val name: String,
|
||||
val canonicalAlias: RoomAlias?,
|
||||
val numberOfUnreadMessages: Int,
|
||||
val numberOfUnreadMentions: Int,
|
||||
val numberOfUnreadNotifications: Int,
|
||||
@@ -33,18 +36,21 @@ data class RoomListRoomSummary(
|
||||
val timestamp: String?,
|
||||
val lastMessage: CharSequence?,
|
||||
val avatarData: AvatarData,
|
||||
val isPlaceholder: Boolean,
|
||||
val userDefinedNotificationMode: RoomNotificationMode?,
|
||||
val hasRoomCall: Boolean,
|
||||
val isDirect: Boolean,
|
||||
val isDm: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
) {
|
||||
val inviteSender: InviteSender?,
|
||||
) {
|
||||
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
|
||||
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
|
||||
isMarkedUnread
|
||||
isMarkedUnread ||
|
||||
displayType == RoomSummaryDisplayType.INVITE
|
||||
|
||||
val hasNewContent = numberOfUnreadMessages > 0 ||
|
||||
numberOfUnreadMentions > 0 ||
|
||||
numberOfUnreadNotifications > 0 ||
|
||||
isMarkedUnread
|
||||
isMarkedUnread ||
|
||||
displayType == RoomSummaryDisplayType.INVITE
|
||||
}
|
||||
|
||||
@@ -19,14 +19,16 @@ package io.element.android.features.roomlist.impl.model
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
|
||||
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
|
||||
override val values: Sequence<RoomListRoomSummary>
|
||||
get() = sequenceOf(
|
||||
listOf(
|
||||
aRoomListRoomSummary(isPlaceholder = true),
|
||||
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
|
||||
aRoomListRoomSummary(),
|
||||
aRoomListRoomSummary(lastMessage = null),
|
||||
aRoomListRoomSummary(
|
||||
@@ -80,9 +82,37 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
|
||||
)
|
||||
}.flatten()
|
||||
}.flatten(),
|
||||
listOf(
|
||||
aRoomListRoomSummary(
|
||||
displayType = RoomSummaryDisplayType.INVITE,
|
||||
inviteSender = anInviteSender(
|
||||
userId = "@alice:matrix.org",
|
||||
displayName = "Alice",
|
||||
),
|
||||
canonicalAlias = RoomAlias("#alias:matrix.org"),
|
||||
),
|
||||
aRoomListRoomSummary(
|
||||
name = "Bob",
|
||||
displayType = RoomSummaryDisplayType.INVITE,
|
||||
inviteSender = anInviteSender(
|
||||
userId = "@bob:matrix.org",
|
||||
displayName = "Bob",
|
||||
),
|
||||
isDirect = true,
|
||||
)
|
||||
),
|
||||
).flatten()
|
||||
}
|
||||
|
||||
internal fun anInviteSender(
|
||||
userId: String,
|
||||
displayName: String,
|
||||
) = InviteSender(
|
||||
userId = UserId(userId),
|
||||
displayName = displayName,
|
||||
avatarData = AvatarData(userId, displayName, size = AvatarSize.InviteSender),
|
||||
)
|
||||
|
||||
internal fun aRoomListRoomSummary(
|
||||
id: String = "!roomId:domain",
|
||||
name: String = "Room name",
|
||||
@@ -92,12 +122,15 @@ internal fun aRoomListRoomSummary(
|
||||
isMarkedUnread: Boolean = false,
|
||||
lastMessage: String? = "Last message",
|
||||
timestamp: String? = lastMessage?.let { "88:88" },
|
||||
isPlaceholder: Boolean = false,
|
||||
notificationMode: RoomNotificationMode? = null,
|
||||
hasRoomCall: Boolean = false,
|
||||
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
|
||||
isDirect: Boolean = false,
|
||||
isDm: Boolean = false,
|
||||
isFavorite: Boolean = false,
|
||||
inviteSender: InviteSender? = null,
|
||||
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
|
||||
canonicalAlias: RoomAlias? = null,
|
||||
) = RoomListRoomSummary(
|
||||
id = id,
|
||||
roomId = RoomId(id),
|
||||
@@ -109,9 +142,12 @@ internal fun aRoomListRoomSummary(
|
||||
timestamp = timestamp,
|
||||
lastMessage = lastMessage,
|
||||
avatarData = avatarData,
|
||||
isPlaceholder = isPlaceholder,
|
||||
userDefinedNotificationMode = notificationMode,
|
||||
hasRoomCall = hasRoomCall,
|
||||
isDirect = isDirect,
|
||||
isDm = isDm,
|
||||
isFavorite = isFavorite,
|
||||
inviteSender = inviteSender,
|
||||
displayType = displayType,
|
||||
canonicalAlias = canonicalAlias,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,12 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.invite.api
|
||||
package io.element.android.features.roomlist.impl.model
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface SeenInvitesStore {
|
||||
fun seenRoomIds(): Flow<Set<RoomId>>
|
||||
suspend fun markAsSeen(roomIds: Set<RoomId>)
|
||||
/**
|
||||
* Represents the type of display for a room list item.
|
||||
*/
|
||||
enum class RoomSummaryDisplayType {
|
||||
PLACEHOLDER,
|
||||
ROOM,
|
||||
INVITE
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomlist.impl.R
|
||||
import io.element.android.features.roomlist.impl.RoomListEvents
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
import io.element.android.features.roomlist.impl.contentType
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
@@ -65,8 +66,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
internal fun RoomListSearchView(
|
||||
state: RoomListSearchState,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomDirectorySearchClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -90,7 +91,7 @@ internal fun RoomListSearchView(
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onRoomLongClicked = onRoomLongClicked,
|
||||
eventSink = eventSink,
|
||||
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
|
||||
)
|
||||
}
|
||||
@@ -102,8 +103,8 @@ internal fun RoomListSearchView(
|
||||
@Composable
|
||||
private fun RoomListSearchContent(
|
||||
state: RoomListSearchState,
|
||||
eventSink: (RoomListEvents) -> Unit,
|
||||
onRoomClicked: (RoomId) -> Unit,
|
||||
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
|
||||
onRoomDirectorySearchClicked: () -> Unit,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colorScheme.tertiary
|
||||
@@ -193,7 +194,7 @@ private fun RoomListSearchContent(
|
||||
RoomSummaryRow(
|
||||
room = room,
|
||||
onClick = ::onRoomClicked,
|
||||
onLongClick = onRoomLongClicked,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +221,7 @@ internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearch
|
||||
RoomListSearchContent(
|
||||
state = state,
|
||||
onRoomClicked = {},
|
||||
onRoomLongClicked = {},
|
||||
eventSink = {},
|
||||
onRoomDirectorySearchClicked = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
|
||||
<string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string>
|
||||
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string>
|
||||
<string name="screen_invites_decline_chat_title">"Decline invite"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string>
|
||||
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
|
||||
<string name="screen_invites_empty_list">"No Invites"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
|
||||
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
|
||||
<string name="screen_migration_title">"Setting up your account."</string>
|
||||
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
|
||||
|
||||
@@ -21,14 +21,15 @@ import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Interaction
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
|
||||
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
|
||||
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomEvent
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
|
||||
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.features.preferences.api.store.SessionPreferencesStore
|
||||
import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
|
||||
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
|
||||
@@ -52,6 +53,7 @@ import io.element.android.libraries.indicator.impl.DefaultIndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomList
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
@@ -77,11 +79,14 @@ import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.MutablePresenter
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.assert
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@@ -303,38 +308,6 @@ class RoomListPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets invite state`() = runTest {
|
||||
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
|
||||
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
|
||||
val roomListService = FakeRoomListService()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val presenter = createRoomListPresenter(
|
||||
inviteStateDataSource = inviteStateDataSource,
|
||||
coroutineScope = scope,
|
||||
client = FakeMatrixClient(roomListService = roomListService),
|
||||
)
|
||||
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val firstItem = consumeItemsUntilPredicate {
|
||||
it.contentState is RoomListContentState.Rooms
|
||||
}.last()
|
||||
assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||
|
||||
inviteStateFlow.value = InvitesState.SeenInvites
|
||||
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
|
||||
|
||||
inviteStateFlow.value = InvitesState.NewInvites
|
||||
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
|
||||
|
||||
inviteStateFlow.value = InvitesState.NoInvites
|
||||
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - show context menu`() = runTest {
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
@@ -609,11 +582,53 @@ class RoomListPresenterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
|
||||
val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> }
|
||||
val acceptDeclinePresenter = Presenter {
|
||||
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
|
||||
}
|
||||
val roomListService = FakeRoomListService()
|
||||
val scope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||
val matrixClient = FakeMatrixClient(
|
||||
roomListService = roomListService,
|
||||
)
|
||||
val roomSummary = aRoomSummaryFilled(
|
||||
currentUserMembership = CurrentUserMembership.INVITED
|
||||
)
|
||||
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
|
||||
roomListService.postAllRooms(listOf(roomSummary))
|
||||
val presenter = createRoomListPresenter(
|
||||
coroutineScope = scope,
|
||||
client = matrixClient,
|
||||
acceptDeclineInvitePresenter = acceptDeclinePresenter
|
||||
)
|
||||
presenter.test {
|
||||
val state = consumeItemsUntilPredicate {
|
||||
it.contentState is RoomListContentState.Rooms
|
||||
}.last()
|
||||
|
||||
val roomListRoomSummary = state.contentAsRooms().summaries.first {
|
||||
it.id == roomSummary.identifier()
|
||||
}
|
||||
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
|
||||
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
|
||||
|
||||
val inviteData = roomListRoomSummary.toInviteData()
|
||||
|
||||
assert(eventSinkRecorder)
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
|
||||
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createRoomListPresenter(
|
||||
client: MatrixClient = FakeMatrixClient(),
|
||||
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
|
||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||
inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(),
|
||||
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
|
||||
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
|
||||
givenFormat(A_FORMATTED_DATE)
|
||||
@@ -626,11 +641,11 @@ class RoomListPresenterTests {
|
||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
|
||||
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
|
||||
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
networkMonitor = networkMonitor,
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
inviteStateDataSource = inviteStateDataSource,
|
||||
leaveRoomPresenter = leaveRoomPresenter,
|
||||
roomListDataSource = RoomListDataSource(
|
||||
roomListService = client.roomListService,
|
||||
@@ -652,5 +667,6 @@ class RoomListPresenterTests {
|
||||
sessionPreferencesStore = sessionPreferencesStore,
|
||||
filtersPresenter = filtersPresenter,
|
||||
analyticsService = analyticsService,
|
||||
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
|
||||
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
@@ -93,7 +94,9 @@ class RoomListViewTest {
|
||||
val state = aRoomListState(
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val room0 = state.contentAsRooms().summaries.first()
|
||||
val room0 = state.contentAsRooms().summaries.first {
|
||||
it.displayType == RoomSummaryDisplayType.ROOM
|
||||
}
|
||||
ensureCalledOnceWithParam(room0.roomId) { callback ->
|
||||
rule.setRoomListView(
|
||||
state = state,
|
||||
@@ -109,7 +112,9 @@ class RoomListViewTest {
|
||||
val state = aRoomListState(
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val room0 = state.contentAsRooms().summaries.first()
|
||||
val room0 = state.contentAsRooms().summaries.first {
|
||||
it.displayType == RoomSummaryDisplayType.ROOM
|
||||
}
|
||||
rule.setRoomListView(
|
||||
state = state,
|
||||
)
|
||||
@@ -136,19 +141,20 @@ class RoomListViewTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on invites invokes the expected callback`() {
|
||||
fun `clicking on accept and decline invite emits the expected Events`() {
|
||||
val eventsRecorder = EventsRecorder<RoomListEvents>()
|
||||
val state = aRoomListState(
|
||||
contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomListView(
|
||||
state = state,
|
||||
onInvitesClicked = callback,
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_invites_list)
|
||||
val invitedRoom = state.contentAsRooms().summaries.first {
|
||||
it.displayType == RoomSummaryDisplayType.INVITE
|
||||
}
|
||||
rule.setRoomListView(state = state)
|
||||
rule.clickOn(CommonStrings.action_accept)
|
||||
rule.clickOn(CommonStrings.action_decline)
|
||||
eventsRecorder.assertList(
|
||||
listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +164,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
onSettingsClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
|
||||
onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -170,10 +175,10 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
||||
onSettingsClicked = onSettingsClicked,
|
||||
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
onInvitesClicked = onInvitesClicked,
|
||||
onRoomSettingsClicked = onRoomSettingsClicked,
|
||||
onMenuActionClicked = onMenuActionClicked,
|
||||
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
|
||||
acceptDeclineInviteView = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomlist.impl.datasource
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.invite.test.FakeSeenInvitesStore
|
||||
import io.element.android.features.roomlist.impl.InvitesState
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
internal class DefaultInviteStateDataSourceTest {
|
||||
@Test
|
||||
fun `emits NoInvites state if invites list is empty`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val client = FakeMatrixClient(roomListService = roomListService)
|
||||
val seenStore = FakeSeenInvitesStore()
|
||||
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
dataSource.inviteState()
|
||||
}.test {
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits NewInvites state if unseen invite exists`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||
val client = FakeMatrixClient(roomListService = roomListService)
|
||||
val seenStore = FakeSeenInvitesStore()
|
||||
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
dataSource.inviteState()
|
||||
}.test {
|
||||
skipItems(2)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
|
||||
val client = FakeMatrixClient(roomListService = roomListService)
|
||||
val seenStore = FakeSeenInvitesStore()
|
||||
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
dataSource.inviteState()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits SeenInvites state if invite exists in seen store`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||
val client = FakeMatrixClient(roomListService = roomListService)
|
||||
val seenStore = FakeSeenInvitesStore()
|
||||
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers(useUnconfinedTestDispatcher = true))
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
dataSource.inviteState()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `emits new state in response to upstream events`() = runTest {
|
||||
val roomListService = FakeRoomListService()
|
||||
val client = FakeMatrixClient(roomListService = roomListService)
|
||||
val seenStore = FakeSeenInvitesStore()
|
||||
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
|
||||
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
dataSource.inviteState()
|
||||
}.test {
|
||||
// Initially there are no invites
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||
|
||||
// When a single invite is received, state should be NewInvites
|
||||
roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||
|
||||
// If that invite is marked as seen, then the state becomes SeenInvites
|
||||
seenStore.publishRoomIds(setOf(A_ROOM_ID))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
|
||||
|
||||
// Another new invite resets it to NewInvites
|
||||
roomListService.postInviteRooms(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
|
||||
|
||||
// All of the invites going away reverts to NoInvites
|
||||
roomListService.postInviteRooms(emptyList())
|
||||
skipItems(1)
|
||||
assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,15 @@ class RoomListFiltersEmptyStateResourcesTest {
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Invites)
|
||||
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
|
||||
assertThat(result).isNotNull()
|
||||
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
|
||||
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
|
||||
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)
|
||||
|
||||
@@ -45,6 +45,7 @@ class RoomListFiltersPresenterTests {
|
||||
filterSelectionState(RoomListFilter.People, false),
|
||||
filterSelectionState(RoomListFilter.Rooms, false),
|
||||
filterSelectionState(RoomListFilter.Favourites, false),
|
||||
filterSelectionState(RoomListFilter.Invites, false),
|
||||
)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
@@ -84,6 +85,7 @@ class RoomListFiltersPresenterTests {
|
||||
filterSelectionState(RoomListFilter.People, false),
|
||||
filterSelectionState(RoomListFilter.Rooms, false),
|
||||
filterSelectionState(RoomListFilter.Favourites, false),
|
||||
filterSelectionState(RoomListFilter.Invites, false),
|
||||
).inOrder()
|
||||
assertThat(state.selectedFilters()).isEmpty()
|
||||
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All
|
||||
|
||||
@@ -72,6 +72,15 @@ class RoomListRoomSummaryTest {
|
||||
assertThat(sut.isHighlighted).isTrue()
|
||||
assertThat(sut.hasNewContent).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when display type is invite then isHighlighted and hasNewContent are true`() {
|
||||
val sut = createRoomListRoomSummary(
|
||||
displayType = RoomSummaryDisplayType.INVITE,
|
||||
)
|
||||
assertThat(sut.isHighlighted).isTrue()
|
||||
assertThat(sut.hasNewContent).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createRoomListRoomSummary(
|
||||
@@ -81,6 +90,7 @@ internal fun createRoomListRoomSummary(
|
||||
isMarkedUnread: Boolean = false,
|
||||
userDefinedNotificationMode: RoomNotificationMode? = null,
|
||||
isFavorite: Boolean = false,
|
||||
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
|
||||
) = RoomListRoomSummary(
|
||||
id = A_ROOM_ID.value,
|
||||
roomId = A_ROOM_ID,
|
||||
@@ -92,9 +102,12 @@ internal fun createRoomListRoomSummary(
|
||||
timestamp = A_FORMATTED_DATE,
|
||||
lastMessage = "",
|
||||
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
|
||||
isPlaceholder = false,
|
||||
displayType = displayType,
|
||||
userDefinedNotificationMode = userDefinedNotificationMode,
|
||||
hasRoomCall = false,
|
||||
isDm = false,
|
||||
isDirect = false,
|
||||
isFavorite = isFavorite,
|
||||
canonicalAlias = null,
|
||||
inviteSender = null,
|
||||
isDm = false,
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ ksp = "1.9.23-1.0.20"
|
||||
firebaseAppDistribution = "4.2.0"
|
||||
|
||||
# AndroidX
|
||||
core = "1.12.0"
|
||||
datastore = "1.0.0"
|
||||
core = "1.13.0"
|
||||
datastore = "1.1.0"
|
||||
constraintlayout = "2.1.4"
|
||||
constraintlayout_compose = "1.0.1"
|
||||
lifecycle = "2.7.0"
|
||||
@@ -18,8 +18,8 @@ activity = "1.8.2"
|
||||
media3 = "1.3.1"
|
||||
|
||||
# Compose
|
||||
compose_bom = "2024.04.00"
|
||||
composecompiler = "1.5.11"
|
||||
compose_bom = "2024.04.01"
|
||||
composecompiler = "1.5.12"
|
||||
|
||||
# Coroutines
|
||||
coroutines = "1.8.0"
|
||||
|
||||
@@ -18,7 +18,3 @@ package io.element.android.libraries.deeplink
|
||||
|
||||
internal const val SCHEME = "elementx"
|
||||
internal const val HOST = "open"
|
||||
|
||||
object DeepLinkPaths {
|
||||
const val INVITE_LIST = "invites"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user