[Room details] DM designs (#313)
* Implement member details screen * Add DM-only sections to the room details screen.
This commit is contained in:
committed by
GitHub
parent
c8fcf9549b
commit
aa12feb4d4
1
changelog.d/312.feature
Normal file
1
changelog.d/312.feature
Normal file
@@ -0,0 +1 @@
|
||||
Room details: implement custom designs for DMs.
|
||||
@@ -34,6 +34,7 @@ import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@@ -64,24 +65,23 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.RoomDetails -> {
|
||||
val callback = object : RoomDetailsNode.Callback {
|
||||
val roomDetailsCallback = object : RoomDetailsNode.Callback {
|
||||
override fun openRoomMemberList() {
|
||||
backstack.push(NavTarget.RoomMemberList)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(callback))
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
}
|
||||
NavTarget.RoomMemberList -> {
|
||||
val callback = object : RoomMemberListNode.Callback {
|
||||
val roomMemberListCallback = object : RoomMemberListNode.Callback {
|
||||
override fun openRoomMemberDetails(roomMember: RoomMember) {
|
||||
backstack.push(NavTarget.RoomMemberDetails(roomMember))
|
||||
}
|
||||
}
|
||||
createNode<RoomMemberListNode>(buildContext, listOf(callback))
|
||||
createNode<RoomMemberListNode>(buildContext, listOf(roomMemberListCallback))
|
||||
}
|
||||
is NavTarget.RoomMemberDetails -> {
|
||||
val inputs = RoomMemberDetailsNode.Inputs(navTarget.roomMember)
|
||||
createNode<RoomMemberDetailsNode>(buildContext, listOf(inputs))
|
||||
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import timber.log.Timber
|
||||
import io.element.android.libraries.androidutils.R as AndroidUtilsR
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class RoomDetailsNode @AssistedInject constructor(
|
||||
@@ -44,10 +47,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
fun openRoomMemberList()
|
||||
}
|
||||
|
||||
private val callback = plugins<Callback>().firstOrNull()
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
private fun openRoomMemberList() {
|
||||
callback?.openRoomMemberList()
|
||||
callbacks.forEach { it.openRoomMemberList() }
|
||||
}
|
||||
|
||||
private fun onShareRoom(context: Context) {
|
||||
@@ -64,6 +67,21 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onShareMember(context: Context, member: RoomMember) {
|
||||
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
|
||||
permalinkResult.onSuccess { permalink ->
|
||||
startSharePlainTextIntent(
|
||||
context = context,
|
||||
activityResultLauncher = null,
|
||||
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
|
||||
text = permalink,
|
||||
noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found)
|
||||
)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
@@ -73,6 +91,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
modifier = modifier,
|
||||
goBack = { navigateUp() },
|
||||
onShareRoom = { onShareRoom(context) },
|
||||
onShareMember = { onShareMember(context, it) },
|
||||
openRoomMemberList = ::openRoomMemberList,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -33,6 +35,7 @@ import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomDetailsPresenter @Inject constructor(
|
||||
private val sessionId: SessionId,
|
||||
private val room: MatrixRoom,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
) : Presenter<RoomDetailsState> {
|
||||
@@ -46,6 +49,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
var error by remember {
|
||||
mutableStateOf<RoomDetailsError?>(null)
|
||||
}
|
||||
|
||||
var memberCount: Async<Int> by remember { mutableStateOf(Async.Loading()) }
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -56,6 +60,14 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val dmMember = room.getDmMember()
|
||||
val roomType = if (dmMember != null) {
|
||||
RoomDetailsType.Dm(dmMember)
|
||||
} else {
|
||||
RoomDetailsType.Room
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomDetailsEvent) {
|
||||
when (event) {
|
||||
is RoomDetailsEvent.LeaveRoom -> {
|
||||
@@ -78,7 +90,6 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return RoomDetailsState(
|
||||
roomId = room.roomId.value,
|
||||
roomName = room.name ?: room.displayName,
|
||||
@@ -89,7 +100,8 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
isEncrypted = room.isEncrypted,
|
||||
displayLeaveRoomWarning = leaveRoomWarning,
|
||||
error = error,
|
||||
eventSink = ::handleEvents
|
||||
roomType = roomType,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.isLoading
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
data class RoomDetailsState(
|
||||
val roomId: String,
|
||||
@@ -31,9 +32,15 @@ data class RoomDetailsState(
|
||||
val isEncrypted: Boolean,
|
||||
val displayLeaveRoomWarning: LeaveRoomWarning?,
|
||||
val error: RoomDetailsError?,
|
||||
val roomType: RoomDetailsType,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
)
|
||||
|
||||
sealed interface RoomDetailsType {
|
||||
object Room : RoomDetailsType
|
||||
data class Dm(val roomMember: RoomMember) : RoomDetailsType
|
||||
}
|
||||
|
||||
sealed class LeaveRoomWarning {
|
||||
object Generic : LeaveRoomWarning()
|
||||
object PrivateRoom : LeaveRoomWarning()
|
||||
|
||||
@@ -18,6 +18,9 @@ package io.element.android.features.roomdetails.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
|
||||
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
|
||||
override val values: Sequence<RoomDetailsState>
|
||||
@@ -27,10 +30,32 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
||||
aRoomDetailsState().copy(isEncrypted = false),
|
||||
aRoomDetailsState().copy(roomAlias = null),
|
||||
aRoomDetailsState().copy(memberCount = Async.Failure(Throwable())),
|
||||
aRoomDetailsState().copy(roomType = RoomDetailsType.Dm(aDmRoomMember()), roomName = "Daniel"),
|
||||
aRoomDetailsState().copy(roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = true)), roomName = "Daniel"),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
||||
fun aDmRoomMember(
|
||||
userId: UserId = UserId("@daniel:domain.com"),
|
||||
displayName: String? = "Daniel",
|
||||
avatarUrl: String? = null,
|
||||
membership: RoomMembershipState = RoomMembershipState.JOIN,
|
||||
isNameAmbiguous: Boolean = false,
|
||||
powerLevel: Long = 0,
|
||||
normalizedPowerLevel: Long = powerLevel,
|
||||
isIgnored: Boolean = false,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
membership = membership,
|
||||
isNameAmbiguous = isNameAmbiguous,
|
||||
powerLevel = powerLevel,
|
||||
normalizedPowerLevel = normalizedPowerLevel,
|
||||
isIgnored = isIgnored,
|
||||
)
|
||||
|
||||
fun aRoomDetailsState() = RoomDetailsState(
|
||||
roomId = "a room id",
|
||||
roomName = "Marketing",
|
||||
@@ -45,5 +70,6 @@ fun aRoomDetailsState() = RoomDetailsState(
|
||||
isEncrypted = true,
|
||||
displayLeaveRoomWarning = null,
|
||||
error = null,
|
||||
roomType = RoomDetailsType.Room,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -42,6 +42,9 @@ import androidx.compose.ui.res.vectorResource
|
||||
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.roomdetails.impl.members.details.BlockSection
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberShareSection
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.isLoading
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
@@ -59,6 +62,7 @@ import io.element.android.libraries.designsystem.theme.LocalColors
|
||||
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.room.RoomMember
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -67,9 +71,15 @@ fun RoomDetailsView(
|
||||
state: RoomDetailsState,
|
||||
goBack: () -> Unit,
|
||||
onShareRoom: () -> Unit,
|
||||
onShareMember: (RoomMember) -> Unit,
|
||||
openRoomMemberList: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
||||
fun onShareMember() {
|
||||
onShareMember((state.roomType as RoomDetailsType.Dm).roomMember)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -80,33 +90,57 @@ fun RoomDetailsView(
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
HeaderSection(
|
||||
avatarUrl = state.roomAvatarUrl,
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAlias = state.roomAlias
|
||||
)
|
||||
|
||||
ShareSection(onShareUser = onShareRoom)
|
||||
when (state.roomType) {
|
||||
RoomDetailsType.Room -> {
|
||||
RoomHeaderSection(
|
||||
avatarUrl = state.roomAvatarUrl,
|
||||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAlias = state.roomAlias
|
||||
)
|
||||
RoomShareSection(onShareRoom = onShareRoom)
|
||||
}
|
||||
is RoomDetailsType.Dm -> {
|
||||
val member = state.roomType.roomMember
|
||||
RoomMemberHeaderSection(
|
||||
avatarUrl = state.roomAvatarUrl ?: member.avatarUrl,
|
||||
userId = member.userId.value,
|
||||
userName = state.roomName
|
||||
)
|
||||
RoomMemberShareSection(onShareUser = ::onShareMember)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.roomTopic != null) {
|
||||
TopicSection(roomTopic = state.roomTopic)
|
||||
}
|
||||
|
||||
val memberCount = (state.memberCount as? Async.Success<Int>)?.state
|
||||
MembersSection(
|
||||
memberCount = memberCount,
|
||||
isLoading = state.memberCount.isLoading(),
|
||||
openRoomMemberList = openRoomMemberList
|
||||
)
|
||||
if (state.roomType is RoomDetailsType.Room) {
|
||||
val memberCount = (state.memberCount as? Async.Success<Int>)?.state
|
||||
MembersSection(
|
||||
memberCount = memberCount,
|
||||
isLoading = state.memberCount.isLoading(),
|
||||
openRoomMemberList = openRoomMemberList
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isEncrypted) {
|
||||
SecuritySection()
|
||||
}
|
||||
|
||||
OtherActionsSection(onLeaveRoom = {
|
||||
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||
})
|
||||
when (state.roomType) {
|
||||
RoomDetailsType.Room -> {
|
||||
OtherActionsSection(onLeaveRoom = {
|
||||
state.eventSink(RoomDetailsEvent.LeaveRoom(needsConfirmation = true))
|
||||
})
|
||||
}
|
||||
is RoomDetailsType.Dm -> {
|
||||
BlockSection(
|
||||
isBlocked = state.roomType.roomMember.isIgnored,
|
||||
onToggleBlock = { /*TODO*/ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.displayLeaveRoomWarning != null) {
|
||||
ConfirmLeaveRoomDialog(
|
||||
@@ -127,18 +161,18 @@ fun RoomDetailsView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
||||
internal fun RoomShareSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) {
|
||||
PreferenceCategory(modifier = modifier) {
|
||||
PreferenceText(
|
||||
title = stringResource(R.string.screen_room_details_share_room_title),
|
||||
icon = Icons.Outlined.Share,
|
||||
onClick = onShareUser,
|
||||
onClick = onShareRoom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HeaderSection(
|
||||
internal fun RoomHeaderSection(
|
||||
avatarUrl: String?,
|
||||
roomId: String,
|
||||
roomName: String,
|
||||
@@ -152,10 +186,10 @@ internal fun HeaderSection(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(roomName, style = ElementTextStyles.Bold.title1)
|
||||
if (roomAlias != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(roomAlias, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
@@ -256,6 +290,7 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||
state = state,
|
||||
goBack = {},
|
||||
onShareRoom = {},
|
||||
onShareMember = {},
|
||||
openRoomMemberList = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,15 @@ import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
|
||||
import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import javax.inject.Named
|
||||
|
||||
@Module
|
||||
@@ -40,6 +43,16 @@ interface RoomMemberBindsModule {
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
object RoomMemberProvidesModule {
|
||||
|
||||
@Provides
|
||||
fun provideRoomDetailsPresenter(
|
||||
matrixClient: MatrixClient,
|
||||
room: MatrixRoom,
|
||||
roomMembershipObserver: RoomMembershipObserver,
|
||||
): RoomDetailsPresenter {
|
||||
return RoomDetailsPresenter(matrixClient.sessionId, room, roomMembershipObserver)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideRoomMemberDetailsPresenterFactory(
|
||||
room: MatrixRoom,
|
||||
@@ -25,7 +25,6 @@ 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.roomdetails.impl.RoomDetailsFlowNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
@@ -44,12 +43,12 @@ class RoomMemberListNode @AssistedInject constructor(
|
||||
fun openRoomMemberDetails(roomMember: RoomMember)
|
||||
}
|
||||
|
||||
private val callback = plugins<Callback>().first()
|
||||
private val callbacks = plugins<Callback>()
|
||||
|
||||
private fun onUserSelected(matrixUser: MatrixUser) {
|
||||
val member = room.getMember(matrixUser.id)
|
||||
if (member != null) {
|
||||
callback.openRoomMemberDetails(member)
|
||||
callbacks.forEach { it.openRoomMemberDetails(member) }
|
||||
} else {
|
||||
Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}")
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ class RoomUserListDataSource @Inject constructor(
|
||||
if (query.isBlank()) {
|
||||
true
|
||||
} else {
|
||||
member.userId.contains(query, ignoreCase = true) || member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||
member.userId.value.contains(query, ignoreCase = true)
|
||||
|| member.displayName?.contains(query, ignoreCase = true).orFalse()
|
||||
}
|
||||
}.map(::mapMemberToMatrixUser)
|
||||
}
|
||||
@@ -45,10 +46,10 @@ class RoomUserListDataSource @Inject constructor(
|
||||
|
||||
private fun mapMemberToMatrixUser(member: RoomMember): MatrixUser {
|
||||
return MatrixUser(
|
||||
id = UserId(member.userId),
|
||||
id = member.userId,
|
||||
username = member.displayName,
|
||||
avatarData = AvatarData(
|
||||
id = member.userId,
|
||||
id = member.userId.value,
|
||||
name = member.displayName,
|
||||
url = member.avatarUrl
|
||||
)
|
||||
|
||||
@@ -16,7 +16,4 @@
|
||||
|
||||
package io.element.android.features.roomdetails.impl.members.details
|
||||
|
||||
// TODO Add your events or remove the file completely if no events
|
||||
sealed interface RoomMemberDetailsEvents {
|
||||
object MyEvent : RoomMemberDetailsEvents
|
||||
}
|
||||
sealed interface RoomMemberDetailsEvents
|
||||
|
||||
@@ -56,7 +56,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
|
||||
val context = LocalContext.current
|
||||
|
||||
fun onShareUser() {
|
||||
val permalinkResult = PermalinkBuilder.permalinkForUser(UserId(inputs.member.userId))
|
||||
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.member.userId)
|
||||
permalinkResult.onSuccess { permalink ->
|
||||
startSharePlainTextIntent(
|
||||
context = context,
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
package io.element.android.features.roomdetails.impl.members.details
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -40,10 +42,22 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
|
||||
// }
|
||||
// }
|
||||
|
||||
val userName by produceState(initialValue = roomMember.displayName) {
|
||||
room.userDisplayName(roomMember.userId).onSuccess { displayName ->
|
||||
if (displayName != null) value = displayName
|
||||
}
|
||||
}
|
||||
|
||||
val userAvatar by produceState(initialValue = roomMember.avatarUrl) {
|
||||
room.userAvatarUrl(roomMember.userId).onSuccess { avatarUrl ->
|
||||
if (avatarUrl != null) value = avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
return RoomMemberDetailsState(
|
||||
userId = roomMember.userId,
|
||||
userName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl,
|
||||
userId = roomMember.userId.value,
|
||||
userName = userName,
|
||||
avatarUrl = userAvatar,
|
||||
isBlocked = roomMember.isIgnored,
|
||||
// eventSink = ::handleEvents
|
||||
)
|
||||
|
||||
@@ -74,13 +74,13 @@ fun RoomMemberDetailsView(
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
HeaderSection(
|
||||
RoomMemberHeaderSection(
|
||||
avatarUrl = state.avatarUrl,
|
||||
userId = state.userId,
|
||||
userName = state.userName,
|
||||
)
|
||||
|
||||
ShareSection(onShareUser = onShareUser)
|
||||
RoomMemberShareSection(onShareUser = onShareUser)
|
||||
|
||||
SendMessageSection(onSendMessage = {
|
||||
// TODO implement send DM
|
||||
@@ -94,7 +94,7 @@ fun RoomMemberDetailsView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HeaderSection(
|
||||
internal fun RoomMemberHeaderSection(
|
||||
avatarUrl: String?,
|
||||
userId: String,
|
||||
userName: String?,
|
||||
@@ -107,10 +107,10 @@ internal fun HeaderSection(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (userName != null) {
|
||||
Text(userName, style = ElementTextStyles.Bold.title1)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
Text(userId, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
@@ -118,7 +118,7 @@ internal fun HeaderSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
||||
internal fun RoomMemberShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) {
|
||||
PreferenceCategory(modifier = modifier) {
|
||||
PreferenceText(
|
||||
title = stringResource(StringR.string.action_share),
|
||||
|
||||
@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
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.room.FakeMatrixRoom
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -49,7 +50,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state is created from room info`() = runTest {
|
||||
val room = aMatrixRoom()
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -68,7 +69,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - room member count is calculated asynchronously`() = runTest {
|
||||
val room = aMatrixRoom()
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -83,7 +84,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state with no room name`() = runTest {
|
||||
val room = aMatrixRoom(name = null)
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -99,7 +100,7 @@ class RoomDetailsPresenterTests {
|
||||
val room = aMatrixRoom(name = null).apply {
|
||||
givenFetchMemberResult(Result.failure(Throwable()))
|
||||
}
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -113,7 +114,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - Leave with confirmation on private room shows a specific warning`() = runTest {
|
||||
val room = aMatrixRoom(isPublic = false)
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -130,7 +131,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - Leave with confirmation on empty room shows a specific warning`() = runTest {
|
||||
val room = aMatrixRoom(members = listOf(aRoomMember()))
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -147,7 +148,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - Leave with confirmation shows a generic warning`() = runTest {
|
||||
val room = aMatrixRoom()
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -164,7 +165,7 @@ class RoomDetailsPresenterTests {
|
||||
@Test
|
||||
fun `present - Leave without confirmation leaves the room`() = runTest {
|
||||
val room = aMatrixRoom()
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -188,7 +189,7 @@ class RoomDetailsPresenterTests {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenLeaveRoomError(Throwable())
|
||||
}
|
||||
val presenter = RoomDetailsPresenter(room, roomMembershipObserver)
|
||||
val presenter = RoomDetailsPresenter(A_SESSION_ID, room, roomMembershipObserver)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -235,7 +236,7 @@ fun aRoomMember(
|
||||
normalizedPowerLevel: Long = 0L,
|
||||
isIgnored: Boolean = false,
|
||||
) = RoomMember(
|
||||
userId = userId.value,
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
membership = membership,
|
||||
|
||||
@@ -31,18 +31,63 @@ import org.junit.Test
|
||||
class RoomMemberDetailsPresenterTests {
|
||||
|
||||
@Test
|
||||
fun `present - returns the room member's data`() = runTest {
|
||||
val room = aMatrixRoom()
|
||||
fun `present - returns the room member's data, then updates it if needed`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenUserDisplayNameResult(Result.success("A custom name"))
|
||||
givenUserAvatarUrlResult(Result.success("A custom avatar"))
|
||||
}
|
||||
val roomMember = aRoomMember(displayName = "Alice")
|
||||
val presenter = RoomMemberDetailsPresenter(room, roomMember)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId)
|
||||
Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId.value)
|
||||
Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored)
|
||||
|
||||
val loadedState = awaitItem()
|
||||
Truth.assertThat(loadedState.userName).isEqualTo("A custom name")
|
||||
Truth.assertThat(loadedState.avatarUrl).isEqualTo("A custom avatar")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - will recover when retrieving room member details fails`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenUserDisplayNameResult(Result.failure(Throwable()))
|
||||
givenUserAvatarUrlResult(Result.failure(Throwable()))
|
||||
}
|
||||
val roomMember = aRoomMember(displayName = "Alice")
|
||||
val presenter = RoomMemberDetailsPresenter(room, roomMember)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - will fallback to original data if the updated data is null`() = runTest {
|
||||
val room = aMatrixRoom().apply {
|
||||
givenUserDisplayNameResult(Result.success(null))
|
||||
givenUserAvatarUrlResult(Result.success(null))
|
||||
}
|
||||
val roomMember = aRoomMember(displayName = "Alice")
|
||||
val presenter = RoomMemberDetailsPresenter(room, roomMember)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName)
|
||||
Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ object PermalinkBuilder {
|
||||
}
|
||||
Result.success(url)
|
||||
} else {
|
||||
Result.failure(PermalinkBuilderError.InvalidRoomAlias)
|
||||
Result.failure(PermalinkBuilderError.InvalidUserId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,4 +87,5 @@ object PermalinkBuilder {
|
||||
sealed class PermalinkBuilderError : Throwable() {
|
||||
object InvalidRoomAlias : PermalinkBuilderError()
|
||||
object InvalidRoomId : PermalinkBuilderError()
|
||||
object InvalidUserId : PermalinkBuilderError()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ interface MatrixRoom: Closeable {
|
||||
val topic: String?
|
||||
val avatarUrl: String?
|
||||
val isEncrypted: Boolean
|
||||
val isDirect: Boolean
|
||||
val isPublic: Boolean
|
||||
|
||||
suspend fun members() : List<RoomMember>
|
||||
@@ -41,15 +42,17 @@ interface MatrixRoom: Closeable {
|
||||
|
||||
fun getMember(userId: UserId): RoomMember?
|
||||
|
||||
fun getDmMember(): RoomMember?
|
||||
|
||||
fun syncUpdateFlow(): Flow<Long>
|
||||
|
||||
fun timeline(): MatrixTimeline
|
||||
|
||||
suspend fun fetchMembers(): Result<Unit>
|
||||
|
||||
suspend fun userDisplayName(userId: String): Result<String?>
|
||||
suspend fun userDisplayName(userId: UserId): Result<String?>
|
||||
|
||||
suspend fun userAvatarUrl(userId: String): Result<String?>
|
||||
suspend fun userAvatarUrl(userId: UserId): Result<String?>
|
||||
|
||||
suspend fun sendMessage(message: String): Result<Unit>
|
||||
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import android.os.Parcelable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class RoomMember(
|
||||
val userId: String,
|
||||
val userId: UserId,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?,
|
||||
val membership: RoomMembershipState,
|
||||
|
||||
@@ -168,6 +168,7 @@ class RustMatrixClient constructor(
|
||||
val slidingSyncRoom = slidingSync.getRoom(roomId.value) ?: return null
|
||||
val fullRoom = slidingSyncRoom.fullRoom() ?: return null
|
||||
return RustMatrixRoom(
|
||||
currentUserId = sessionId,
|
||||
slidingSyncUpdateFlow = slidingSyncObserverProxy.updateSummaryFlow,
|
||||
slidingSyncRoom = slidingSyncRoom,
|
||||
innerRoom = fullRoom,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
|
||||
@@ -25,7 +26,7 @@ object RoomMemberMapper {
|
||||
|
||||
fun map(roomMember: RustRoomMember): RoomMember =
|
||||
RoomMember(
|
||||
roomMember.userId(),
|
||||
UserId(roomMember.userId()),
|
||||
roomMember.displayName(),
|
||||
roomMember.avatarUrl(),
|
||||
mapMembership(roomMember.membership()),
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.matrix.rustcomponents.sdk.genTransactionId
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
|
||||
class RustMatrixRoom(
|
||||
private val currentUserId: UserId,
|
||||
private val slidingSyncUpdateFlow: Flow<UpdateSummary>,
|
||||
private val slidingSyncRoom: SlidingSyncRoom,
|
||||
private val innerRoom: Room,
|
||||
@@ -70,7 +71,15 @@ class RustMatrixRoom(
|
||||
}
|
||||
|
||||
override fun getMember(userId: UserId): RoomMember? {
|
||||
return cachedMembers.firstOrNull { it.userId == userId.value }
|
||||
return cachedMembers.find { it.userId == userId }
|
||||
}
|
||||
|
||||
override fun getDmMember(): RoomMember? {
|
||||
return if (cachedMembers.size == 2 && isDirect && isEncrypted) {
|
||||
cachedMembers.find { it.userId != currentUserId }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun syncUpdateFlow(): Flow<Long> {
|
||||
@@ -127,7 +136,7 @@ class RustMatrixRoom(
|
||||
}
|
||||
|
||||
override val isEncrypted: Boolean
|
||||
get() = innerRoom.isEncrypted()
|
||||
get() = runCatching { innerRoom.isEncrypted() }.getOrDefault(false)
|
||||
|
||||
override val alias: String?
|
||||
get() = innerRoom.canonicalAlias()
|
||||
@@ -138,23 +147,26 @@ class RustMatrixRoom(
|
||||
override val isPublic: Boolean
|
||||
get() = innerRoom.isPublic()
|
||||
|
||||
override val isDirect: Boolean
|
||||
get() = innerRoom.isDirect()
|
||||
|
||||
override suspend fun fetchMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.fetchMembers()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun userDisplayName(userId: String): Result<String?> =
|
||||
override suspend fun userDisplayName(userId: UserId): Result<String?> =
|
||||
withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.memberDisplayName(userId)
|
||||
innerRoom.memberDisplayName(userId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun userAvatarUrl(userId: String): Result<String?> =
|
||||
override suspend fun userAvatarUrl(userId: UserId): Result<String?> =
|
||||
withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
innerRoom.memberAvatarUrl(userId)
|
||||
innerRoom.memberAvatarUrl(userId.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,14 @@ class FakeMatrixRoom(
|
||||
override val alias: String? = null,
|
||||
override val alternativeAliases: List<String> = emptyList(),
|
||||
override val isPublic: Boolean = true,
|
||||
override val isDirect: Boolean = false,
|
||||
private val members: List<RoomMember> = emptyList(),
|
||||
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||
) : MatrixRoom {
|
||||
|
||||
private var userDisplayNameResult = Result.success<String?>(null)
|
||||
private var userAvatarUrlResult = Result.success<String?>(null)
|
||||
private var dmMember: RoomMember? = null
|
||||
private var fetchMemberResult: Result<Unit> = Result.success(Unit)
|
||||
|
||||
var areMembersFetched: Boolean = false
|
||||
@@ -66,12 +70,16 @@ class FakeMatrixRoom(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun userDisplayName(userId: String): Result<String?> {
|
||||
return Result.success("")
|
||||
override fun getDmMember(): RoomMember? {
|
||||
return dmMember
|
||||
}
|
||||
|
||||
override suspend fun userAvatarUrl(userId: String): Result<String?> {
|
||||
TODO("Not yet implemented")
|
||||
override suspend fun userDisplayName(userId: UserId): Result<String?> {
|
||||
return userDisplayNameResult
|
||||
}
|
||||
|
||||
override suspend fun userAvatarUrl(userId: UserId): Result<String?> {
|
||||
return userAvatarUrlResult
|
||||
}
|
||||
|
||||
override suspend fun members(): List<RoomMember> {
|
||||
@@ -87,7 +95,7 @@ class FakeMatrixRoom(
|
||||
}
|
||||
|
||||
override fun getMember(userId: UserId): RoomMember? {
|
||||
return members.firstOrNull { it.userId == userId.value }
|
||||
return members.firstOrNull { it.userId == userId }
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(message: String): Result<Unit> {
|
||||
@@ -133,4 +141,16 @@ class FakeMatrixRoom(
|
||||
fun givenFetchMemberResult(result: Result<Unit>) {
|
||||
fetchMemberResult = result
|
||||
}
|
||||
|
||||
fun givenDmMember(roomMember: RoomMember) {
|
||||
this.dmMember = roomMember
|
||||
}
|
||||
|
||||
fun givenUserDisplayNameResult(displayName: Result<String?>) {
|
||||
userDisplayNameResult = displayName
|
||||
}
|
||||
|
||||
fun givenUserAvatarUrlResult(avatarUrl: Result<String?>) {
|
||||
userAvatarUrlResult = avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,15 +121,60 @@
|
||||
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite."</string>
|
||||
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="notification_channel_call">"Call"</string>
|
||||
<string name="notification_channel_listening_for_events">"Listening for events"</string>
|
||||
<string name="notification_channel_noisy">"Noisy notifications"</string>
|
||||
<string name="notification_channel_silent">"Silent notifications"</string>
|
||||
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
|
||||
<string name="notification_invitation_action_join">"Join"</string>
|
||||
<string name="notification_invitation_action_reject">"Reject"</string>
|
||||
<string name="notification_new_messages">"New Messages"</string>
|
||||
<string name="notification_room_action_mark_as_read">"Mark as read"</string>
|
||||
<string name="notification_room_action_quick_reply">"Quick reply"</string>
|
||||
<string name="notification_sender_me">"Me"</string>
|
||||
<string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string>
|
||||
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
|
||||
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
|
||||
<string name="notification_unread_notified_messages_and_invitation">"%1$s and %2$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s in %2$s and %3$s"</string>
|
||||
<plurals name="common_member_count">
|
||||
<item quantity="one">"%1$d member"</item>
|
||||
<item quantity="other">"%1$d members"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_compat_summary_line_for_room">
|
||||
<item quantity="one">"%1$s: %2$d message"</item>
|
||||
<item quantity="other">"%1$s: %2$d messages"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_compat_summary_title">
|
||||
<item quantity="one">"%d notification"</item>
|
||||
<item quantity="other">"%d notifications"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_invitations">
|
||||
<item quantity="one">"%d invitation"</item>
|
||||
<item quantity="other">"%d invitations"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_new_messages_for_room">
|
||||
<item quantity="one">"%d new message"</item>
|
||||
<item quantity="other">"%d new messages"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_unread_notified_messages">
|
||||
<item quantity="one">"%d unread notified message"</item>
|
||||
<item quantity="other">"%d unread notified messages"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_unread_notified_messages_in_room_rooms">
|
||||
<item quantity="one">"%d room"</item>
|
||||
<item quantity="other">"%d rooms"</item>
|
||||
</plurals>
|
||||
<plurals name="room_timeline_state_changes">
|
||||
<item quantity="one">"%1$d room change"</item>
|
||||
<item quantity="other">"%1$d room changes"</item>
|
||||
</plurals>
|
||||
<string name="preference_rageshake">"Rageshake to report bug"</string>
|
||||
<string name="push_choose_distributor_dialog_title_android">"Choose how to receive notifications"</string>
|
||||
<string name="push_distributor_background_sync_android">"Background synchronization"</string>
|
||||
<string name="push_distributor_firebase_android">"Google Services"</string>
|
||||
<string name="push_no_valid_google_play_services_apk_android">"No valid Google Play Services found. Notifications may not work properly."</string>
|
||||
<string name="rageshake_dialog_content">"You seem to be shaking the phone in frustration. Would you like to open the bug report screen?"</string>
|
||||
<string name="report_content_explanation">"This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages."</string>
|
||||
<string name="report_content_hint">"Reason for reporting this content"</string>
|
||||
|
||||
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.
Reference in New Issue
Block a user