Merge pull request #2714 from element-hq/feature/fga/room_list_invites

[Feature] Room list invites
This commit is contained in:
ganfra
2024-04-17 23:04:31 +02:00
committed by GitHub
291 changed files with 842 additions and 2278 deletions

View File

@@ -45,11 +45,4 @@ class IntentProviderImpl @Inject constructor(
data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
}
}
override fun getInviteListIntent(sessionId: SessionId): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.inviteList(sessionId).toUri()
}
}
}

View File

@@ -67,16 +67,6 @@ class IntentProviderImplTest {
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
}
@Test
fun `test getInviteListIntent`() {
val sut = createIntentProviderImpl()
val result = sut.getInviteListIntent(
sessionId = A_SESSION_ID,
)
result.commonAssertions()
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/invites")
}
private fun createIntentProviderImpl(): IntentProviderImpl {
return IntentProviderImpl(
context = RuntimeEnvironment.getApplication() as Context,

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.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
@@ -48,7 +47,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
@@ -62,17 +60,13 @@ import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@@ -81,7 +75,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
@@ -95,11 +88,9 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService,
@@ -160,23 +151,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
)
observeSyncStateAndNetworkStatus()
observeInvitesLoadingState()
}
private fun observeInvitesLoadingState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
matrixClient.roomListService.invites.loadingState
.collect { inviteState ->
when (inviteState) {
is RoomList.LoadingState.Loaded -> if (inviteState.numberOfRooms == 0) {
backstack.removeLast(NavTarget.InviteList)
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
}
}
}
@OptIn(FlowPreview::class)
@@ -233,9 +207,6 @@ class LoggedInFlowNode @AssistedInject constructor(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize
data object InviteList : NavTarget
@Parcelize
data object Ftue : NavTarget
@@ -272,10 +243,6 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
override fun onInvitesClicked() {
backstack.push(NavTarget.InviteList)
}
override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.Details))
}
@@ -351,25 +318,6 @@ class LoggedInFlowNode @AssistedInject constructor(
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.build()
}
NavTarget.InviteList -> {
val callback = object : InviteListEntryPoint.Callback {
override fun onBackClicked() {
backstack.pop()
}
override fun onInviteClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onInviteAccepted(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
}
inviteListEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
@@ -410,16 +358,6 @@ class LoggedInFlowNode @AssistedInject constructor(
}
}
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
}
}
private fun canShowRoomList(): Boolean {
return ftueService.state.value is FtueState.Complete
}

View File

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

View File

@@ -1,38 +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.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface InviteListEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onBackClicked()
fun onInviteClicked(roomId: RoomId)
fun onInviteAccepted(roomId: RoomId)
}
}

View File

@@ -1,25 +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.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore {
fun seenRoomIds(): Flow<Set<RoomId>>
suspend fun markAsSeen(roomIds: Set<RoomId>)
}

View File

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

View File

@@ -1,45 +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 com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.features.invite.impl.invitelist.InviteListNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultInviteListEntryPoint @Inject constructor() : InviteListEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): InviteListEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : InviteListEntryPoint.NodeBuilder {
override fun callback(callback: InviteListEntryPoint.Callback): InviteListEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<InviteListNode>(buildContext, plugins)
}
}
}
}

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,24 +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 io.element.android.features.invite.impl.model.InviteListInviteSummary
sealed interface InviteListEvents {
data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents
}

View File

@@ -1,61 +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.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class InviteListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: InviteListPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onBackClicked() {
plugins<InviteListEntryPoint.Callback>().forEach { it.onBackClicked() }
}
private fun onInviteAccepted(roomId: RoomId) {
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteAccepted(roomId) }
}
private fun onInviteClicked(roomId: RoomId) {
plugins<InviteListEntryPoint.Callback>().forEach { it.onInviteClicked(roomId) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
InviteListView(
state = state,
onBackClicked = ::onBackClicked,
onInviteAccepted = ::onInviteAccepted,
onInviteDeclined = {},
onInviteClicked = ::onInviteClicked,
)
}
}

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,29 +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.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.impl.model.InviteListInviteSummary
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class InviteListState(
val inviteList: ImmutableList<InviteListInviteSummary>,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (InviteListEvents) -> Unit
)

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

@@ -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,29 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.invite.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
api(projects.features.invite.api)
}

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

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

View File

@@ -16,6 +16,7 @@
package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -31,7 +32,6 @@ 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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import org.jetbrains.annotations.VisibleForTesting
import java.util.Optional
class JoinRoomPresenter @AssistedInject constructor(

View File

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

View File

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

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(
invitesState: InvitesState = InvitesState.NoInvites,
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
) = RoomListContentState.Rooms(
invitesState = invitesState,
securityBannerState = securityBannerState,
summaries = summaries,
)
@@ -46,6 +44,4 @@ internal fun aMigrationContentState() = RoomListContentState.Migration
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)
internal fun anEmptyContentState(
invitesState: InvitesState = InvitesState.NoInvites,
) = RoomListContentState.Empty(invitesState)
internal fun anEmptyContentState() = RoomListContentState.Empty

View File

@@ -24,6 +24,8 @@ sealed interface RoomListEvents {
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents
data object ToggleSearchResults : RoomListEvents
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
sealed interface ContextMenuEvents : RoomListEvents

View File

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

View File

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

View File

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

View File

@@ -17,11 +17,15 @@
package io.element.android.features.roomlist.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.model.InviteSender
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.features.roomlist.impl.search.aRoomListSearchState
@@ -41,8 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(),
aRoomListState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aRoomListState(hasNetworkConnection = false),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.SeenInvites)),
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
@@ -64,6 +66,7 @@ internal fun aRoomListState(
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(isFeatureEnabled = false),
contentState: RoomListContentState = aRoomsContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
matrixUser = matrixUser,
@@ -75,11 +78,23 @@ internal fun aRoomListState(
filtersState = filtersState,
searchState = searchState,
contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf(
aRoomListRoomSummary(
name = "Room Invited",
avatarData = AvatarData("!roomId", "Room with Alice and Bob", size = AvatarSize.RoomListItem),
id = "!roomId:domain",
inviteSender = InviteSender(
userId = UserId("@bob:domain"),
displayName = "Bob",
avatarData = AvatarData("@bob:domain", "Bob", size = AvatarSize.InviteSender),
),
displayType = RoomSummaryDisplayType.INVITE,
),
aRoomListRoomSummary(
name = "Room",
numberOfUnreadMessages = 1,
@@ -98,11 +113,11 @@ internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
),
aRoomListRoomSummary(
id = "!roomId3:domain",
isPlaceholder = true,
displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
aRoomListRoomSummary(
id = "!roomId4:domain",
isPlaceholder = true,
displayType = RoomSummaryDisplayType.PLACEHOLDER,
),
)
}

View File

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

View File

@@ -44,8 +44,6 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.InvitesEntryPointView
import io.element.android.features.roomlist.impl.InvitesState
import io.element.android.features.roomlist.impl.R
import io.element.android.features.roomlist.impl.RoomListContentState
import io.element.android.features.roomlist.impl.RoomListContentStateProvider
@@ -75,9 +73,7 @@ fun RoomListContentView(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
@@ -92,8 +88,6 @@ fun RoomListContentView(
}
is RoomListContentState.Empty -> {
EmptyView(
state = contentState,
onInvitesClicked = onInvitesClicked,
onCreateRoomClicked = onCreateRoomClicked,
)
}
@@ -104,8 +98,6 @@ fun RoomListContentView(
eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
)
}
}
@@ -128,30 +120,21 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) {
@Composable
private fun EmptyView(
state: RoomListContentState.Empty,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClicked,
)
},
modifier = modifier.fillMaxSize(),
) {
if (state.invitesState != InvitesState.NoInvites) {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
EmptyScaffold(
title = R.string.screen_roomlist_empty_title,
subtitle = R.string.screen_roomlist_empty_message,
action = {
Button(
text = stringResource(CommonStrings.action_start_chat),
leadingIcon = IconSource.Vector(CompoundIcons.Compose()),
onClick = onCreateRoomClicked,
)
},
modifier = Modifier.fillMaxSize(),
)
}
)
}
@Composable
@@ -161,8 +144,6 @@ private fun RoomsView(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) {
@@ -176,8 +157,6 @@ private fun RoomsView(
eventSink = eventSink,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
onInvitesClicked = onInvitesClicked,
modifier = modifier.fillMaxSize(),
)
}
@@ -189,8 +168,6 @@ private fun RoomsViewList(
eventSink: (RoomListEvents) -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
onInvitesClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
@@ -228,11 +205,6 @@ private fun RoomsViewList(
else -> Unit
}
if (state.invitesState != InvitesState.NoInvites) {
item {
InvitesEntryPointView(onInvitesClicked, state.invitesState)
}
}
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list.
itemsIndexed(
@@ -242,7 +214,7 @@ private fun RoomsViewList(
RoomSummaryRow(
room = room,
onClick = onRoomClicked,
onLongClick = onRoomLongClicked,
eventSink = eventSink,
)
if (index != state.summaries.lastIndex) {
HorizontalDivider()
@@ -305,8 +277,6 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
eventSink = {},
onConfirmRecoveryKeyClicked = {},
onRoomClicked = {},
onRoomLongClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {}
)
}

View File

@@ -20,15 +20,18 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -36,26 +39,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomlist.impl.RoomListEvents
import io.element.android.features.roomlist.impl.model.InviteSender
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
internal val minHeight = 84.dp
@@ -63,30 +75,67 @@ internal val minHeight = 84.dp
internal fun RoomSummaryRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier,
) {
if (room.isPlaceholder) {
RoomSummaryPlaceholderRow(
modifier = modifier,
)
} else {
RoomSummaryRealRow(
room = room,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier
)
when (room.displayType) {
RoomSummaryDisplayType.PLACEHOLDER -> {
RoomSummaryPlaceholderRow(modifier = modifier)
}
RoomSummaryDisplayType.INVITE -> {
RoomSummaryScaffoldRow(
room = room,
onClick = onClick,
onLongClick = {
Timber.d("Long click on invite room")
},
modifier = modifier
) {
InviteNameAndIndicatorRow(name = room.name)
InviteSubtitle(isDirect = room.isDirect, inviteSender = room.inviteSender, canonicalAlias = room.canonicalAlias)
if (!room.isDirect && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp))
InviteSenderRow(sender = room.inviteSender)
}
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRow(
onAcceptClicked = {
eventSink(RoomListEvents.AcceptInvite(room))
},
onDeclineClicked = {
eventSink(RoomListEvents.DeclineInvite(room))
}
)
}
}
RoomSummaryDisplayType.ROOM -> {
RoomSummaryScaffoldRow(
room = room,
onClick = onClick,
onLongClick = {
eventSink(RoomListEvents.ShowContextMenu(room))
},
modifier = modifier
) {
NameAndTimestampRow(
name = room.name,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
LastMessageAndIndicatorRow(room = room)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RoomSummaryRealRow(
private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary,
onClick: (RoomListRoomSummary) -> Unit,
onLongClick: (RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val clickModifier = Modifier.combinedClickable(
onClick = { onClick(room) },
@@ -100,94 +149,186 @@ private fun RoomSummaryRealRow(
.fillMaxWidth()
.heightIn(min = minHeight)
.then(clickModifier)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min),
) {
Avatar(
room
.avatarData,
modifier = Modifier
.align(Alignment.CenterVertically)
)
Avatar(room.avatarData)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
modifier = Modifier.fillMaxWidth(),
content = content,
)
}
}
@Composable
private fun NameAndTimestampRow(
name: String,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
// Name
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Timestamp
Text(
text = timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
MaterialTheme.roomListRoomMessageDate()
},
)
}
}
@Composable
private fun InviteSubtitle(
isDirect: Boolean,
inviteSender: InviteSender?,
canonicalAlias: String?,
modifier: Modifier = Modifier
) {
val subtitle = if (isDirect) {
inviteSender?.userId?.value
} else {
canonicalAlias
}
if (subtitle != null) {
Text(
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.roomListRoomMessage(),
modifier = modifier,
)
}
}
@Composable
private fun LastMessageAndIndicatorRow(
room: RoomListRoomSummary,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(28.dp)
) {
// Last Message
val attributedLastMessage = room.lastMessage as? AnnotatedString
?: AnnotatedString(room.lastMessage.orEmpty().toString())
Text(
modifier = Modifier.weight(1f),
text = attributedLastMessage,
color = MaterialTheme.roomListRoomMessage(),
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Call and unread
Row(
modifier = Modifier.height(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.fillMaxWidth()) {
NameAndTimestampRow(room = room)
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
)
}
Row(modifier = Modifier.fillMaxWidth()) {
LastMessageAndIndicatorRow(room = room)
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
UnreadIndicatorAtom(
color = tint
)
}
}
}
}
@Composable
private fun RowScope.NameAndTimestampRow(room: RoomListRoomSummary) {
// Name
Text(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp),
style = ElementTheme.typography.fontBodyLgMedium,
text = room.name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Timestamp
Text(
text = room.timestamp ?: "",
style = ElementTheme.typography.fontBodySmMedium,
color = if (room.isHighlighted) {
ElementTheme.colors.unreadIndicator
} else {
MaterialTheme.roomListRoomMessageDate()
},
)
private fun InviteNameAndIndicatorRow(
name: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
text = name,
color = MaterialTheme.roomListRoomName(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
UnreadIndicatorAtom(
color = ElementTheme.colors.unreadIndicator
)
}
}
@Composable
private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
// Last Message
val attributedLastMessage = room.lastMessage as? AnnotatedString
?: AnnotatedString(room.lastMessage.orEmpty().toString())
Text(
modifier = Modifier
.weight(1f)
.padding(end = 28.dp),
text = attributedLastMessage,
color = MaterialTheme.roomListRoomMessage(),
style = ElementTheme.typography.fontBodyMdRegular,
minLines = 2,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Call and unread
private fun InviteSenderRow(
sender: InviteSender,
modifier: Modifier = Modifier
) {
Row(
modifier = Modifier.height(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier.fillMaxWidth(),
) {
val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
if (room.hasRoomCall) {
OnGoingCallIcon(
color = tint,
)
}
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
UnreadIndicatorAtom(
color = tint
)
}
Avatar(avatarData = sender.avatarData)
Text(
text = sender.annotatedString(),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@Composable
private fun InviteButtonsRow(
onAcceptClicked: () -> Unit,
onDeclineClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.padding(),
horizontalArrangement = spacedBy(12.dp)
) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClicked,
size = ButtonSize.Medium,
modifier = Modifier.weight(1f),
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClicked,
size = ButtonSize.Medium,
modifier = Modifier.weight(1f),
)
}
}
@@ -229,6 +370,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider
RoomSummaryRow(
room = data,
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
import io.element.android.features.roomlist.impl.model.InviteSender
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import javax.inject.Inject
@@ -35,7 +38,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
return RoomListRoomSummary(
id = id,
roomId = RoomId(id),
isPlaceholder = true,
displayType = RoomSummaryDisplayType.PLACEHOLDER,
name = "Short name",
timestamp = "hh:mm",
lastMessage = "Last message for placeholder",
@@ -46,8 +49,11 @@ class RoomListRoomSummaryFactory @Inject constructor(
isMarkedUnread = false,
userDefinedNotificationMode = null,
hasRoomCall = false,
isDm = false,
isDirect = false,
isFavorite = false,
inviteSender = null,
isDm = false,
canonicalAlias = null,
)
}
}
@@ -73,11 +79,29 @@ class RoomListRoomSummaryFactory @Inject constructor(
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(),
avatarData = avatarData,
isPlaceholder = false,
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall,
isDm = roomSummary.details.isDm,
isDirect = roomSummary.details.isDirect,
isFavorite = roomSummary.details.isFavorite,
inviteSender = roomSummary.details.inviter?.run {
InviteSender(
userId = userId,
displayName = displayName ?: "",
avatarData = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.InviteSender,
),
)
},
isDm = roomSummary.details.isDm,
canonicalAlias = roomSummary.details.canonicalAlias,
displayType = if (roomSummary.details.currentUserMembership == CurrentUserMembership.INVITED) {
RoomSummaryDisplayType.INVITE
} else {
RoomSummaryDisplayType.ROOM
}
)
}
}

View File

@@ -26,13 +26,15 @@ enum class RoomListFilter(val stringResource: Int) {
Unread(R.string.screen_roomlist_filter_unreads),
People(R.string.screen_roomlist_filter_people),
Rooms(R.string.screen_roomlist_filter_rooms),
Favourites(R.string.screen_roomlist_filter_favourites);
Favourites(R.string.screen_roomlist_filter_favourites),
Invites(R.string.screen_roomlist_filter_invites);
val oppositeFilter: RoomListFilter?
val incompatibleFilters: Set<RoomListFilter>
get() = when (this) {
Rooms -> People
People -> Rooms
Unread -> null
Favourites -> null
Rooms -> setOf(People, Invites)
People -> setOf(Rooms, Invites)
Unread -> setOf(Invites)
Favourites -> setOf(Invites)
Invites -> setOf(Rooms, People, Unread, Favourites)
}
}

View File

@@ -53,6 +53,10 @@ data class RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_favourites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_favourites_empty_state_subtitle
)
RoomListFilter.Invites -> RoomListFiltersEmptyStateResources(
title = R.string.screen_roomlist_filter_invites_empty_state_title,
subtitle = R.string.screen_roomlist_filter_mixed_empty_state_subtitle
)
}
}
else -> RoomListFiltersEmptyStateResources(

View File

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

View File

@@ -54,7 +54,7 @@ class DefaultFilterSelectionStrategy @Inject constructor() : FilterSelectionStra
isSelected = true
)
}
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.mapNotNull { it.oppositeFilter }.toSet()
val unselectedFilters = RoomListFilter.entries - selectedFilters - selectedFilters.flatMap { it.incompatibleFilters }.toSet()
val unselectedFilterStates = unselectedFilters.map {
FilterSelectionState(
filter = it,

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

@@ -24,8 +24,10 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable
data class RoomListRoomSummary(
val id: String,
val displayType: RoomSummaryDisplayType,
val roomId: RoomId,
val name: String,
val canonicalAlias: String?,
val numberOfUnreadMessages: Int,
val numberOfUnreadMentions: Int,
val numberOfUnreadNotifications: Int,
@@ -33,18 +35,21 @@ data class RoomListRoomSummary(
val timestamp: String?,
val lastMessage: CharSequence?,
val avatarData: AvatarData,
val isPlaceholder: Boolean,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val isDirect: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
) {
val inviteSender: InviteSender?,
) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
isMarkedUnread
isMarkedUnread ||
displayType == RoomSummaryDisplayType.INVITE
val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
isMarkedUnread
isMarkedUnread ||
displayType == RoomSummaryDisplayType.INVITE
}

View File

@@ -20,13 +20,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary>
get() = sequenceOf(
listOf(
aRoomListRoomSummary(isPlaceholder = true),
aRoomListRoomSummary(displayType = RoomSummaryDisplayType.PLACEHOLDER),
aRoomListRoomSummary(),
aRoomListRoomSummary(lastMessage = null),
aRoomListRoomSummary(
@@ -80,9 +81,37 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSu
)
}.flatten()
}.flatten(),
listOf(
aRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
inviteSender = anInviteSender(
userId = "@alice:matrix.org",
displayName = "Alice",
),
canonicalAlias = "#alias:matrix.org",
),
aRoomListRoomSummary(
name = "Bob",
displayType = RoomSummaryDisplayType.INVITE,
inviteSender = anInviteSender(
userId = "@bob:matrix.org",
displayName = "Bob",
),
isDirect = true,
)
),
).flatten()
}
internal fun anInviteSender(
userId: String,
displayName: String,
) = InviteSender(
userId = UserId(userId),
displayName = displayName,
avatarData = AvatarData(userId, displayName, size = AvatarSize.InviteSender),
)
internal fun aRoomListRoomSummary(
id: String = "!roomId:domain",
name: String = "Room name",
@@ -92,12 +121,15 @@ internal fun aRoomListRoomSummary(
isMarkedUnread: Boolean = false,
lastMessage: String? = "Last message",
timestamp: String? = lastMessage?.let { "88:88" },
isPlaceholder: Boolean = false,
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDirect: Boolean = false,
isDm: Boolean = false,
isFavorite: Boolean = false,
inviteSender: InviteSender? = null,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: String? = null,
) = RoomListRoomSummary(
id = id,
roomId = RoomId(id),
@@ -109,9 +141,12 @@ internal fun aRoomListRoomSummary(
timestamp = timestamp,
lastMessage = lastMessage,
avatarData = avatarData,
isPlaceholder = isPlaceholder,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDirect = isDirect,
isDm = isDm,
isFavorite = isFavorite,
inviteSender = inviteSender,
displayType = displayType,
canonicalAlias = canonicalAlias,
)

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");
* you may not use this file except in compliance with the License.
@@ -14,12 +14,13 @@
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.datasource
package io.element.android.features.roomlist.impl.model
import androidx.compose.runtime.Composable
import io.element.android.features.roomlist.impl.InvitesState
interface InviteStateDataSource {
@Composable
fun inviteState(): InvitesState
/**
* Represents the type of display for a room list item.
*/
enum class RoomSummaryDisplayType {
PLACEHOLDER,
ROOM,
INVITE
}

View File

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

View File

@@ -2,6 +2,12 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Enter your recovery key"</string>
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline the invitation to join %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Decline invite"</string>
<string name="screen_invites_decline_direct_chat_message">"Are you sure you want to decline this private chat with %1$s?"</string>
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
<string name="screen_invites_empty_list">"No Invites"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>

View File

@@ -21,14 +21,15 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.features.roomlist.impl.datasource.FakeInviteDataSource
import io.element.android.features.roomlist.impl.datasource.InviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
@@ -52,6 +53,7 @@ import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncState
@@ -77,11 +79,14 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.MutablePresenter
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -303,38 +308,6 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
val roomListService = FakeRoomListService()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(
inviteStateDataSource = inviteStateDataSource,
coroutineScope = scope,
client = FakeMatrixClient(roomListService = roomListService),
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val firstItem = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)
inviteStateFlow.value = InvitesState.NewInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
scope.cancel()
}
}
@Test
fun `present - show context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
@@ -609,11 +582,53 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - when a room is invited then accept and decline events are sent to acceptDeclinePresenter`() = runTest {
val eventSinkRecorder = lambdaRecorder { _: AcceptDeclineInviteEvents -> }
val acceptDeclinePresenter = Presenter {
anAcceptDeclineInviteState(eventSink = eventSinkRecorder)
}
val roomListService = FakeRoomListService()
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val roomSummary = aRoomSummaryFilled(
currentUserMembership = CurrentUserMembership.INVITED
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(roomSummary))
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = matrixClient,
acceptDeclineInvitePresenter = acceptDeclinePresenter
)
presenter.test {
val state = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last()
val roomListRoomSummary = state.contentAsRooms().summaries.first {
it.id == roomSummary.identifier()
}
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
val inviteData = roomListRoomSummary.toInviteData()
assert(eventSinkRecorder)
.isCalledExactly(2)
.withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))),
)
}
}
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
networkMonitor: NetworkMonitor = FakeNetworkMonitor(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource: InviteStateDataSource = FakeInviteDataSource(),
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE)
@@ -626,11 +641,11 @@ class RoomListPresenterTests {
analyticsService: AnalyticsService = FakeAnalyticsService(),
filtersPresenter: Presenter<RoomListFiltersState> = Presenter { aRoomListFiltersState() },
searchPresenter: Presenter<RoomListSearchState> = Presenter { aRoomListSearchState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
) = RoomListPresenter(
client = client,
networkMonitor = networkMonitor,
snackbarDispatcher = snackbarDispatcher,
inviteStateDataSource = inviteStateDataSource,
leaveRoomPresenter = leaveRoomPresenter,
roomListDataSource = RoomListDataSource(
roomListService = client.roomListService,
@@ -652,5 +667,6 @@ class RoomListPresenterTests {
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
)
}

View File

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

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

@@ -1,33 +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.collectAsState
import io.element.android.features.roomlist.impl.InvitesState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class FakeInviteDataSource(
private val flow: Flow<InvitesState> = flowOf()
) : InviteStateDataSource {
@Composable
override fun inviteState(): InvitesState {
val state = flow.collectAsState(initial = InvitesState.NoInvites)
return state.value
}
}

View File

@@ -64,6 +64,15 @@ class RoomListFiltersEmptyStateResourcesTest {
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_favourites_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has only invites filter`() {
val selectedFilters = listOf(RoomListFilter.Invites)
val result = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters)
assertThat(result).isNotNull()
assertThat(result?.title).isEqualTo(R.string.screen_roomlist_filter_invites_empty_state_title)
assertThat(result?.subtitle).isEqualTo(R.string.screen_roomlist_filter_mixed_empty_state_subtitle)
}
@Test
fun `fromSelectedFilters should return exact RoomListFiltersEmptyStateResources when selectedFilters has multiple filters`() {
val selectedFilters = listOf(RoomListFilter.Unread, RoomListFilter.People, RoomListFilter.Rooms, RoomListFilter.Favourites)

View File

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

View File

@@ -72,6 +72,15 @@ class RoomListRoomSummaryTest {
assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).isTrue()
}
@Test
fun `when display type is invite then isHighlighted and hasNewContent are true`() {
val sut = createRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE,
)
assertThat(sut.isHighlighted).isTrue()
assertThat(sut.hasNewContent).isTrue()
}
}
internal fun createRoomListRoomSummary(
@@ -81,6 +90,7 @@ internal fun createRoomListRoomSummary(
isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
) = RoomListRoomSummary(
id = A_ROOM_ID.value,
roomId = A_ROOM_ID,
@@ -92,9 +102,12 @@ internal fun createRoomListRoomSummary(
timestamp = A_FORMATTED_DATE,
lastMessage = "",
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME, size = AvatarSize.RoomListItem),
isPlaceholder = false,
displayType = displayType,
userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false,
isDm = false,
isDirect = false,
isFavorite = isFavorite,
canonicalAlias = null,
inviteSender = null,
isDm = false,
)

View File

@@ -18,7 +18,3 @@ package io.element.android.libraries.deeplink
internal const val SCHEME = "elementx"
internal const val HOST = "open"
object DeepLinkPaths {
const val INVITE_LIST = "invites"
}

View File

@@ -36,13 +36,4 @@ class DeepLinkCreator @Inject constructor() {
}
}
}
fun inviteList(sessionId: SessionId): String {
return buildString {
append("$SCHEME://$HOST/")
append(sessionId.value)
append("/")
append(DeepLinkPaths.INVITE_LIST)
}
}
}

View File

@@ -29,7 +29,4 @@ sealed interface DeeplinkData {
/** The target is a room, with the given [sessionId], [roomId] and optionally a [threadId]. */
data class Room(override val sessionId: SessionId, val roomId: RoomId, val threadId: ThreadId?) : DeeplinkData
/** The target is the invites list, with the given [sessionId]. */
data class InviteList(override val sessionId: SessionId) : DeeplinkData
}

View File

@@ -39,7 +39,6 @@ class DeeplinkParser @Inject constructor() {
return when (val screenPathComponent = pathBits.elementAtOrNull(1)) {
null -> DeeplinkData.Root(sessionId)
DeepLinkPaths.INVITE_LIST -> DeeplinkData.InviteList(sessionId)
else -> {
val roomId = screenPathComponent.let(::RoomId)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)

View File

@@ -33,11 +33,4 @@ class DeepLinkCreatorTest {
assertThat(sut.room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
.isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
}
@Test
fun inviteList() {
val sut = DeepLinkCreator()
assertThat(sut.inviteList(A_SESSION_ID))
.isEqualTo("elementx://open/@alice:server.org/invites")
}
}

View File

@@ -36,8 +36,6 @@ class DeeplinkParserTest {
"elementx://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
const val A_URI_FOR_INVITE_LIST =
"elementx://open/@alice:server.org/invites"
}
private val sut = DeeplinkParser()
@@ -50,8 +48,6 @@ class DeeplinkParserTest {
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null))
assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD)))
.isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID))
assertThat(sut.getFromIntent(createIntent(A_URI_FOR_INVITE_LIST)))
.isEqualTo(DeeplinkData.InviteList(A_SESSION_ID))
}
@Test

View File

@@ -57,6 +57,11 @@ sealed interface RoomListFilter {
*/
data object Favorite : RoomListFilter
/**
* A filter that matches rooms with Invited membership.
*/
data object Invite : RoomListFilter
/**
* A filter that matches either Group or People rooms.
*/

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.api.roomlist
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.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
@@ -49,6 +50,7 @@ data class RoomSummaryDetails(
val hasRoomCall: Boolean,
val isDm: Boolean,
val isFavorite: Boolean,
val currentUserMembership: CurrentUserMembership,
) {
val lastMessageTimestamp = lastMessage?.originServerTs
}

View File

@@ -71,6 +71,7 @@ class RustMatrixClientFactory @Inject constructor(
val syncService = client.syncService()
.withUtdHook(utdTracker)
.withUnifiedInvitesInRoomList(true)
.finish()
RustMatrixClient(

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
@@ -25,21 +26,25 @@ val RoomListFilter.predicate
is RoomListFilter.Any -> { _: RoomSummary -> true }
RoomListFilter.None -> { _: RoomSummary -> false }
RoomListFilter.Category.Group -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && !roomSummary.details.isDirect
roomSummary is RoomSummary.Filled && !roomSummary.details.isDirect && !roomSummary.isInvited()
}
RoomListFilter.Category.People -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.isDirect
roomSummary is RoomSummary.Filled && roomSummary.details.isDirect && !roomSummary.isInvited()
}
RoomListFilter.Favorite -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.isFavorite
roomSummary is RoomSummary.Filled && roomSummary.details.isFavorite && !roomSummary.isInvited()
}
RoomListFilter.Unread -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled &&
!roomSummary.isInvited() &&
(roomSummary.details.numUnreadNotifications > 0 || roomSummary.details.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.name.contains(pattern, ignoreCase = true)
}
RoomListFilter.Invite -> { roomSummary: RoomSummary ->
roomSummary.isInvited()
}
}
fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
@@ -55,3 +60,5 @@ fun List<RoomSummary>.filter(filter: RoomListFilter): List<RoomSummary> {
else -> filter(filter.predicate)
}
}
private fun RoomSummary.isInvited() = this is RoomSummary.Filled && this.details.currentUserMembership == CurrentUserMembership.INVITED

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomInfo
@@ -45,6 +46,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
hasRoomCall = roomInfo.hasRoomCall,
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
isFavorite = roomInfo.isFavourite,
currentUserMembership = roomInfo.membership.map(),
)
}
}

View File

@@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.roomlist
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
@@ -54,6 +55,11 @@ class RoomListFilterTests {
name = "Room to search"
)
)
private val invitedRoom = aRoomSummaryFilled(
aRoomSummaryDetails(
currentUserMembership = CurrentUserMembership.INVITED
)
)
private val roomSummaries = listOf(
regularRoom,
@@ -61,7 +67,8 @@ class RoomListFilterTests {
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch
roomToSearch,
invitedRoom
)
@Test
@@ -100,6 +107,12 @@ class RoomListFilterTests {
assertThat(roomSummaries.filter(filter)).containsExactly(markedAsUnreadRoom, unreadNotificationRoom)
}
@Test
fun `Room list filter invites`() = runTest {
val filter = RoomListFilter.Invite
assertThat(roomSummaries.filter(filter)).containsExactly(invitedRoom)
}
@Test
fun `Room list filter normalized match room name`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("search")

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
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.message.RoomMessage
@@ -40,6 +41,7 @@ fun aRoomSummaryFilled(
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
notificationMode: RoomNotificationMode? = null,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = RoomSummary.Filled(
aRoomSummaryDetails(
roomId = roomId,
@@ -50,6 +52,7 @@ fun aRoomSummaryFilled(
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
notificationMode = notificationMode,
currentUserMembership = currentUserMembership,
)
)
@@ -73,6 +76,7 @@ fun aRoomSummaryDetails(
hasRoomCall: Boolean = false,
isDm: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@@ -89,6 +93,7 @@ fun aRoomSummaryDetails(
hasRoomCall = hasRoomCall,
isDm = isDm,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
)
fun aRoomMessage(

View File

@@ -44,6 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
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.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage
@@ -118,6 +119,7 @@ fun aRoomSummaryDetails(
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@@ -134,4 +136,5 @@ fun aRoomSummaryDetails(
numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
)

View File

@@ -30,9 +30,4 @@ interface IntentProvider {
roomId: RoomId?,
threadId: ThreadId?,
): Intent
/**
* Provide an intent to start the application on the invite list.
*/
fun getInviteListIntent(sessionId: SessionId): Intent
}

View File

@@ -157,7 +157,7 @@ class NotificationCreator @Inject constructor(
// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
.apply {
// Build the pending intent for when the notification is clicked
setContentIntent(pendingIntentFactory.createInviteListPendingIntent(inviteNotifiableEvent.sessionId))
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
if (inviteNotifiableEvent.noisy) {
// Compat

View File

@@ -19,7 +19,6 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId
@@ -128,9 +127,4 @@ class PendingIntentFactory @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
fun createInviteListPendingIntent(sessionId: SessionId): PendingIntent {
val intent = intentProvider.getInviteListIntent(sessionId)
return PendingIntentCompat.getActivity(context, 0, intent, 0, false)!!
}
}

View File

@@ -24,6 +24,4 @@ import io.element.android.libraries.push.impl.intent.IntentProvider
class FakeIntentProvider : IntentProvider {
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent()
override fun getInviteListIntent(sessionId: SessionId) = Intent()
}

View File

@@ -65,4 +65,5 @@ dependencies {
implementation(projects.libraries.featureflag.impl)
implementation(projects.services.analytics.noop)
implementation(libs.coroutines.core)
implementation(projects.libraries.push.test)
}

View File

@@ -20,12 +20,12 @@ import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import io.element.android.features.invite.impl.DefaultSeenInvitesStore
import io.element.android.features.invite.impl.response.AcceptDeclineInvitePresenter
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
import io.element.android.features.leaveroom.impl.LeaveRoomPresenterImpl
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
import io.element.android.features.roomlist.impl.RoomListPresenter
import io.element.android.features.roomlist.impl.RoomListView
import io.element.android.features.roomlist.impl.datasource.DefaultInviteStateDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.datasource.RoomListRoomSummaryFactory
import io.element.android.features.roomlist.impl.filters.RoomListFiltersPresenter
@@ -50,6 +50,7 @@ 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.RoomMembershipObserver
import io.element.android.libraries.preferences.impl.store.DefaultSessionPreferencesStore
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.noop.NoopAnalyticsService
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import kotlinx.coroutines.launch
@@ -96,7 +97,6 @@ class RoomListScreen(
client = matrixClient,
networkMonitor = NetworkMonitorImpl(context, Singleton.appScope),
snackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource = DefaultInviteStateDataSource(matrixClient, DefaultSeenInvitesStore(context), coroutineDispatchers),
leaveRoomPresenter = LeaveRoomPresenterImpl(matrixClient, RoomMembershipObserver(), coroutineDispatchers),
roomListDataSource = RoomListDataSource(
roomListService = matrixClient.roomListService,
@@ -132,6 +132,11 @@ class RoomListScreen(
featureFlagService = featureFlagService,
filterSelectionStrategy = DefaultFilterSelectionStrategy(),
),
acceptDeclineInvitePresenter = AcceptDeclineInvitePresenter(
client = matrixClient,
analyticsService = NoopAnalyticsService(),
notificationDrawerManager = FakeNotificationDrawerManager(),
),
analyticsService = NoopAnalyticsService(),
)
@@ -154,11 +159,13 @@ class RoomListScreen(
onSettingsClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},
onRoomSettingsClicked = {},
onMenuActionClicked = {},
onRoomDirectorySearchClicked = {},
modifier = modifier,
acceptDeclineInviteView = {
AcceptDeclineInviteView(state = state.acceptDeclineInviteState, onInviteAccepted = {}, onInviteDeclined = {})
}
)
DisposableEffect(Unit) {

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