Merge develop into feature/fga/permalink_timeline

This commit is contained in:
ganfra 2024-04-18 15:40:17 +02:00
commit 6808edda86
398 changed files with 2667 additions and 2461 deletions

View file

@ -11,7 +11,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@11.3.1 uses: danger/danger-js@12.1.0
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:

View file

@ -72,7 +72,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@11.3.1 uses: danger/danger-js@12.1.0
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:

View file

@ -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) Changes in Element X v0.4.9 (2024-04-12)
======================================== ========================================

View file

@ -45,11 +45,4 @@ class IntentProviderImpl @Inject constructor(
data = deepLinkCreator.room(sessionId, roomId, threadId).toUri() 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()
}
}
} }

View file

@ -67,16 +67,6 @@ class IntentProviderImplTest {
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") 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 { private fun createIntentProviderImpl(): IntentProviderImpl {
return IntentProviderImpl( return IntentProviderImpl(
context = RuntimeEnvironment.getApplication() as Context, context = RuntimeEnvironment.getApplication() as Context,

View file

@ -33,7 +33,6 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop import 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.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService 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.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.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.matrix.api.sync.SyncState
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -81,7 +78,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
@ -95,11 +91,9 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueService: FtueService, private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService, private val lockScreenStateService: LockScreenService,
@ -160,23 +154,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
) )
observeSyncStateAndNetworkStatus() 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) @OptIn(FlowPreview::class)
@ -215,9 +192,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class Room( data class Room(
val roomId: RoomId, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: RoomDescription? = null, val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages()
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -233,9 +210,6 @@ class LoggedInFlowNode @AssistedInject constructor(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget ) : NavTarget
@Parcelize
data object InviteList : NavTarget
@Parcelize @Parcelize
data object Ftue : NavTarget data object Ftue : NavTarget
@ -257,7 +231,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.RoomList -> { NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback { val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) { override fun onRoomClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onSettingsClicked() { override fun onSettingsClicked() {
@ -272,12 +246,8 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
} }
override fun onInvitesClicked() {
backstack.push(NavTarget.InviteList)
}
override fun onRoomSettingsClicked(roomId: RoomId) { 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() { override fun onReportBugClicked() {
@ -296,19 +266,41 @@ class LoggedInFlowNode @AssistedInject constructor(
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : JoinedRoomLoadedFlowNode.Callback { val callback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(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() { override fun onOpenGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings)) backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
} }
} }
val inputs = RoomFlowNode.Inputs( val inputs = RoomFlowNode.Inputs(
roomId = navTarget.roomId, roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription), roomDescription = Optional.ofNullable(navTarget.roomDescription),
initialElement = navTarget.initialElement initialElement = navTarget.initialElement
) )
@ -325,7 +317,7 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onOpenRoomNotificationSettings(roomId: RoomId) { 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) val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
@ -337,7 +329,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.CreateRoom -> { NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback { val callback = object : CreateRoomEntryPoint.Callback {
override fun onSuccess(roomId: RoomId) { 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)) .params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.build() .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 -> { NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext) ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback { .callback(object : FtueEntryPoint.Callback {
@ -383,11 +356,11 @@ class LoggedInFlowNode @AssistedInject constructor(
roomDirectoryEntryPoint.nodeBuilder(this, buildContext) roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback { .callback(object : RoomDirectoryEntryPoint.Callback {
override fun onRoomJoined(roomId: RoomId) { override fun onRoomJoined(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onResultClicked(roomDescription: RoomDescription) { override fun onResultClicked(roomDescription: RoomDescription) {
backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription)) backstack.push(NavTarget.Room(roomDescription.roomId.toRoomIdOrAlias(), roomDescription))
} }
}) })
.build() .build()
@ -406,17 +379,7 @@ class LoggedInFlowNode @AssistedInject constructor(
if (!canShowRoomList()) return if (!canShowRoomList()) return
attachChild<RoomFlowNode> { attachChild<RoomFlowNode> {
backstack.singleTop(NavTarget.RoomList) backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
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
} }
} }

View file

@ -290,7 +290,6 @@ class RootFlowNode @AssistedInject constructor(
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> attachRoomList() is DeeplinkData.Root -> attachRoomList()
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId) is DeeplinkData.Room -> attachRoom(deeplinkData.roomId)
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} }
} }
} }

View file

@ -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.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.joinroom.api.JoinRoomEntryPoint 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.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@ -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.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
@ -64,6 +68,7 @@ class RoomFlowNode @AssistedInject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val roomMembershipObserver: RoomMembershipObserver, private val roomMembershipObserver: RoomMembershipObserver,
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -73,9 +78,9 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins plugins = plugins
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -85,29 +90,51 @@ class RoomFlowNode @AssistedInject constructor(
data object Loading : NavTarget data object Loading : NavTarget
@Parcelize @Parcelize
data object JoinRoom : NavTarget data class Resolving(val roomAlias: RoomAlias) : NavTarget
@Parcelize @Parcelize
data object JoinedRoom : NavTarget data class JoinRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
client.getRoomInfoFlow( resolveRoomId()
inputs.roomId }
).onEach { roomInfo ->
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}") private fun resolveRoomId() {
if (roomInfo.getOrNull()?.currentUserMembership == CurrentUserMembership.JOINED) { lifecycleScope.launch {
backstack.newRoot(NavTarget.JoinedRoom) when (val i = inputs.roomIdOrAlias) {
} else { is RoomIdOrAlias.Alias -> {
backstack.newRoot(NavTarget.JoinRoom) 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) .launchIn(lifecycleScope)
// When leaving the room from this session only, navigate up. // When leaving the room from this session only, navigate up.
roomMembershipObserver.updates roomMembershipObserver.updates
.filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom } .filter { update -> update.roomId == roomId && !update.isUserInRoom }
.onEach { .onEach {
navigateUp() navigateUp()
} }
@ -116,14 +143,30 @@ class RoomFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Loading -> loadingNode(buildContext) is NavTarget.Loading -> loadingNode(buildContext)
NavTarget.JoinRoom -> { is NavTarget.Resolving -> {
val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription) 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) joinRoomEntryPoint.createNode(this, buildContext, inputs)
} }
NavTarget.JoinedRoom -> { is NavTarget.JoinedRoom -> {
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>() 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) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
} }

View file

@ -16,8 +16,17 @@
package io.element.android.appnav.room package io.element.android.appnav.room
enum class RoomNavigationTarget { import android.os.Parcelable
Messages, import io.element.android.libraries.matrix.api.core.EventId
Details, import kotlinx.parcelize.Parcelize
NotificationSettings,
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : RoomNavigationTarget
@Parcelize
data object Details : RoomNavigationTarget
@Parcelize
data object NotificationSettings : RoomNavigationTarget
} }

View file

@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()

View file

@ -42,8 +42,10 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -63,8 +65,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) { initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
RoomNavigationTarget.Messages -> NavTarget.Messages is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
}, },
@ -75,13 +77,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner { ), DaggerComponentOwner {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId) fun onOpenRoom(roomId: RoomId)
fun onPermalinkClicked(data: PermalinkData)
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
} }
data class Inputs( data class Inputs(
val room: MatrixRoom, val room: MatrixRoom,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages(),
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -139,7 +142,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Messages -> { is NavTarget.Messages -> {
val callback = object : MessagesEntryPoint.Callback { val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClicked() { override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails) backstack.push(NavTarget.RoomDetails)
@ -149,11 +152,18 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
backstack.push(NavTarget.RoomMemberDetails(userId)) backstack.push(NavTarget.RoomMemberDetails(userId))
} }
override fun onPermalinkClicked(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClicked(data) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(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 -> { NavTarget.RoomDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
@ -169,7 +179,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Messages : NavTarget data class Messages(val focusedEventId: EventId? = null) : NavTarget
@Parcelize @Parcelize
data object RoomDetails : NavTarget data object RoomDetails : NavTarget

View file

@ -47,14 +47,30 @@ class JoinRoomLoadedFlowNodeTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private class FakeMessagesEntryPoint : MessagesEntryPoint { private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
var buildContext: BuildContext? = null
var nodeId: String? = null var nodeId: String? = null
var parameters: MessagesEntryPoint.Params? = null
var callback: MessagesEntryPoint.Callback? = null var callback: MessagesEntryPoint.Callback? = null
override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
return node(buildContext) {}.also { 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 nodeId = it.id
this.callback = callback
} }
} }
} }
@ -118,9 +134,9 @@ class JoinRoomLoadedFlowNodeTest {
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN // THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages) assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!! val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
} }

View file

@ -1 +0,0 @@
Fix compile for forks that use the `noop` analytics module

View file

@ -1 +0,0 @@
Encrypt new session data with a passphrase

View file

@ -1 +0,0 @@
Use sdk API to build permalinks

View file

@ -1 +0,0 @@
Parse permalink using parseMatrixEntityFrom from the SDK

1
changelog.d/2721.misc Normal file
View file

@ -0,0 +1 @@
RoomMember screen: fallback to userProfile data, if the member is not a user of the room.

View 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

View file

@ -50,7 +50,6 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -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()
}
}
}

View file

@ -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 = {},
)
}

View file

@ -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,
)
}

View file

@ -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"
),
)
}

View file

@ -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 = {},
)
}

View file

@ -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),
)

View file

@ -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"
),
)

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.invite.api.response.AcceptDeclineInviteState 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.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.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@ -102,9 +102,9 @@ private fun DeclineConfirmationDialog(
) )
} }
@PreviewLightDark @PreviewsDayNight
@Composable @Composable
internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) = internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
ElementPreview { ElementPreview {
AcceptDeclineInviteView( AcceptDeclineInviteView(
state = state, state = state,

View file

@ -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,
)
}

View file

@ -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()
}
}

View file

@ -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.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional import java.util.Optional
interface JoinRoomEntryPoint : FeatureEntryPoint { interface JoinRoomEntryPoint : FeatureEntryPoint {
@ -29,6 +30,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
) : NodeInputs ) : NodeInputs
} }

View file

@ -49,7 +49,6 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -17,6 +17,7 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
sealed interface JoinRoomEvents { sealed interface JoinRoomEvents {
data object RetryFetchingContent : JoinRoomEvents
data object JoinRoom : JoinRoomEvents data object JoinRoom : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents data object DeclineInvite : JoinRoomEvents

View file

@ -37,7 +37,11 @@ class JoinRoomNode @AssistedInject constructor(
private val acceptDeclineInviteView: AcceptDeclineInviteView, private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs() 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 @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {

View file

@ -16,10 +16,14 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents 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.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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 import java.util.Optional
class JoinRoomPresenter @AssistedInject constructor( class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId, @Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
@Assisted private val roomDescription: Optional<RoomDescription>, @Assisted private val roomDescription: Optional<RoomDescription>,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter fun create(
roomId: RoomId,
roomIdOrAlias: RoomIdOrAlias,
roomDescription: Optional<RoomDescription>,
): JoinRoomPresenter
} }
@Composable @Composable
override fun present(): JoinRoomState { override fun present(): JoinRoomState {
var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty()) val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val contentState by produceState<ContentState>(initialValue = ContentState.Loading(roomId), key1 = roomInfo) { val contentState by produceState<ContentState>(
value = when { initialValue = ContentState.Loading(roomIdOrAlias),
key1 = roomInfo,
key2 = retryCount,
) {
when {
roomInfo.isPresent -> { roomInfo.isPresent -> {
roomInfo.get().toContentState() value = roomInfo.get().toContentState()
} }
roomDescription.isPresent -> { roomDescription.isPresent -> {
roomDescription.get().toContentState() value = roomDescription.get().toContentState()
} }
else -> { 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) { fun handleEvents(event: JoinRoomEvents) {
when (event) { when (event) {
JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> { JoinRoomEvents.AcceptInvite,
JoinRoomEvents.JoinRoom -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvents.AcceptInvite(inviteData)
@ -76,6 +104,9 @@ class JoinRoomPresenter @AssistedInject constructor(
AcceptDeclineInviteEvents.DeclineInvite(inviteData) 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 @VisibleForTesting
internal fun RoomDescription.toContentState(): ContentState { internal fun RoomDescription.toContentState(): ContentState {
return ContentState.Loaded( return ContentState.Loaded(
@ -108,7 +157,7 @@ internal fun RoomDescription.toContentState(): ContentState {
@VisibleForTesting @VisibleForTesting
internal fun MatrixRoomInfo.toContentState(): ContentState { internal fun MatrixRoomInfo.toContentState(): ContentState {
return ContentState.Loaded( return ContentState.Loaded(
roomId = RoomId(id), roomId = id,
name = name, name = name,
topic = topic, topic = topic,
alias = canonicalAlias, alias = canonicalAlias,

View file

@ -20,7 +20,9 @@ import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
@Immutable @Immutable
data class JoinRoomState( data class JoinRoomState(
@ -35,13 +37,14 @@ data class JoinRoomState(
} }
sealed interface ContentState { sealed interface ContentState {
data class Loading(val roomId: RoomId) : ContentState data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState
data class UnknownRoom(val roomId: RoomId) : ContentState data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState
data class UnknownRoom(val roomIdOrAlias: RoomIdOrAlias) : ContentState
data class Loaded( data class Loaded(
val roomId: RoomId, val roomId: RoomId,
val name: String?, val name: String?,
val topic: String?, val topic: String?,
val alias: String?, val alias: RoomAlias?,
val numberOfMembers: Long?, val numberOfMembers: Long?,
val isDirect: Boolean, val isDirect: Boolean,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
@ -50,7 +53,7 @@ sealed interface ContentState {
val computedTitle = name ?: roomId.value val computedTitle = name ?: roomId.value
val computedSubtitle = when { val computedSubtitle = when {
alias != null -> alias alias != null -> alias.value
name == null -> "" name == null -> ""
else -> roomId.value else -> roomId.value
} }

View file

@ -19,7 +19,10 @@ package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState 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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> { open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState> override val values: Sequence<JoinRoomState>
@ -34,22 +37,45 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
), ),
aJoinRoomState( 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( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited) 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( fun aLoadedContentState(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = "Element X android", 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.", topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null, numberOfMembers: Long? = null,
isDirect: Boolean = false, isDirect: Boolean = false,
@ -77,3 +103,4 @@ fun aJoinRoomState(
) )
private val A_ROOM_ID = RoomId("!exa:matrix.org") private val A_ROOM_ID = RoomId("!exa:matrix.org")
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")

View file

@ -16,42 +16,36 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom 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.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.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button 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.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.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -71,7 +65,7 @@ fun JoinRoomView(
}, },
footer = { footer = {
JoinRoomFooter( JoinRoomFooter(
joinAuthorisationStatus = state.joinAuthorisationStatus, state = state,
onAcceptInvite = { onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite) state.eventSink(JoinRoomEvents.AcceptInvite)
}, },
@ -81,6 +75,9 @@ fun JoinRoomView(
onJoinRoom = { onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvents.JoinRoom)
}, },
onRetry = {
state.eventSink(JoinRoomEvents.RetryFetchingContent)
}
) )
} }
) )
@ -88,46 +85,57 @@ fun JoinRoomView(
@Composable @Composable
private fun JoinRoomFooter( private fun JoinRoomFooter(
joinAuthorisationStatus: JoinAuthorisationStatus, state: JoinRoomState,
onAcceptInvite: () -> Unit, onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit, onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (joinAuthorisationStatus) { if (state.contentState is ContentState.Failure) {
JoinAuthorisationStatus.IsInvited -> { Button(
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { text = stringResource(CommonStrings.action_retry),
OutlinedButton( onClick = onRetry,
text = stringResource(CommonStrings.action_decline), modifier = modifier.fillMaxWidth(),
onClick = onDeclineInvite, size = ButtonSize.Medium,
modifier = Modifier.weight(1f), )
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( Button(
text = stringResource(CommonStrings.action_accept), text = stringResource(R.string.screen_join_room_join_action),
onClick = onAcceptInvite, onClick = onJoinRoom,
modifier = Modifier.weight(1f), modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium, 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) { when (contentState) {
is ContentState.Loaded -> { is ContentState.Loaded -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader)) Avatar(contentState.avatarData(AvatarSize.RoomHeader))
}, },
title = { title = {
Title(contentState.computedTitle) RoomPreviewTitleAtom(contentState.computedTitle)
}, },
subtitle = { subtitle = {
Subtitle(contentState.computedSubtitle) RoomPreviewSubtitleAtom(contentState.computedSubtitle)
}, },
description = { description = {
Description(contentState.topic ?: "") RoomPreviewDescriptionAtom(contentState.topic ?: "")
}, },
memberCount = { memberCount = {
if (contentState.showMemberCount) { if (contentState.showMemberCount) {
MembersCount(memberCount = contentState.numberOfMembers ?: 0) RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
} }
} }
) )
} }
is ContentState.UnknownRoom -> { is ContentState.UnknownRoom -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
}, },
title = { title = {
Title(stringResource(R.string.screen_join_room_title_no_preview)) RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
}, },
subtitle = { subtitle = {
Subtitle(stringResource(R.string.screen_join_room_subtitle_no_preview)) RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview))
}, },
) )
} }
is ContentState.Loading -> { is ContentState.Loading -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
@ -187,94 +195,31 @@ private fun JoinRoomContent(
}, },
) )
} }
} is ContentState.Failure -> {
} RoomPreviewOrganism(
modifier = modifier,
@Composable avatar = {
private fun ContentScaffold( PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
avatar: @Composable () -> Unit, },
title: @Composable () -> Unit, title = {
subtitle: @Composable () -> Unit, when (contentState.roomIdOrAlias) {
modifier: Modifier = Modifier, is RoomIdOrAlias.Alias -> {
description: @Composable (() -> Unit)? = null, RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier)
memberCount: @Composable (() -> Unit)? = null, }
) { is RoomIdOrAlias.Id -> {
Column( PlaceholderAtom(width = 200.dp, height = 22.dp)
modifier = modifier.fillMaxWidth(), }
horizontalAlignment = Alignment.CenterHorizontally }
) { },
avatar() subtitle = {
Spacer(modifier = Modifier.height(16.dp)) Text(
title() text = "Failed to get information about the room",
Spacer(modifier = Modifier.height(8.dp)) textAlign = TextAlign.Center,
subtitle() color = MaterialTheme.colorScheme.error,
Spacer(modifier = Modifier.height(8.dp)) )
if (memberCount != null) { },
memberCount() )
} }
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 @Composable
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
JoinRoomView( JoinRoomView(

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional import java.util.Optional
@Module @Module
@ -37,9 +38,14 @@ object JoinRoomModule {
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : 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( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
roomIdOrAlias = roomIdOrAlias,
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = client, matrixClient = client,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,

View file

@ -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.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient 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.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.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_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -49,9 +53,10 @@ class JoinRoomPresenterTest {
val presenter = createJoinRoomPresenter() val presenter = createJoinRoomPresenter()
presenter.test { presenter.test {
awaitItem().also { state -> 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.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) 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( private fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(), roomDescription: Optional<RoomDescription> = Optional.empty(),
@ -245,6 +354,7 @@ class JoinRoomPresenterTest {
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = matrixClient, matrixClient = matrixClient,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
@ -255,7 +365,7 @@ class JoinRoomPresenterTest {
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
topic: String? = "A room about something", topic: String? = "A room about something",
alias: String? = "#alias:matrix.org", alias: RoomAlias? = RoomAlias("#alias:matrix.org"),
avatarUrl: String? = null, avatarUrl: String? = null,
joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN, joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers: Long = 2L numberOfMembers: Long = 2L

View file

@ -20,19 +20,28 @@ import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
interface MessagesEntryPoint : FeatureEntryPoint { interface MessagesEntryPoint : FeatureEntryPoint {
fun createNode( fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
parentNode: Node,
buildContext: BuildContext, interface NodeBuilder {
callback: Callback, fun params(params: Params): NodeBuilder
): Node fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
data class Params(
val focusedEventId: EventId?,
)
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClicked() fun onRoomDetailsClicked()
fun onUserDataClicked(userId: UserId) fun onUserDataClicked(userId: UserId)
fun onPermalinkClicked(data: PermalinkData)
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
} }
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -26,11 +27,23 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint { class DefaultMessagesEntryPoint @Inject constructor() : MessagesEntryPoint {
override fun createNode( override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
parentNode: Node, val plugins = ArrayList<Plugin>()
buildContext: BuildContext,
callback: MessagesEntryPoint.Callback return object : MessagesEntryPoint.NodeBuilder {
): Node { override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
return parentNode.createNode<MessagesFlowNode>(buildContext, listOf(callback)) 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)
}
}
} }
} }

View file

@ -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.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode 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.createNode
import io.element.android.libraries.architecture.overlay.Overlay import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.show 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource 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.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
@ -79,7 +81,7 @@ class MessagesFlowNode @AssistedInject constructor(
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
) : BaseFlowNode<MessagesFlowNode.NavTarget>( ) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages(plugins.filterIsInstance<Inputs>().firstOrNull()?.focusedEventId),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
overlay = Overlay( overlay = Overlay(
@ -88,12 +90,16 @@ class MessagesFlowNode @AssistedInject constructor(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
data class Inputs(val focusedEventId: EventId?) : NodeInputs
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Empty : NavTarget data object Empty : NavTarget
@Parcelize @Parcelize
data object Messages : NavTarget data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
@Parcelize @Parcelize
data class MediaViewer( data class MediaViewer(
@ -149,6 +155,10 @@ class MessagesFlowNode @AssistedInject constructor(
callback?.onUserDataClicked(userId) callback?.onUserDataClicked(userId)
} }
override fun onPermalinkClicked(data: PermalinkData) {
callback?.onPermalinkClicked(data)
}
override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo))
} }
@ -181,7 +191,10 @@ class MessagesFlowNode @AssistedInject constructor(
ElementCallActivity.start(context, inputs) 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 -> { is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs( val inputs = MediaViewerNode.Inputs(

View file

@ -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.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.core.bool.orFalse
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -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.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.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.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -58,15 +62,21 @@ class MessagesNode @AssistedInject constructor(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
@ApplicationContext
private val context: Context,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this) private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull() private val callback = plugins<Callback>().firstOrNull()
// TODO Handle navigation to the Event
data class Inputs(val focusedEventId: EventId?) : NodeInputs
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomDetailsClicked() fun onRoomDetailsClicked()
fun onEventClicked(event: TimelineItem.Event): Boolean fun onEventClicked(event: TimelineItem.Event): Boolean
fun onPreviewAttachments(attachments: ImmutableList<Attachment>) fun onPreviewAttachments(attachments: ImmutableList<Attachment>)
fun onUserDataClicked(userId: UserId) fun onUserDataClicked(userId: UserId)
fun onPermalinkClicked(data: PermalinkData)
fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo)
fun onForwardEventClicked(eventId: EventId) fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId) fun onReportMessage(eventId: EventId, senderId: UserId)
@ -109,16 +119,12 @@ class MessagesNode @AssistedInject constructor(
) { ) {
when (val permalink = permalinkParser.parse(url)) { when (val permalink = permalinkParser.parse(url)) {
is PermalinkData.UserLink -> { 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) callback?.onUserDataClicked(permalink.userId)
} }
is PermalinkData.RoomLink -> { is PermalinkData.RoomLink -> {
// TODO Implement room link handling handleRoomLinkClicked(permalink)
}
is PermalinkData.EventIdAliasLink -> {
// TODO Implement room and Event link handling
}
is PermalinkData.EventIdLink -> {
// TODO Implement room and Event link handling
} }
is PermalinkData.FallbackLink, is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> { 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) { override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) {
callback?.onShowEventDebugInfoClicked(eventId, debugInfo) callback?.onShowEventDebugInfoClicked(eventId, debugInfo)
} }

View file

@ -258,7 +258,7 @@ class MessagesPresenter @AssistedInject constructor(
private fun MatrixRoomInfo.avatarData(): AvatarData { private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData( return AvatarData(
id = id, id = id.value,
name = name, name = name,
url = avatarUrl ?: room.avatarUrl, url = avatarUrl ?: room.avatarUrl,
size = AvatarSize.TimelineRoom size = AvatarSize.TimelineRoom

View file

@ -720,7 +720,7 @@ class MessagesPresenterTest {
private fun TestScope.createMessagesPresenter( private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
matrixRoom: MatrixRoom = FakeMatrixRoom().apply { matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(id = roomId.value, name = "")) givenRoomInfo(aRoomInfo(id = roomId, name = ""))
}, },
navigator: FakeMessagesNavigator = FakeMessagesNavigator(), navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),

View file

@ -201,7 +201,7 @@ class TypingNotificationPresenterTest {
private fun createPresenter( private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply { matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(id = roomId.value, name = "")) givenRoomInfo(aRoomInfo(id = roomId, name = ""))
}, },
sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore( sessionPreferencesStore: SessionPreferencesStore = InMemorySessionPreferencesStore(
isRenderTypingNotificationsEnabled = true isRenderTypingNotificationsEnabled = true

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -19,11 +19,10 @@ plugins {
} }
android { android {
namespace = "io.element.android.features.invite.test" namespace = "io.element.android.features.roomaliasresolver.api"
} }
dependencies { dependencies {
implementation(libs.coroutines.core) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.invite.api)
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,25 +14,30 @@
* limitations under the License. * 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint 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 import io.element.android.libraries.matrix.api.core.RoomId
interface InviteListEntryPoint : FeatureEntryPoint { interface RoomAliasResolverEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun build(): Node fun build(): Node
} }
interface Callback : Plugin { interface Callback : Plugin {
fun onBackClicked() fun onAliasResolved(roomId: RoomId)
fun onInviteClicked(roomId: RoomId)
fun onInviteAccepted(roomId: RoomId)
} }
data class Params(
val roomAlias: RoomAlias
) : NodeInputs
} }

View 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)
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,31 +14,35 @@
* limitations under the License. * 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.InviteListEntryPoint import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.invite.impl.invitelist.InviteListNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint { class DefaultRoomAliasResolverEntryPoint @Inject constructor() : RoomAliasResolverEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomAliasResolverEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()
return object : InviteListEntryPoint.NodeBuilder { return object : RoomAliasResolverEntryPoint.NodeBuilder {
override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder { override fun callback(callback: RoomAliasResolverEntryPoint.Callback): RoomAliasResolverEntryPoint.NodeBuilder {
plugins += callback plugins += callback
return this return this
} }
override fun params(params: RoomAliasResolverEntryPoint.Params): RoomAliasResolverEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun build(): Node { override fun build(): Node {
return parentNode.createNode<InviteListNode>(buildContext, plugins) return parentNode.createNode<RoomAliasResolverNode>(buildContext, plugins)
} }
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,12 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.roomlist.impl.datasource package io.element.android.features.roomaliasresolver.impl
import androidx.compose.runtime.Composable sealed interface RoomAliasResolverEvents {
import io.element.android.features.roomlist.impl.InvitesState data object Retry : RoomAliasResolverEvents
interface InviteStateDataSource {
@Composable
fun inviteState(): InvitesState
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -25,37 +25,35 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
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.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class InviteListNode @AssistedInject constructor( class RoomAliasResolverNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: InviteListPresenter, presenterFactory: RoomAliasResolverPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onBackClicked() { private val inputs = inputs<RoomAliasResolverEntryPoint.Params>()
plugins<InviteListEntryPoint.Callback>().forEach { it.onBackClicked() }
}
private fun onInviteAccepted(roomId: RoomId) { private val presenter = presenterFactory.create(
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) } inputs.roomAlias
} )
private fun onInviteClicked(roomId: RoomId) { private fun onAliasResolved(roomId: RoomId) {
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteClicked(roomId) } plugins<RoomAliasResolverEntryPoint.Callback>().forEach { it.onAliasResolved(roomId) }
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
InviteListView( RoomAliasResolverView(
state = state, state = state,
onBackClicked = ::onBackClicked, onAliasResolved = ::onAliasResolved,
onInviteAccepted = ::onInviteAccepted, onBackPressed = ::navigateUp,
onInviteDeclined = {}, modifier = modifier
onInviteClicked = ::onInviteClicked,
) )
} }
} }

View file

@ -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)
}
}

View file

@ -14,16 +14,16 @@
* limitations under the License. * 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 androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.libraries.architecture.AsyncData
import io.element.android.features.invite.impl.model.InviteListInviteSummary import io.element.android.libraries.matrix.api.core.RoomAlias
import kotlinx.collections.immutable.ImmutableList import io.element.android.libraries.matrix.api.core.RoomId
@Immutable @Immutable
data class InviteListState( data class RoomAliasResolverState(
val inviteList: ImmutableList<InviteListInviteSummary>, val roomAlias: RoomAlias,
val acceptDeclineInviteState: AcceptDeclineInviteState, val resolveState: AsyncData<RoomId>,
val eventSink: (InviteListEvents) -> Unit val eventSink: (RoomAliasResolverEvents) -> Unit
) )

View file

@ -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")

View file

@ -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 = { }
)
}

View file

@ -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,
)
}
}
}
}

View file

@ -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>

View file

@ -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,
)
}

View file

@ -128,7 +128,7 @@ class RoomDetailsPresenter @Inject constructor(
val roomMemberDetailsState = roomMemberDetailsPresenter?.present() val roomMemberDetailsState = roomMemberDetailsPresenter?.present()
return RoomDetailsState( return RoomDetailsState(
roomId = room.roomId.value, roomId = room.roomId,
roomName = roomName, roomName = roomName,
roomAlias = room.alias, roomAlias = room.alias,
roomAvatarUrl = roomAvatar, roomAvatarUrl = roomAvatar,

View file

@ -18,13 +18,15 @@ package io.element.android.features.roomdetails.impl
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState 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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
data class RoomDetailsState( data class RoomDetailsState(
val roomId: String, val roomId: RoomId,
val roomName: String, val roomName: String,
val roomAlias: String?, val roomAlias: RoomAlias?,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
val roomTopic: RoomTopicState, val roomTopic: RoomTopicState,
val memberCount: Long, val memberCount: Long,

View file

@ -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.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.features.roomdetails.impl.members.details.aRoomMemberDetailsState 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
@ -71,9 +73,9 @@ fun aDmRoomMember(
) )
fun aRoomDetailsState( fun aRoomDetailsState(
roomId: String = "a room id", roomId: RoomId = RoomId("!aRoomId:domain.com"),
roomName: String = "Marketing", roomName: String = "Marketing",
roomAlias: String? = "#marketing:domain.com", roomAlias: RoomAlias? = RoomAlias("#marketing:domain.com"),
roomAvatarUrl: String? = null, roomAvatarUrl: String? = null,
roomTopic: RoomTopicState = RoomTopicState.ExistingTopic( roomTopic: RoomTopicState = RoomTopicState.ExistingTopic(
"Welcome to #marketing, home of the Marketing team " + "Welcome to #marketing, home of the Marketing team " +

View file

@ -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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables 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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
@ -302,9 +304,9 @@ private fun MainActionsSection(
@Composable @Composable
private fun RoomHeaderSection( private fun RoomHeaderSection(
avatarUrl: String?, avatarUrl: String?,
roomId: String, roomId: RoomId,
roomName: String, roomName: String,
roomAlias: String?, roomAlias: RoomAlias?,
openAvatarPreview: (url: String) -> Unit, openAvatarPreview: (url: String) -> Unit,
) { ) {
Column( Column(
@ -314,7 +316,7 @@ private fun RoomHeaderSection(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar( Avatar(
avatarData = AvatarData(roomId, roomName, avatarUrl, AvatarSize.RoomHeader), avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
modifier = Modifier modifier = Modifier
.size(70.dp) .size(70.dp)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
@ -329,7 +331,7 @@ private fun RoomHeaderSection(
if (roomAlias != null) { if (roomAlias != null) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = roomAlias, text = roomAlias.value,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View file

@ -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.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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 io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import kotlinx.coroutines.CoroutineScope 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 import kotlinx.coroutines.launch
class RoomMemberDetailsPresenter @AssistedInject constructor( class RoomMemberDetailsPresenter @AssistedInject constructor(
@ -56,20 +61,24 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) } var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val roomMember by room.getRoomMemberAsState(roomMemberId) val roomMember by room.getRoomMemberAsState(roomMemberId)
var userProfile by remember { mutableStateOf<MatrixUser?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
// the room member is not really live... val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember(roomMember) { LaunchedEffect(Unit) {
val isIgnored = roomMember?.isIgnored client.ignoredUsersFlow
if (isIgnored == null) { .map { ignoredUsers -> roomMemberId in ignoredUsers }
mutableStateOf(AsyncData.Uninitialized) .distinctUntilChanged()
} else { .onEach { isBlocked.value = AsyncData.Success(it) }
mutableStateOf(AsyncData.Success(isIgnored)) .launchIn(this)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Update room member info when opening this screen // 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` // We don't need to assign the result as it will be automatically propagated by `room.getRoomMemberAsState`
room.getUpdatedMember(roomMemberId) 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) { fun handleEvents(event: RoomMemberDetailsEvents) {
@ -105,16 +114,34 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
} }
} }
val userName by produceState(initialValue = roomMember?.displayName) { val userName: String? by produceState(
room.userDisplayName(roomMemberId).onSuccess { displayName -> initialValue = roomMember?.displayName ?: userProfile?.displayName,
if (displayName != null) value = 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) { val userAvatar: String? by produceState(
room.userAvatarUrl(roomMemberId).onSuccess { avatarUrl -> initialValue = roomMember?.avatarUrl ?: userProfile?.avatarUrl,
if (avatarUrl != null) value = avatarUrl key1 = roomMember,
} key2 = userProfile,
) {
value = room.userAvatarUrl(roomMemberId)
.fold(
onSuccess = { it },
onFailure = {
// Fallback to user profile
userProfile?.avatarUrl
}
)
} }
return RoomMemberDetailsState( return RoomMemberDetailsState(
@ -124,7 +151,7 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
isBlocked = isBlocked.value, isBlocked = isBlocked.value,
startDmActionState = startDmActionState.value, startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog, displayConfirmationDialog = confirmationDialog,
isCurrentUser = client.isMe(roomMember?.userId), isCurrentUser = client.isMe(roomMemberId),
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
@ -132,28 +159,18 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch { private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch {
isBlockedState.value = AsyncData.Loading(false) isBlockedState.value = AsyncData.Loading(false)
client.ignoreUser(userId) client.ignoreUser(userId)
.fold( .onFailure {
onSuccess = { isBlockedState.value = AsyncData.Failure(it, false)
isBlockedState.value = AsyncData.Success(true) }
room.getUpdatedMember(userId) // Note: on success, ignoredUserList will be updated.
},
onFailure = {
isBlockedState.value = AsyncData.Failure(it, false)
}
)
} }
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch { private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<AsyncData<Boolean>>) = launch {
isBlockedState.value = AsyncData.Loading(true) isBlockedState.value = AsyncData.Loading(true)
client.unignoreUser(userId) client.unignoreUser(userId)
.fold( .onFailure {
onSuccess = { isBlockedState.value = AsyncData.Failure(it, true)
isBlockedState.value = AsyncData.Success(false) }
room.getUpdatedMember(userId) // Note: on success, ignoredUserList will be updated.
},
onFailure = {
isBlockedState.value = AsyncData.Failure(it, true)
}
)
} }
} }

View file

@ -117,7 +117,7 @@ class RoomDetailsPresenterTests {
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value) assertThat(initialState.roomId).isEqualTo(room.roomId)
assertThat(initialState.roomName).isEqualTo(room.name) assertThat(initialState.roomName).isEqualTo(room.name)
assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl) assertThat(initialState.roomAvatarUrl).isEqualTo(room.avatarUrl)
assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!)) assertThat(initialState.roomTopic).isEqualTo(RoomTopicState.ExistingTopic(room.topic!!))

View file

@ -18,6 +18,7 @@ package io.element.android.features.roomdetails.members.details
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.StartDMAction 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.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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_ROOM_ID
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -58,12 +60,12 @@ class RoomMemberDetailsPresenterTests {
} }
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMember = roomMember roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(roomMember.userId.value) assertThat(initialState.userId).isEqualTo(roomMember.userId.value)
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
@ -85,12 +87,12 @@ class RoomMemberDetailsPresenterTests {
} }
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMember = roomMember roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
@ -108,12 +110,12 @@ class RoomMemberDetailsPresenterTests {
} }
val presenter = createRoomMemberDetailsPresenter( val presenter = createRoomMemberDetailsPresenter(
room = room, room = room,
roomMember = roomMember roomMemberId = roomMember.userId
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.userName).isEqualTo(roomMember.displayName) assertThat(initialState.userName).isEqualTo(roomMember.displayName)
assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) 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 @Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createRoomMemberDetailsPresenter() val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true))
val dialogState = awaitItem() val dialogState = awaitItem()
@ -142,17 +171,24 @@ class RoomMemberDetailsPresenterTests {
@Test @Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf(roomMember.userId))
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue() assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)) initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
client.emitIgnoreUserList(listOf())
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse() assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
} }
} }
@ -165,7 +201,7 @@ class RoomMemberDetailsPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)) initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue() assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem() 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 @Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest { fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createRoomMemberDetailsPresenter() val presenter = createRoomMemberDetailsPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem() val dialogState = awaitItem()
@ -202,7 +257,7 @@ class RoomMemberDetailsPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID) val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
val startDMFailureResult = AsyncAction.Failure(A_THROWABLE) 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( private fun createRoomMemberDetailsPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
room: MatrixRoom = aMatrixRoom(), room: MatrixRoom = aMatrixRoom(),
roomMember: RoomMember = aRoomMember(), roomMemberId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction() startDMAction: StartDMAction = FakeStartDMAction()
): RoomMemberDetailsPresenter { ): RoomMemberDetailsPresenter {
return RoomMemberDetailsPresenter( return RoomMemberDetailsPresenter(
roomMemberId = roomMember.userId, roomMemberId = roomMemberId,
client = client, client = client,
room = room, room = room,
startDMAction = startDMAction startDMAction = startDMAction

View file

@ -20,6 +20,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -29,7 +30,7 @@ import kotlinx.parcelize.Parcelize
data class RoomDescription( data class RoomDescription(
val roomId: RoomId, val roomId: RoomId,
val name: String?, val name: String?,
val alias: String?, val alias: RoomAlias?,
val topic: String?, val topic: String?,
val avatarUrl: String?, val avatarUrl: String?,
val joinRule: JoinRule, val joinRule: JoinRule,
@ -42,14 +43,14 @@ data class RoomDescription(
} }
@IgnoredOnParcel @IgnoredOnParcel
val computedName = name ?: alias ?: roomId.value val computedName = name ?: alias?.value ?: roomId.value
@IgnoredOnParcel @IgnoredOnParcel
val computedDescription: String val computedDescription: String
get() { get() {
return when { return when {
topic != null -> topic topic != null -> topic
name != null && alias != null -> alias name != null && alias != null -> alias.value
name == null && alias == null -> "" name == null && alias == null -> ""
else -> roomId.value else -> roomId.value
} }

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdirectory.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction 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 io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -69,7 +70,7 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
roomId = RoomId("!exa:matrix.org"), roomId = RoomId("!exa:matrix.org"),
name = "Element X Android", name = "Element X Android",
topic = "Element X is a secure, private and decentralized messenger.", 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, avatarUrl = null,
joinRule = RoomDescription.JoinRule.PUBLIC, joinRule = RoomDescription.JoinRule.PUBLIC,
numberOfMembers = 2765, numberOfMembers = 2765,
@ -78,7 +79,7 @@ fun aRoomDescriptionList(): ImmutableList<RoomDescription> {
roomId = RoomId("!exi:matrix.org"), roomId = RoomId("!exi:matrix.org"),
name = "Element X iOS", name = "Element X iOS",
topic = "Element X is a secure, private and decentralized messenger.", 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, avatarUrl = null,
joinRule = RoomDescription.JoinRule.UNKNOWN, joinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers = 356, numberOfMembers = 356,

View file

@ -34,7 +34,6 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onCreateRoomClicked() fun onCreateRoomClicked()
fun onSettingsClicked() fun onSettingsClicked()
fun onSessionConfirmRecoveryKeyClicked() fun onSessionConfirmRecoveryKeyClicked()
fun onInvitesClicked()
fun onRoomSettingsClicked(roomId: RoomId) fun onRoomSettingsClicked(roomId: RoomId)
fun onReportBugClicked() fun onReportBugClicked()
fun onRoomDirectorySearchClicked() fun onRoomDirectorySearchClicked()

View file

@ -75,7 +75,6 @@ dependencies {
testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -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,
)
}

View file

@ -33,11 +33,9 @@ open class RoomListContentStateProvider : PreviewParameterProvider<RoomListConte
} }
internal fun aRoomsContentState( internal fun aRoomsContentState(
invitesState: InvitesState = InvitesState.NoInvites,
securityBannerState: SecurityBannerState = SecurityBannerState.None, securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
summaries = summaries, summaries = summaries,
) )
@ -46,6 +44,4 @@ internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16) internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState( internal fun anEmptyContentState() = RoomListContentState.Empty
invitesState: InvitesState = InvitesState.NoInvites,
) = RoomListContentState.Empty(invitesState)

View file

@ -24,6 +24,8 @@ sealed interface RoomListEvents {
data object DismissRequestVerificationPrompt : RoomListEvents data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents data object DismissRecoveryKeyPrompt : RoomListEvents
data object ToggleSearchResults : 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 data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
sealed interface ContextMenuEvents : RoomListEvents sealed interface ContextMenuEvents : RoomListEvents

View file

@ -29,6 +29,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode 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.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
@ -43,6 +44,7 @@ class RoomListNode @AssistedInject constructor(
private val presenter: RoomListPresenter, private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase, private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -68,10 +70,6 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() } plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
} }
private fun onInvitesClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onInvitesClicked() }
}
private fun onRoomSettingsClicked(roomId: RoomId) { private fun onRoomSettingsClicked(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) } plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClicked(roomId) }
} }
@ -101,11 +99,17 @@ class RoomListNode @AssistedInject constructor(
onSettingsClicked = this::onOpenSettings, onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked, onCreateRoomClicked = this::onCreateRoomClicked,
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked, onRoomSettingsClicked = this::onRoomSettingsClicked,
onMenuActionClicked = { onMenuActionClicked(activity, it) }, onMenuActionClicked = { onMenuActionClicked(activity, it) },
onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked, onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked,
modifier = modifier, modifier = modifier,
) ) {
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onInviteAccepted = this::onRoomClicked,
onInviteDeclined = { },
modifier = Modifier
)
}
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@ -32,15 +33,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction 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.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.SessionPreferencesStore 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.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState 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.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.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -79,7 +83,6 @@ class RoomListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
private val inviteStateDataSource: InviteStateDataSource,
private val leaveRoomPresenter: LeaveRoomPresenter, private val leaveRoomPresenter: LeaveRoomPresenter,
private val roomListDataSource: RoomListDataSource, private val roomListDataSource: RoomListDataSource,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
@ -89,6 +92,7 @@ class RoomListPresenter @Inject constructor(
private val migrationScreenPresenter: Presenter<MigrationScreenState>, private val migrationScreenPresenter: Presenter<MigrationScreenState>,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService() private val encryptionService: EncryptionService = client.encryptionService()
private val syncService: SyncService = client.syncService() private val syncService: SyncService = client.syncService()
@ -101,6 +105,7 @@ class RoomListPresenter @Inject constructor(
val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
roomListDataSource.launchIn(this) roomListDataSource.launchIn(this)
@ -131,6 +136,16 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite) is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId) is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(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, filtersState = filtersState,
searchState = searchState, searchState = searchState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -192,16 +208,11 @@ class RoomListPresenter @Inject constructor(
} }
return when { return when {
showMigration -> RoomListContentState.Migration showMigration -> RoomListContentState.Migration
showEmpty -> { showEmpty -> RoomListContentState.Empty
val invitesState = inviteStateDataSource.inviteState()
RoomListContentState.Empty(invitesState)
}
showSkeleton -> RoomListContentState.Skeleton(count = 16) showSkeleton -> RoomListContentState.Skeleton(count = 16)
else -> { else -> {
val invitesState = inviteStateDataSource.inviteState()
val securityBannerState by securityBannerState(securityBannerDismissed) val securityBannerState by securityBannerState(securityBannerDismissed)
RoomListContentState.Rooms( RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList() summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList()
) )
@ -283,3 +294,10 @@ class RoomListPresenter @Inject constructor(
client.roomListService.updateAllRoomsVisibleRange(extendedRange) client.roomListService.updateAllRoomsVisibleRange(extendedRange)
} }
} }
@VisibleForTesting
internal fun RoomListRoomSummary.toInviteData() = InviteData(
roomId = roomId,
roomName = name,
isDirect = isDirect,
)

View file

@ -17,6 +17,7 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable 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.leaveroom.api.LeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@ -37,6 +38,7 @@ data class RoomListState(
val filtersState: RoomListFiltersState, val filtersState: RoomListFiltersState,
val searchState: RoomListSearchState, val searchState: RoomListSearchState,
val contentState: RoomListContentState, val contentState: RoomListContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (RoomListEvents) -> Unit, val eventSink: (RoomListEvents) -> Unit,
) { ) {
val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms val displayFilters = filtersState.isFeatureEnabled && contentState is RoomListContentState.Rooms
@ -70,9 +72,8 @@ enum class SecurityBannerState {
sealed interface RoomListContentState { sealed interface RoomListContentState {
data object Migration : RoomListContentState data object Migration : RoomListContentState
data class Skeleton(val count: Int) : RoomListContentState data class Skeleton(val count: Int) : RoomListContentState
data class Empty(val invitesState: InvitesState) : RoomListContentState data object Empty : RoomListContentState
data class Rooms( data class Rooms(
val invitesState: InvitesState,
val securityBannerState: SecurityBannerState, val securityBannerState: SecurityBannerState,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
) : RoomListContentState ) : RoomListContentState

View file

@ -17,11 +17,15 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState 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.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState 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.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.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState import io.element.android.features.roomlist.impl.search.aRoomListSearchState
@ -41,8 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(), aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false), 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(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)), aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@ -64,6 +66,7 @@ internal fun aRoomListState(
searchState: RoomListSearchState = aRoomListSearchState(), searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false), filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
contentState: RoomListContentState = aRoomsContentState(), contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (RoomListEvents) -> Unit = {} eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState( ) = RoomListState(
matrixUser = matrixUser, matrixUser = matrixUser,
@ -75,11 +78,23 @@ internal fun aRoomListState(
filtersState = filtersState, filtersState = filtersState,
searchState = searchState, searchState = searchState,
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink, eventSink = eventSink,
) )
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> { internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf( 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( aRoomListRoomSummary(
name = "Room", name = "Room",
numberOfUnreadMessages = 1, numberOfUnreadMessages = 1,
@ -98,11 +113,11 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
id = "!roomId3:domain", id = "!roomId3:domain",
isPlaceholder = true, displayType = RoomSummaryDisplayType.PLACEHOLDER,
), ),
aRoomListRoomSummary( aRoomListRoomSummary(
id = "!roomId4:domain", id = "!roomId4:domain",
isPlaceholder = true, displayType = RoomSummaryDisplayType.PLACEHOLDER,
), ),
) )
} }

View file

@ -55,23 +55,17 @@ fun RoomListView(
onSettingsClicked: () -> Unit, onSettingsClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit, onMenuActionClicked: (RoomListMenuAction) -> Unit,
onRoomDirectorySearchClicked: () -> Unit, onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
ConnectivityIndicatorContainer( ConnectivityIndicatorContainer(
modifier = modifier, modifier = modifier,
isOnline = state.hasNetworkConnection, isOnline = state.hasNetworkConnection,
) { topPadding -> ) { topPadding ->
Box { Box {
fun onRoomLongClicked(
roomListRoomSummary: RoomListRoomSummary
) {
state.eventSink(RoomListEvents.ShowContextMenu(roomListRoomSummary))
}
if (state.contextMenu is RoomListState.ContextMenu.Shown) { if (state.contextMenu is RoomListState.ContextMenu.Shown) {
RoomListContextMenu( RoomListContextMenu(
contextMenu = state.contextMenu, contextMenu = state.contextMenu,
@ -83,21 +77,19 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState) LeaveRoomView(state = state.leaveRoomState)
RoomListScaffold( RoomListScaffold(
modifier = Modifier.padding(top = topPadding),
state = state, state = state,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
onOpenSettings = onSettingsClicked, onOpenSettings = onSettingsClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
modifier = Modifier.padding(top = topPadding),
) )
// This overlaid view will only be visible when state.displaySearchResults is true // This overlaid view will only be visible when state.displaySearchResults is true
RoomListSearchView( RoomListSearchView(
state = state.searchState, state = state.searchState,
eventSink = state.eventSink,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
modifier = Modifier modifier = Modifier
.statusBarsPadding() .statusBarsPadding()
@ -105,6 +97,7 @@ fun RoomListView(
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
) )
acceptDeclineInviteView()
} }
} }
} }
@ -115,10 +108,8 @@ private fun RoomListScaffold(
state: RoomListState, state: RoomListState,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
onMenuActionClicked: (RoomListMenuAction) -> Unit, onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -153,9 +144,7 @@ private fun RoomListScaffold(
eventSink = state.eventSink, eventSink = state.eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked, onRoomClicked = ::onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding) .consumeWindowInsets(padding)
@ -180,7 +169,7 @@ private fun RoomListScaffold(
) )
} }
internal fun RoomListRoomSummary.contentType() = isPlaceholder internal fun RoomListRoomSummary.contentType() = displayType.ordinal
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@ -191,9 +180,9 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onSettingsClicked = {}, onSettingsClicked = {},
onConfirmRecoveryKeyClicked = {}, onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},
onMenuActionClicked = {}, onMenuActionClicked = {},
onRoomDirectorySearchClicked = {}, onRoomDirectorySearchClicked = {},
acceptDeclineInviteView = {},
) )
} }

View file

@ -44,8 +44,6 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.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.R
import io.element.android.features.roomlist.impl.RoomListContentState import io.element.android.features.roomlist.impl.RoomListContentState
import io.element.android.features.roomlist.impl.RoomListContentStateProvider import io.element.android.features.roomlist.impl.RoomListContentStateProvider
@ -75,9 +73,7 @@ fun RoomListContentView(
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
@ -92,8 +88,6 @@ fun RoomListContentView(
} }
is RoomListContentState.Empty -> { is RoomListContentState.Empty -> {
EmptyView( EmptyView(
state = contentState,
onInvitesClicked = onInvitesClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
) )
} }
@ -104,8 +98,6 @@ fun RoomListContentView(
eventSink = eventSink, eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
) )
} }
} }
@ -128,30 +120,21 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
@Composable @Composable
private fun EmptyView( private fun EmptyView(
state: RoomListContentState.Empty,
onCreateRoomClicked: () -> Unit, onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier 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(), 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 @Composable
@ -161,8 +144,6 @@ private fun RoomsView(
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) { if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@ -176,8 +157,6 @@ private fun RoomsView(
eventSink = eventSink, eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) )
} }
@ -189,8 +168,6 @@ private fun RoomsViewList(
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit, onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit, onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@ -228,11 +205,6 @@ private fun RoomsViewList(
else -> Unit 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 // 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. // is moved to the top of the list.
itemsIndexed( itemsIndexed(
@ -242,7 +214,7 @@ private fun RoomsViewList(
RoomSummaryRow( RoomSummaryRow(
room = room, room = room,
onClick = onRoomClicked, onClick = onRoomClicked,
onLongClick = onRoomLongClicked, eventSink = eventSink,
) )
if (index != state.summaries.lastIndex) { if (index != state.summaries.lastIndex) {
HorizontalDivider() HorizontalDivider()
@ -305,8 +277,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
eventSink = {}, eventSink = {},
onConfirmRecoveryKeyClicked = {}, onConfirmRecoveryKeyClicked = {},
onRoomClicked = {}, onRoomClicked = {},
onRoomLongClicked = {},
onCreateRoomClicked = {}, onCreateRoomClicked = {},
onInvitesClicked = {}
) )
} }

View file

@ -20,15 +20,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement 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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -36,26 +39,36 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.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.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider 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.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.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.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator 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.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
internal val minHeight = 84.dp internal val minHeight = 84.dp
@ -63,30 +76,67 @@ internal val minHeight = 84.dp
internal fun RoomSummaryRow( internal fun RoomSummaryRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit, eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (room.isPlaceholder) { when (room.displayType) {
RoomSummaryPlaceholderRow( RoomSummaryDisplayType.PLACEHOLDER -> {
modifier = modifier, RoomSummaryPlaceholderRow(modifier = modifier)
) }
} else { RoomSummaryDisplayType.INVITE -> {
RoomSummaryRealRow( RoomSummaryScaffoldRow(
room = room, room = room,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = {
modifier = modifier 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun RoomSummaryRealRow( private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit, onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) { ) {
val clickModifier = Modifier.combinedClickable( val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) }, onClick = { onClick(room) },
@ -100,94 +150,186 @@ private fun RoomSummaryRealRow(
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .heightIn(min = minHeight)
.then(clickModifier) .then(clickModifier)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 11.dp) .padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
) { ) {
Avatar( Avatar(room.avatarData)
room Spacer(modifier = Modifier.width(16.dp))
.avatarData,
modifier = Modifier
.align(Alignment.CenterVertically)
)
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() content = content,
.padding(start = 16.dp) )
}
}
@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()) { val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
NameAndTimestampRow(room = room) if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
)
} }
Row(modifier = Modifier.fillMaxWidth()) { if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
LastMessageAndIndicatorRow(room = room) NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
UnreadIndicatorAtom(
color = tint
)
} }
} }
} }
} }
@Composable @Composable
private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) { private fun InviteNameAndIndicatorRow(
// Name name: String,
Text( modifier: Modifier = Modifier,
modifier = Modifier ) {
.weight(1f) Row(
.padding(end = 16.dp), modifier = modifier.fillMaxWidth(),
style = ElementTheme.typography.fontBodyLgMedium, horizontalArrangement = spacedBy(16.dp),
text = room.name, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.roomListRoomName(), ) {
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis modifier = Modifier.weight(1f),
) style = ElementTheme.typography.fontBodyLgMedium,
// Timestamp text = name,
Text( color = MaterialTheme.roomListRoomName(),
text = room.timestamp ?: "", maxLines = 1,
style = ElementTheme.typography.fontBodySmMedium, overflow = TextOverflow.Ellipsis
color = if (room.isHighlighted) { )
ElementTheme.colors.unreadIndicator UnreadIndicatorAtom(
} else { color = ElementTheme.colors.unreadIndicator
MaterialTheme.roomListRoomMessageDate() )
}, }
)
} }
@Composable @Composable
private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) { private fun InviteSenderRow(
// Last Message sender: InviteSender,
val attributedLastMessage = room.lastMessage as? AnnotatedString modifier: Modifier = Modifier
?: 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
Row( Row(
modifier = Modifier.height(16.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) { ) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary Avatar(avatarData = sender.avatarData)
if (room.hasRoomCall) { Text(
OnGoingCallIcon( text = sender.annotatedString(),
color = tint, style = ElementTheme.typography.fontBodyMdRegular,
) color = MaterialTheme.colorScheme.secondary,
} )
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { }
NotificationOffIndicatorAtom() }
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom() @Composable
} private fun InviteButtonsRow(
if (room.hasNewContent) { onAcceptClicked: () -> Unit,
UnreadIndicatorAtom( onDeclineClicked: () -> Unit,
color = tint 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( RoomSummaryRow(
room = data, room = data,
onClick = {}, onClick = {},
onLongClick = {} eventSink = {},
) )
} }

View file

@ -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 }

View file

@ -16,13 +16,16 @@
package io.element.android.features.roomlist.impl.datasource 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.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import javax.inject.Inject import javax.inject.Inject
@ -35,7 +38,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
return RoomListRoomSummary( return RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
isPlaceholder = true, displayType = RoomSummaryDisplayType.PLACEHOLDER,
name = "Short name", name = "Short name",
timestamp = "hh:mm", timestamp = "hh:mm",
lastMessage = "Last message for placeholder", lastMessage = "Last message for placeholder",
@ -46,8 +49,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
isMarkedUnread = false, isMarkedUnread = false,
userDefinedNotificationMode = null, userDefinedNotificationMode = null,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDirect = false,
isFavorite = false, isFavorite = false,
inviteSender = null,
isDm = false,
canonicalAlias = null,
) )
} }
} }
@ -73,11 +79,29 @@ class RoomListRoomSummaryFactory @Inject constructor(
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(), }.orEmpty(),
avatarData = avatarData, avatarData = avatarData,
isPlaceholder = false,
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode, userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall, hasRoomCall = roomSummary.details.hasRoomCall,
isDm = roomSummary.details.isDm, isDirect = roomSummary.details.isDirect,
isFavorite = roomSummary.details.isFavorite, 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
}
) )
} }
} }

View file

@ -26,13 +26,15 @@ enum class RoomListFilter(val stringResource: Int) {
Unread(R.string.screen_roomlist_filter_unreads), Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people), People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms), 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) { get() = when (this) {
Rooms -> People Rooms -> setOf(People, Invites)
People -> Rooms People -> setOf(Rooms, Invites)
Unread -> null Unread -> setOf(Invites)
Favourites -> null Favourites -> setOf(Invites)
Invites -> setOf(Rooms, People, Unread, Favourites)
} }
} }

View file

@ -53,6 +53,10 @@ data class RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_favourites_empty_state_title, title = R.string.screen_roomlist_filter_favourites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle 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( else -> RoomListFiltersEmptyStateResources(

View file

@ -66,6 +66,7 @@ class RoomListFiltersPresenter @Inject constructor(
RoomListFilter.People -> MatrixRoomListFilter.Category.People RoomListFilter.People -> MatrixRoomListFilter.Category.People
RoomListFilter.Unread -> MatrixRoomListFilter.Unread RoomListFilter.Unread -> MatrixRoomListFilter.Unread
RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite RoomListFilter.Favourites -> MatrixRoomListFilter.Favorite
RoomListFilter.Invites -> MatrixRoomListFilter.Invite
} }
} }
) )

View file

@ -54,7 +54,7 @@ class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStra
isSelected = true 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 { val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState( FilterSelectionState(
filter = it, filter = it,

View file

@ -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
)
)
)
}
}
}

View file

@ -18,14 +18,17 @@ package io.element.android.features.roomlist.impl.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable @Immutable
data class RoomListRoomSummary( data class RoomListRoomSummary(
val id: String, val id: String,
val displayType: RoomSummaryDisplayType,
val roomId: RoomId, val roomId: RoomId,
val name: String, val name: String,
val canonicalAlias: RoomAlias?,
val numberOfUnreadMessages: Int, val numberOfUnreadMessages: Int,
val numberOfUnreadMentions: Int, val numberOfUnreadMentions: Int,
val numberOfUnreadNotifications: Int, val numberOfUnreadNotifications: Int,
@ -33,18 +36,21 @@ data class RoomListRoomSummary(
val timestamp: String?, val timestamp: String?,
val lastMessage: CharSequence?, val lastMessage: CharSequence?,
val avatarData: AvatarData, val avatarData: AvatarData,
val isPlaceholder: Boolean,
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val isDirect: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
) { val inviteSender: InviteSender?,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
isMarkedUnread isMarkedUnread ||
displayType == RoomSummaryDisplayType.INVITE
val hasNewContent = numberOfUnreadMessages > 0 || val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 || numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 || numberOfUnreadNotifications > 0 ||
isMarkedUnread isMarkedUnread ||
displayType == RoomSummaryDisplayType.INVITE
} }

View file

@ -19,14 +19,16 @@ package io.element.android.features.roomlist.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> { open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary> override val values: Sequence<RoomListRoomSummary>
get() = sequenceOf( get() = sequenceOf(
listOf( listOf(
aRoomListRoomSummary(isPlaceholder = true), aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(), aRoomListRoomSummary(),
aRoomListRoomSummary(lastMessage = null), aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary( aRoomListRoomSummary(
@ -80,9 +82,37 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
) )
}.flatten() }.flatten()
}.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() ).flatten()
} }
internal fun anInviteSender(
userId: String,
displayName: String,
) = InviteSender(
userId = UserId(userId),
displayName = displayName,
avatarData = AvatarData(userId, displayName, size = AvatarSize.InviteSender),
)
internal fun aRoomListRoomSummary( internal fun aRoomListRoomSummary(
id: String = "!roomId:domain", id: String = "!roomId:domain",
name: String = "Room name", name: String = "Room name",
@ -92,12 +122,15 @@ internal fun aRoomListRoomSummary(
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message", lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" }, timestamp: String? = lastMessage?.let { "88:88" },
isPlaceholder: Boolean = false,
notificationMode: RoomNotificationMode? = null, notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDirect: Boolean = false,
isDm: Boolean = false, isDm: Boolean = false,
isFavorite: Boolean = false, isFavorite: Boolean = false,
inviteSender: InviteSender? = null,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: RoomAlias? = null,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@ -109,9 +142,12 @@ internal fun aRoomListRoomSummary(
timestamp = timestamp, timestamp = timestamp,
lastMessage = lastMessage, lastMessage = lastMessage,
avatarData = avatarData, avatarData = avatarData,
isPlaceholder = isPlaceholder,
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
isDirect = isDirect,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite, isFavorite = isFavorite,
inviteSender = inviteSender,
displayType = displayType,
canonicalAlias = canonicalAlias,
) )

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,12 +14,13 @@
* limitations under the License. * 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 * Represents the type of display for a room list item.
*/
interface SeenInvitesStore { enum class RoomSummaryDisplayType {
fun seenRoomIds(): Flow<Set<RoomId>> PLACEHOLDER,
suspend fun markAsSeen(roomIds: Set<RoomId>) ROOM,
INVITE
} }

View file

@ -44,6 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.R 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.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.contentType import io.element.android.features.roomlist.impl.contentType
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
@ -65,8 +66,8 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
internal fun RoomListSearchView( internal fun RoomListSearchView(
state: RoomListSearchState, state: RoomListSearchState,
eventSink: (RoomListEvents) -> Unit,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onRoomDirectorySearchClicked: () -> Unit, onRoomDirectorySearchClicked: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -90,7 +91,7 @@ internal fun RoomListSearchView(
RoomListSearchContent( RoomListSearchContent(
state = state, state = state,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked, eventSink = eventSink,
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
) )
} }
@ -102,8 +103,8 @@ internal fun RoomListSearchView(
@Composable @Composable
private fun RoomListSearchContent( private fun RoomListSearchContent(
state: RoomListSearchState, state: RoomListSearchState,
eventSink: (RoomListEvents) -> Unit,
onRoomClicked: (RoomId) -> Unit, onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onRoomDirectorySearchClicked: () -> Unit, onRoomDirectorySearchClicked: () -> Unit,
) { ) {
val borderColor = MaterialTheme.colorScheme.tertiary val borderColor = MaterialTheme.colorScheme.tertiary
@ -193,7 +194,7 @@ private fun RoomListSearchContent(
RoomSummaryRow( RoomSummaryRow(
room = room, room = room,
onClick = ::onRoomClicked, onClick = ::onRoomClicked,
onLongClick = onRoomLongClicked, eventSink = eventSink,
) )
} }
} }
@ -220,7 +221,7 @@ internal fun RoomListSearchResultContentPreview(@PreviewParameter(RoomListSearch
RoomListSearchContent( RoomListSearchContent(
state = state, state = state,
onRoomClicked = {}, onRoomClicked = {},
onRoomLongClicked = {}, eventSink = {},
onRoomDirectorySearchClicked = {}, onRoomDirectorySearchClicked = {},
) )
} }

View file

@ -2,6 +2,12 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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="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_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</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> <string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>

View file

@ -21,14 +21,15 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction 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.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.preferences.api.store.SessionPreferencesStore 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.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState 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.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState 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.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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState 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.MutablePresenter
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate 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 io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule 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 @Test
fun `present - show context menu`() = runTest { fun `present - show context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) 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( private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(), networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(),
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(), leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply { lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE) givenFormat(A_FORMATTED_DATE)
@ -626,11 +641,11 @@ class RoomListPresenterTests {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() }, filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() }, searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
snackbarDispatcher = snackbarDispatcher, snackbarDispatcher = snackbarDispatcher,
inviteStateDataSource = inviteStateDataSource,
leaveRoomPresenter = leaveRoomPresenter, leaveRoomPresenter = leaveRoomPresenter,
roomListDataSource = RoomListDataSource( roomListDataSource = RoomListDataSource(
roomListService = client.roomListService, roomListService = client.roomListService,
@ -652,5 +667,6 @@ class RoomListPresenterTests {
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter, filtersPresenter = filtersPresenter,
analyticsService = analyticsService, analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
) )
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction 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.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -93,7 +94,9 @@ class RoomListViewTest {
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val room0 = state.contentAsRooms().summaries.first() val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback -> ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView( rule.setRoomListView(
state = state, state = state,
@ -109,7 +112,9 @@ class RoomListViewTest {
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val room0 = state.contentAsRooms().summaries.first() val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
rule.setRoomListView( rule.setRoomListView(
state = state, state = state,
) )
@ -136,19 +141,20 @@ class RoomListViewTest {
} }
@Test @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 eventsRecorder = EventsRecorder<RoomListEvents>()
val state = aRoomListState( val state = aRoomListState(
contentState = aRoomsContentState(invitesState = InvitesState.NewInvites),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
ensureCalledOnce { callback -> val invitedRoom = state.contentAsRooms().summaries.first {
rule.setRoomListView( it.displayType == RoomSummaryDisplayType.INVITE
state = state,
onInvitesClicked = callback,
)
rule.clickOn(CommonStrings.action_invites_list)
} }
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(), onSettingsClicked: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(), onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(), onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(),
@ -170,10 +175,10 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onSettingsClicked = onSettingsClicked, onSettingsClicked = onSettingsClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,
onRoomSettingsClicked = onRoomSettingsClicked, onRoomSettingsClicked = onRoomSettingsClicked,
onMenuActionClicked = onMenuActionClicked, onMenuActionClicked = onMenuActionClicked,
onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, onRoomDirectorySearchClicked = onRoomDirectorySearchClicked,
acceptDeclineInviteView = { },
) )
} }
} }

View file

@ -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)
}
}
}

View file

@ -64,6 +64,15 @@ class RoomListFiltersEmptyStateResourcesTest {
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle) 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 @Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() { fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites) val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)

View file

@ -45,6 +45,7 @@ class RoomListFiltersPresenterTests {
filterSelectionState(RoomListFilter.People, false), filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false), filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false), filterSelectionState(RoomListFilter.Favourites, false),
filterSelectionState(RoomListFilter.Invites, false),
) )
} }
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -84,6 +85,7 @@ class RoomListFiltersPresenterTests {
filterSelectionState(RoomListFilter.People, false), filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false), filterSelectionState(RoomListFilter.Rooms, false),
filterSelectionState(RoomListFilter.Favourites, false), filterSelectionState(RoomListFilter.Favourites, false),
filterSelectionState(RoomListFilter.Invites, false),
).inOrder() ).inOrder()
assertThat(state.selectedFilters()).isEmpty() assertThat(state.selectedFilters()).isEmpty()
val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All val roomListCurrentFilter = roomListService.allRooms.currentFilter.value as MatrixRoomListFilter.All

View file

@ -72,6 +72,15 @@ class RoomListRoomSummaryTest {
assertThat(sut.isHighlighted).isTrue() assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).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( internal fun createRoomListRoomSummary(
@ -81,6 +90,7 @@ internal fun createRoomListRoomSummary(
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null, userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false, isFavorite: Boolean = false,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -92,9 +102,12 @@ internal fun createRoomListRoomSummary(
timestamp = A_FORMATTED_DATE, timestamp = A_FORMATTED_DATE,
lastMessage = "", lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem), avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
isPlaceholder = false, displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode, userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDirect = false,
isFavorite = isFavorite, isFavorite = isFavorite,
canonicalAlias = null,
inviteSender = null,
isDm = false,
) )

View file

@ -9,8 +9,8 @@ ksp = "1.9.23-1.0.20"
firebaseAppDistribution = "4.2.0" firebaseAppDistribution = "4.2.0"
# AndroidX # AndroidX
core = "1.12.0" core = "1.13.0"
datastore = "1.0.0" datastore = "1.1.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
constraintlayout_compose = "1.0.1" constraintlayout_compose = "1.0.1"
lifecycle = "2.7.0" lifecycle = "2.7.0"
@ -18,8 +18,8 @@ activity = "1.8.2"
media3 = "1.3.1" media3 = "1.3.1"
# Compose # Compose
compose_bom = "2024.04.00" compose_bom = "2024.04.01"
composecompiler = "1.5.11" composecompiler = "1.5.12"
# Coroutines # Coroutines
coroutines = "1.8.0" coroutines = "1.8.0"

View file

@ -18,7 +18,3 @@ package io.element.android.libraries.deeplink
internal const val SCHEME = "elementx" internal const val SCHEME = "elementx"
internal const val HOST = "open" 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