Browse Source
Adds a CTA on the room list to view invites if there are any. The invite list presents each invite with accept/decline buttons and (for room invites) the sender details. Fixes #102test/jme/fix-danger-lint-duplicate-reports
Chris Smith
1 year ago
75 changed files with 1376 additions and 79 deletions
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
|
||||
plugins { |
||||
id("io.element.android-library") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.features.invitelist.api" |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.libraries.architecture) |
||||
} |
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
/* |
||||
* 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.invitelist.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 |
||||
|
||||
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() |
||||
} |
||||
} |
||||
|
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
|
||||
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed |
||||
@Suppress("DSL_SCOPE_VIOLATION") |
||||
plugins { |
||||
id("io.element.android-compose-library") |
||||
alias(libs.plugins.anvil) |
||||
alias(libs.plugins.ksp) |
||||
id("kotlin-parcelize") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.features.invitelist.impl" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(projects.anvilannotations) |
||||
anvil(projects.anvilcodegen) |
||||
api(projects.features.invitelist.api) |
||||
implementation(projects.libraries.core) |
||||
implementation(projects.libraries.architecture) |
||||
implementation(projects.libraries.matrix.api) |
||||
implementation(projects.libraries.matrixui) |
||||
implementation(projects.libraries.designsystem) |
||||
implementation(projects.libraries.uiStrings) |
||||
|
||||
testImplementation(libs.test.junit) |
||||
testImplementation(libs.coroutines.test) |
||||
testImplementation(libs.molecule.runtime) |
||||
testImplementation(libs.test.truth) |
||||
testImplementation(libs.test.turbine) |
||||
testImplementation(projects.libraries.matrix.test) |
||||
|
||||
ksp(libs.showkase.processor) |
||||
} |
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
/* |
||||
* 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.invitelist.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.invitelist.api.InviteListEntryPoint |
||||
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) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
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.invitelist.api.InviteListEntryPoint |
||||
import io.element.android.libraries.di.SessionScope |
||||
|
||||
@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() } |
||||
} |
||||
|
||||
@Composable |
||||
override fun View(modifier: Modifier) { |
||||
val state = presenter.present() |
||||
InviteListView( |
||||
state = state, |
||||
onBackClicked = ::onBackClicked, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.collectAsState |
||||
import androidx.compose.runtime.getValue |
||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary |
||||
import io.element.android.features.invitelist.impl.model.InviteSender |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData |
||||
import io.element.android.libraries.matrix.api.MatrixClient |
||||
import io.element.android.libraries.matrix.api.room.RoomSummary |
||||
import kotlinx.collections.immutable.toPersistentList |
||||
import javax.inject.Inject |
||||
|
||||
class InviteListPresenter @Inject constructor( |
||||
private val client: MatrixClient, |
||||
) : Presenter<InviteListState> { |
||||
|
||||
@Composable |
||||
override fun present(): InviteListState { |
||||
val invites by client |
||||
.invitesDataSource |
||||
.roomSummaries() |
||||
.collectAsState() |
||||
|
||||
return InviteListState( |
||||
inviteList = invites.mapNotNull(::toInviteSummary).toPersistentList(), |
||||
) |
||||
} |
||||
|
||||
private fun toInviteSummary(roomSummary: RoomSummary): InviteListInviteSummary? { |
||||
return when (roomSummary) { |
||||
is RoomSummary.Filled -> roomSummary.details.run { |
||||
val i = inviter |
||||
val avatarData = if (isDirect && i != null) |
||||
AvatarData( |
||||
id = i.userId.value, |
||||
name = i.displayName, |
||||
url = i.avatarUrl, |
||||
) |
||||
else |
||||
AvatarData( |
||||
id = roomId.value, |
||||
name = name, |
||||
url = avatarURLString |
||||
) |
||||
|
||||
val alias = if (isDirect) |
||||
inviter?.userId?.value |
||||
else |
||||
canonicalAlias |
||||
|
||||
InviteListInviteSummary( |
||||
roomId = roomId, |
||||
roomName = name, |
||||
roomAlias = alias, |
||||
roomAvatarData = avatarData, |
||||
sender = if (isDirect) null else inviter?.let { |
||||
InviteSender( |
||||
userId = it.userId, |
||||
displayName = it.displayName ?: "", |
||||
avatarData = AvatarData( |
||||
id = it.userId.value, |
||||
name = it.displayName, |
||||
url = it.avatarUrl, |
||||
), |
||||
) |
||||
} |
||||
) |
||||
} |
||||
else -> null |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
import androidx.compose.runtime.Immutable |
||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
|
||||
@Immutable |
||||
data class InviteListState( |
||||
val inviteList: ImmutableList<InviteListInviteSummary> |
||||
) |
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary |
||||
import io.element.android.features.invitelist.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> { |
||||
override val values: Sequence<InviteListState> |
||||
get() = sequenceOf( |
||||
aInviteListState(), |
||||
aInviteListState().copy(inviteList = persistentListOf()) |
||||
) |
||||
} |
||||
|
||||
internal fun aInviteListState() = InviteListState( |
||||
inviteList = aInviteListInviteSummaryList(), |
||||
) |
||||
|
||||
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> { |
||||
return persistentListOf( |
||||
InviteListInviteSummary( |
||||
roomId = RoomId("!id1"), |
||||
roomName = "Room 1", |
||||
roomAlias = "#room:example.org", |
||||
sender = InviteSender( |
||||
userId = UserId("@alice:example.org"), |
||||
displayName = "Alice" |
||||
), |
||||
), |
||||
InviteListInviteSummary( |
||||
roomId = RoomId("!id2"), |
||||
roomName = "Room 2", |
||||
sender = InviteSender( |
||||
userId = UserId("@bob:example.org"), |
||||
displayName = "Bob" |
||||
), |
||||
), |
||||
InviteListInviteSummary( |
||||
roomId = RoomId("!id3"), |
||||
roomName = "Alice", |
||||
roomAlias = "@alice:example.com" |
||||
), |
||||
) |
||||
} |
@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.items |
||||
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.Preview |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.features.invitelist.impl.components.InviteSummaryRow |
||||
import io.element.android.libraries.designsystem.components.button.BackButton |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||
import io.element.android.libraries.designsystem.theme.components.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.R as StringR |
||||
|
||||
@Composable |
||||
fun InviteListView( |
||||
state: InviteListState, |
||||
modifier: Modifier = Modifier, |
||||
onBackClicked: () -> Unit = {}, |
||||
onAcceptClicked: (RoomId) -> Unit = {}, |
||||
onDeclineClicked: (RoomId) -> Unit = {}, |
||||
) { |
||||
InviteListContent( |
||||
state = state, |
||||
modifier = modifier, |
||||
onBackClicked = onBackClicked, |
||||
onAcceptClicked = onAcceptClicked, |
||||
onDeclineClicked = onDeclineClicked, |
||||
) |
||||
} |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun InviteListContent( |
||||
state: InviteListState, |
||||
modifier: Modifier = Modifier, |
||||
onBackClicked: () -> Unit = {}, |
||||
onAcceptClicked: (RoomId) -> Unit = {}, |
||||
onDeclineClicked: (RoomId) -> Unit = {}, |
||||
) { |
||||
Scaffold( |
||||
modifier = modifier, |
||||
topBar = { |
||||
TopAppBar( |
||||
navigationIcon = { |
||||
BackButton(onClick = onBackClicked) |
||||
}, |
||||
title = { |
||||
Text(text = stringResource(StringR.string.action_invites_list)) |
||||
} |
||||
) |
||||
}, |
||||
content = { padding -> |
||||
Column( |
||||
modifier = Modifier.padding(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) |
||||
) { |
||||
items( |
||||
items = state.inviteList, |
||||
) { invite -> |
||||
InviteSummaryRow( |
||||
invite = invite, |
||||
onAcceptClicked = onAcceptClicked, |
||||
onDeclineClicked = onDeclineClicked, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun RoomListViewLightPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = |
||||
ElementPreviewLight { ContentToPreview(state) } |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun RoomListViewDarkPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = |
||||
ElementPreviewDark { ContentToPreview(state) } |
||||
|
||||
@Composable |
||||
private fun ContentToPreview(state: InviteListState) { |
||||
InviteListView(state) |
||||
} |
@ -0,0 +1,211 @@
@@ -0,0 +1,211 @@
|
||||
/* |
||||
* 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.invitelist.impl.components |
||||
|
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.IntrinsicSize |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
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.foundation.text.InlineTextContent |
||||
import androidx.compose.foundation.text.appendInlineContent |
||||
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.LocalDensity |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.text.Placeholder |
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign |
||||
import androidx.compose.ui.text.SpanStyle |
||||
import androidx.compose.ui.text.buildAnnotatedString |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.style.TextOverflow |
||||
import androidx.compose.ui.text.withStyle |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import io.element.android.features.invitelist.impl.R |
||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary |
||||
import io.element.android.features.invitelist.impl.model.InviteListInviteSummaryProvider |
||||
import io.element.android.features.invitelist.impl.model.InviteSender |
||||
import io.element.android.libraries.designsystem.ElementTextStyles |
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar |
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||
import io.element.android.libraries.designsystem.theme.components.Button |
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
import io.element.android.libraries.matrix.api.core.RoomId |
||||
import kotlinx.collections.immutable.persistentMapOf |
||||
import io.element.android.libraries.ui.strings.R as StringR |
||||
|
||||
private val minHeight = 72.dp |
||||
|
||||
@Composable |
||||
internal fun InviteSummaryRow( |
||||
invite: InviteListInviteSummary, |
||||
modifier: Modifier = Modifier, |
||||
onAcceptClicked: (RoomId) -> Unit = {}, |
||||
onDeclineClicked: (RoomId) -> Unit = {}, |
||||
) { |
||||
Box( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.heightIn(min = minHeight) |
||||
) { |
||||
DefaultInviteSummaryRow( |
||||
invite = invite, |
||||
onAcceptClicked = onAcceptClicked, |
||||
onDeclineClicked = onDeclineClicked, |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
internal fun DefaultInviteSummaryRow( |
||||
invite: InviteListInviteSummary, |
||||
onAcceptClicked: (RoomId) -> Unit = {}, |
||||
onDeclineClicked: (RoomId) -> Unit = {}, |
||||
) { |
||||
Row( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(horizontal = 16.dp, vertical = 12.dp) |
||||
.height(IntrinsicSize.Min), |
||||
verticalAlignment = Alignment.Top |
||||
) { |
||||
Avatar( |
||||
invite.roomAvatarData, |
||||
) |
||||
Column( |
||||
modifier = Modifier |
||||
.padding(start = 12.dp, end = 4.dp) |
||||
.alignByBaseline() |
||||
.weight(1f) |
||||
) { |
||||
// Name |
||||
Text( |
||||
fontSize = 16.sp, |
||||
fontWeight = FontWeight.SemiBold, |
||||
text = invite.roomName, |
||||
color = MaterialTheme.colorScheme.primary, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis |
||||
) |
||||
|
||||
// ID or Alias |
||||
invite.roomAlias?.let { |
||||
Text( |
||||
fontSize = 14.sp, |
||||
fontWeight = FontWeight.Normal, |
||||
text = it, |
||||
color = MaterialTheme.colorScheme.secondary, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis |
||||
) |
||||
} |
||||
|
||||
// Sender |
||||
invite.sender?.let { sender -> |
||||
SenderRow(sender = sender) |
||||
} |
||||
|
||||
// CTAs |
||||
Row(Modifier.padding(top = 12.dp)) { |
||||
OutlinedButton( |
||||
content = { Text(stringResource(StringR.string.action_decline), style = ElementTextStyles.Button) }, |
||||
onClick = { onDeclineClicked(invite.roomId) }, |
||||
modifier = Modifier.weight(1f), |
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 7.dp), |
||||
) |
||||
|
||||
Spacer(modifier = Modifier.width(12.dp)) |
||||
|
||||
Button( |
||||
content = { Text(stringResource(StringR.string.action_accept), style = ElementTextStyles.Button) }, |
||||
onClick = { onAcceptClicked(invite.roomId) }, |
||||
modifier = Modifier.weight(1f), |
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 7.dp), |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun SenderRow(sender: InviteSender) { |
||||
Text( |
||||
text = buildAnnotatedString { |
||||
val placeholder = "$" |
||||
val text = stringResource(R.string.screen_invites_invited_you, placeholder) |
||||
val nameIndex = text.indexOf(placeholder) |
||||
|
||||
// Text before the placeholder |
||||
append(text.take(nameIndex)) |
||||
|
||||
// Avatar and display name |
||||
appendInlineContent("avatar") |
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)) { |
||||
append(sender.displayName) |
||||
} |
||||
|
||||
// Text after the placeholder |
||||
append(text.drop(nameIndex + placeholder.length)) |
||||
}, |
||||
color = MaterialTheme.colorScheme.secondary, |
||||
modifier = Modifier.padding(top = 6.dp), |
||||
inlineContent = persistentMapOf( |
||||
"avatar" to InlineTextContent( |
||||
with(LocalDensity.current) { |
||||
Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center) |
||||
} |
||||
) { |
||||
Box(Modifier.fillMaxHeight().padding(end = 4.dp)) { |
||||
Avatar( |
||||
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)), |
||||
modifier = Modifier.align(Alignment.Center) |
||||
) |
||||
} |
||||
} |
||||
) |
||||
) |
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun InviteSummaryRowLightPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = |
||||
ElementPreviewLight { ContentToPreview(data) } |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun InviteSummaryRowDarkPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = |
||||
ElementPreviewDark { ContentToPreview(data) } |
||||
|
||||
@Composable |
||||
private fun ContentToPreview(data: InviteListInviteSummary) { |
||||
InviteSummaryRow(data) |
||||
} |
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
/* |
||||
* 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.invitelist.impl.model |
||||
|
||||
import androidx.compose.runtime.Immutable |
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData |
||||
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), |
||||
val sender: InviteSender? = null |
||||
) |
||||
|
||||
data class InviteSender( |
||||
val userId: UserId, |
||||
val displayName: String, |
||||
val avatarData: AvatarData = AvatarData(userId.value, displayName), |
||||
) |
@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
|
||||
/* |
||||
* 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.invitelist.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:example.com"), |
||||
aInviteListInviteSummary().copy(roomName = "Alice", sender = null), |
||||
) |
||||
} |
||||
|
||||
fun aInviteListInviteSummary() = InviteListInviteSummary( |
||||
roomId = RoomId("!room1"), |
||||
roomName = "Some room", |
||||
sender = InviteSender( |
||||
userId = UserId("@alice:example.org"), |
||||
displayName = "Alice" |
||||
), |
||||
) |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> |
||||
<string name="screen_invites_decline_chat_message">"Are you sure you want to decline joining %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 to 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 invited you"</string> |
||||
</resources> |
@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
|
||||
/* |
||||
* 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.invitelist.impl |
||||
|
||||
import app.cash.molecule.RecompositionClock |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth |
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData |
||||
import io.element.android.libraries.matrix.api.room.RoomMember |
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState |
||||
import io.element.android.libraries.matrix.api.room.RoomSummary |
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails |
||||
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_NAME |
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID |
||||
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.FakeRoomSummaryDataSource |
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Test |
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) |
||||
class InviteListPresenterTests { |
||||
|
||||
@Test |
||||
fun `present - starts empty, adds invites when received`() = runTest { |
||||
val invitesDataSource = FakeRoomSummaryDataSource() |
||||
val presenter = InviteListPresenter( |
||||
FakeMatrixClient( |
||||
sessionId = A_SESSION_ID, |
||||
invitesDataSource = invitesDataSource, |
||||
) |
||||
) |
||||
moleculeFlow(RecompositionClock.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem() |
||||
Truth.assertThat(initialState.inviteList).isEmpty() |
||||
|
||||
invitesDataSource.postRoomSummary( |
||||
listOf( |
||||
RoomSummary.Filled( |
||||
RoomSummaryDetails( |
||||
roomId = A_ROOM_ID, |
||||
name = A_ROOM_NAME, |
||||
avatarURLString = null, |
||||
isDirect = false, |
||||
lastMessage = null, |
||||
lastMessageTimestamp = null, |
||||
unreadNotificationCount = 0, |
||||
) |
||||
) |
||||
) |
||||
) |
||||
|
||||
val withInviteState = awaitItem() |
||||
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME) |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - uses user ID and avatar for direct invites`() = runTest { |
||||
val invitesDataSource = FakeRoomSummaryDataSource() |
||||
invitesDataSource.postRoomSummary( |
||||
listOf( |
||||
RoomSummary.Filled( |
||||
RoomSummaryDetails( |
||||
roomId = A_ROOM_ID, |
||||
name = A_USER_NAME, |
||||
avatarURLString = null, |
||||
isDirect = true, |
||||
lastMessage = null, |
||||
lastMessageTimestamp = null, |
||||
unreadNotificationCount = 0, |
||||
inviter = RoomMember( |
||||
userId = A_USER_ID, |
||||
displayName = A_USER_NAME, |
||||
avatarUrl = AN_AVATAR_URL, |
||||
membership = RoomMembershipState.JOIN, |
||||
isNameAmbiguous = false, |
||||
powerLevel = 0, |
||||
normalizedPowerLevel = 0, |
||||
isIgnored = false, |
||||
) |
||||
) |
||||
) |
||||
) |
||||
) |
||||
val presenter = InviteListPresenter( |
||||
FakeMatrixClient( |
||||
sessionId = A_SESSION_ID, |
||||
invitesDataSource = invitesDataSource, |
||||
) |
||||
) |
||||
moleculeFlow(RecompositionClock.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val withInviteState = awaitItem() |
||||
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_USER_NAME) |
||||
Truth.assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo( |
||||
AvatarData( |
||||
id = A_USER_ID.value, |
||||
name = A_USER_NAME, |
||||
url = AN_AVATAR_URL, |
||||
) |
||||
) |
||||
Truth.assertThat(withInviteState.inviteList[0].sender).isNull() |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `present - includes sender details for room invites`() = runTest { |
||||
val invitesDataSource = FakeRoomSummaryDataSource() |
||||
invitesDataSource.postRoomSummary( |
||||
listOf( |
||||
RoomSummary.Filled( |
||||
RoomSummaryDetails( |
||||
roomId = A_ROOM_ID, |
||||
name = A_USER_NAME, |
||||
avatarURLString = null, |
||||
isDirect = false, |
||||
lastMessage = null, |
||||
lastMessageTimestamp = null, |
||||
unreadNotificationCount = 0, |
||||
inviter = RoomMember( |
||||
userId = A_USER_ID, |
||||
displayName = A_USER_NAME, |
||||
avatarUrl = AN_AVATAR_URL, |
||||
membership = RoomMembershipState.JOIN, |
||||
isNameAmbiguous = false, |
||||
powerLevel = 0, |
||||
normalizedPowerLevel = 0, |
||||
isIgnored = false, |
||||
) |
||||
) |
||||
) |
||||
) |
||||
) |
||||
val presenter = InviteListPresenter( |
||||
FakeMatrixClient( |
||||
sessionId = A_SESSION_ID, |
||||
invitesDataSource = invitesDataSource, |
||||
) |
||||
) |
||||
moleculeFlow(RecompositionClock.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val withInviteState = awaitItem() |
||||
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) |
||||
Truth.assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME) |
||||
Truth.assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID) |
||||
Truth.assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo( |
||||
AvatarData( |
||||
id = A_USER_ID.value, |
||||
name = A_USER_NAME, |
||||
url = AN_AVATAR_URL, |
||||
) |
||||
) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
/* |
||||
* 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.libraries.designsystem.theme.components |
||||
|
||||
import androidx.compose.foundation.BorderStroke |
||||
import androidx.compose.foundation.interaction.MutableInteractionSource |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.PaddingValues |
||||
import androidx.compose.foundation.layout.RowScope |
||||
import androidx.compose.material3.ButtonColors |
||||
import androidx.compose.material3.ButtonDefaults |
||||
import androidx.compose.material3.ButtonElevation |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.Shape |
||||
import androidx.compose.ui.tooling.preview.Preview |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark |
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight |
||||
|
||||
@Composable |
||||
fun OutlinedButton( |
||||
onClick: () -> Unit, |
||||
modifier: Modifier = Modifier, |
||||
enabled: Boolean = true, |
||||
shape: Shape = ElementOutlinedButtonDefaults.shape, |
||||
colors: ButtonColors = ElementOutlinedButtonDefaults.buttonColors(), |
||||
elevation: ButtonElevation? = ElementOutlinedButtonDefaults.buttonElevation(), |
||||
border: BorderStroke? = ElementOutlinedButtonDefaults.border, |
||||
contentPadding: PaddingValues = ElementOutlinedButtonDefaults.ContentPadding, |
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, |
||||
content: @Composable RowScope.() -> Unit |
||||
) { |
||||
androidx.compose.material3.Button( |
||||
onClick = onClick, |
||||
modifier = modifier, |
||||
enabled = enabled, |
||||
shape = shape, |
||||
colors = colors, |
||||
elevation = elevation, |
||||
border = border, |
||||
contentPadding = contentPadding, |
||||
interactionSource = interactionSource, |
||||
content = content, |
||||
) |
||||
} |
||||
|
||||
object ElementOutlinedButtonDefaults { |
||||
val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) |
||||
val shape: Shape @Composable get() = ButtonDefaults.outlinedShape |
||||
val border: BorderStroke @Composable get() = ButtonDefaults.outlinedButtonBorder |
||||
@Composable |
||||
fun buttonElevation(): ButtonElevation = ButtonDefaults.buttonElevation() |
||||
|
||||
@Composable |
||||
fun buttonColors(): ButtonColors = ButtonDefaults.outlinedButtonColors() |
||||
|
||||
|
||||
} |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun OutlinedButtonsLightPreview() = ElementPreviewLight { ContentToPreview() } |
||||
|
||||
@Preview |
||||
@Composable |
||||
internal fun OutlinedButtonsDarkPreview() = ElementPreviewDark { ContentToPreview() } |
||||
|
||||
@Composable |
||||
private fun ContentToPreview() { |
||||
Column { |
||||
OutlinedButton(onClick = {}, enabled = true) { |
||||
Text(text = "Click me! - Enabled") |
||||
} |
||||
OutlinedButton(onClick = {}, enabled = false) { |
||||
Text(text = "Click me! - Disabled") |
||||
} |
||||
} |
||||
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue