Merge branch 'develop' into feature/fga/permalink_timeline

This commit is contained in:
Benoit Marty 2024-04-26 12:50:38 +02:00
commit 2c8abbed0c
1157 changed files with 4307 additions and 1899 deletions

View file

@ -27,7 +27,7 @@ fun RoomPreviewSubtitleAtom(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)

View file

@ -18,17 +18,23 @@ package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun RoomPreviewTitleAtom(title: String, modifier: Modifier = Modifier) {
fun RoomPreviewTitleAtom(
title: String,
modifier: Modifier = Modifier,
fontStyle: FontStyle? = null,
) {
Text(
modifier = modifier,
text = title,
style = ElementTheme.typography.fontHeadingMdBold,
textAlign = TextAlign.Center,
fontStyle = fontStyle,
color = ElementTheme.colors.textPrimary,
)
}

View file

@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -42,8 +41,7 @@ fun RoomPreviewMembersCountMolecule(
Row(
modifier = modifier
.background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
.widthIn(min = 48.dp)
.padding(start = 2.dp, end = 6.dp, top = 2.dp, bottom = 2.dp),
.padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {

View file

@ -23,9 +23,11 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -36,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
/**
* @param modifier Classical modifier.
* @param paddingValues padding values to apply to the content.
* @param containerColor color of the container. Set to [Color.Transparent] if you provide a background in the [modifier].
* @param background optional background component.
* @param topBar optional topBar.
* @param header optional header.
@ -46,6 +49,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
fun HeaderFooterPage(
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(20.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
background: @Composable () -> Unit = {},
topBar: @Composable () -> Unit = {},
header: @Composable () -> Unit = {},
@ -55,6 +59,7 @@ fun HeaderFooterPage(
Scaffold(
modifier = modifier,
topBar = topBar,
containerColor = containerColor,
) { padding ->
Box {
background()

View file

@ -0,0 +1,63 @@
/*
* 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.libraries.designsystem.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Light gradient background for Join room screens.
*/
@Composable
fun LightGradientBackground(
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.background,
firstColor: Color = Color(0x1E0DBD8B),
secondColor: Color = Color(0x001273EB),
ratio: Float = 642 / 775f,
) {
Canvas(
modifier = modifier.fillMaxSize()
) {
val biggerDimension = size.width * 1.98f
val gradientShaderBrush = ShaderBrush(
RadialGradientShader(
colors = listOf(firstColor, secondColor),
center = size.center.copy(x = size.width * ratio, y = size.height * ratio),
radius = biggerDimension / 2f,
colorStops = listOf(0f, 0.95f)
)
)
drawRect(backgroundColor, size = size)
drawRect(brush = gradientShaderBrush, size = size)
}
}
@PreviewsDayNight
@Composable
internal fun LightGradientBackgroundPreview() = ElementPreview {
LightGradientBackground()
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
package io.element.android.libraries.designsystem.background
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.drawWithLayer
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight

View file

@ -108,7 +108,7 @@ fun PageTitle(
@PreviewsDayNight
@Composable
internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
internal fun PageTitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvider::class) style: BigIcon.Style) {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),
@ -124,7 +124,7 @@ internal fun TitleWithIconFullPreview(@PreviewParameter(BigIconStylePreviewProvi
@PreviewsDayNight
@Composable
internal fun TitleWithIconMinimalPreview() {
internal fun PageTitleWithIconMinimalPreview() {
ElementPreview {
PageTitle(
modifier = Modifier.padding(top = 24.dp),

View file

@ -75,7 +75,7 @@ internal fun AsyncIndicatorView(
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorView_Loading_Preview() {
internal fun AsyncIndicatorLoadingPreview() {
ElementPreview {
AsyncIndicator.Loading(text = "Loading")
}
@ -83,7 +83,7 @@ internal fun AsyncIndicatorView_Loading_Preview() {
@PreviewsDayNight
@Composable
internal fun AsyncIndicatorView_Failed_Preview() {
internal fun AsyncIndicatorFailurePreview() {
ElementPreview {
AsyncIndicator.Failure(text = "Failed")
}

View file

@ -26,7 +26,8 @@ data class AvatarData(
val size: AvatarSize,
) {
val initial by lazy {
(name?.takeIf { it.isNotBlank() } ?: id)
// For roomIds, use "#" as initial
(name?.takeIf { it.isNotBlank() } ?: id.takeIf { !it.startsWith("!") } ?: "#")
.let { dn ->
var startIndex = 0
val initial = dn[startIndex]

View file

@ -31,7 +31,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
@PreviewsDayNight
@Composable
internal fun UserAvatarPreview() = ElementPreview {
internal fun UserAvatarColorsPreview() = ElementPreview {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -128,7 +128,7 @@ internal fun GradientFloatingActionButtonPreview() {
@PreviewsDayNight
@Composable
internal fun GradientSendButtonPreview() {
internal fun GradientFloatingActionButtonCircleShapePreview() {
ElementPreview {
Box(modifier = Modifier.padding(20.dp)) {
GradientFloatingActionButton(

View file

@ -98,7 +98,7 @@ private fun PreferenceTopAppBar(
@PreviewsDayNight
@Composable
internal fun PreferenceViewPreview() = ElementPreview {
internal fun PreferencePagePreview() = ElementPreview {
PreferencePage(
title = "Preference screen",
onBackPressed = {},

View file

@ -98,11 +98,11 @@ private fun TextFieldDialog(
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
}
var error by rememberSaveable { mutableStateOf<String?>(null) }
var canRequestFocus by rememberSaveable { mutableStateOf(false) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
@ -128,10 +128,11 @@ private fun TextFieldDialog(
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
canRequestFocus = true
}
}
if (autoSelectOnDisplay) {
if (autoSelectOnDisplay && canRequestFocus) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}

View file

@ -64,7 +64,7 @@ fun WithRulers(
@PreviewsDayNight
@Composable
internal fun WithRulerPreview() = ElementPreview {
internal fun WithRulersPreview() = ElementPreview {
WithRulers(xRulersOffset = 20.dp, yRulersOffset = 15.dp) {
OutlinedButton(
text = "A Button with rulers on it!",

View file

@ -41,7 +41,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessage
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -52,6 +51,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecry
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.toPlainText
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
@ -72,15 +72,13 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.isOwn
// Note: we do not use disambiguated display name here, see
// https://github.com/element-hq/element-x-ios/issues/1845#issuecomment-1888707428
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
is MessageContent -> processMessageContents(content, senderDisambiguatedDisplayName, isDmRoom)
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
prefix(message, senderDisambiguatedDisplayName)
} else {
message
}
@ -91,36 +89,40 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
prefix(message, senderDisplayName)
prefix(message, senderDisambiguatedDisplayName)
} else {
message
}
}
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.RoomList)
}
is PollContent -> {
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
prefixIfNeeded(message, senderDisplayName, isDmRoom)
prefixIfNeeded(message, senderDisambiguatedDisplayName, isDmRoom)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisambiguatedDisplayName, isDmRoom)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_call_invite)
}?.take(MAX_SAFE_LENGTH)
}
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {
private fun processMessageContents(
messageContent: MessageContent,
senderDisambiguatedDisplayName: String,
isDmRoom: Boolean,
): CharSequence {
val internalMessage = when (val messageType: MessageType = messageContent.type) {
// Doesn't need a prefix
is EmoteMessageType -> {
return "* $senderDisplayName ${messageType.body}"
return "* $senderDisambiguatedDisplayName ${messageType.body}"
}
is TextMessageType -> {
messageType.toPlainText(permalinkParser)
@ -153,19 +155,23 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
messageType.body
}
}
return prefixIfNeeded(internalMessage, senderDisplayName, isDmRoom)
return prefixIfNeeded(internalMessage, senderDisambiguatedDisplayName, isDmRoom)
}
private fun prefixIfNeeded(message: String, senderDisplayName: String, isDmRoom: Boolean): CharSequence = if (isDmRoom) {
private fun prefixIfNeeded(
message: String,
senderDisambiguatedDisplayName: String,
isDmRoom: Boolean,
): CharSequence = if (isDmRoom) {
message
} else {
prefix(message, senderDisplayName)
prefix(message, senderDisambiguatedDisplayName)
}
private fun prefix(message: String, senderDisplayName: String): AnnotatedString {
private fun prefix(message: String, senderDisambiguatedDisplayName: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(senderDisplayName)
append(senderDisambiguatedDisplayName)
}
append(": ")
append(message)

View file

@ -49,16 +49,16 @@ class DefaultTimelineEventFormatter @Inject constructor(
) : TimelineEventFormatter {
override fun format(event: EventTimelineItem): CharSequence? {
val isOutgoing = event.isOwn
val senderDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
val senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender)
return when (val content = event.content) {
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
roomMembershipContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, event.sender, senderDisplayName, isOutgoing)
profileChangeContentFormatter.format(content, event.sender, senderDisambiguatedDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.Timeline)
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.Timeline)
}
is LegacyCallInviteContent -> {
sp.getString(CommonStrings.common_call_invite)

View file

@ -27,14 +27,19 @@ class ProfileChangeContentFormatter @Inject constructor(
fun format(
profileChangeContent: ProfileChangeContent,
senderId: UserId,
senderDisplayName: String,
senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
): String? = profileChangeContent.run {
val displayNameChanged = displayName != prevDisplayName
val avatarChanged = avatarUrl != prevAvatarUrl
return when {
avatarChanged && displayNameChanged -> {
val message = format(profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null), senderId, senderDisplayName, senderIsYou)
val message = format(
profileChangeContent = profileChangeContent.copy(avatarUrl = null, prevAvatarUrl = null),
senderId = senderId,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
senderIsYou = senderIsYou,
)
val avatarChangedToo = sp.getString(R.string.state_event_avatar_changed_too)
"$message\n$avatarChangedToo"
}
@ -63,7 +68,7 @@ class ProfileChangeContentFormatter @Inject constructor(
if (senderIsYou) {
sp.getString(R.string.state_event_avatar_url_changed_by_you)
} else {
sp.getString(R.string.state_event_avatar_url_changed, senderDisplayName)
sp.getString(R.string.state_event_avatar_url_changed, senderDisambiguatedDisplayName)
}
}
else -> null

View file

@ -29,7 +29,7 @@ class RoomMembershipContentFormatter @Inject constructor(
) {
fun format(
membershipContent: RoomMembershipContent,
senderDisplayName: String,
senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
): CharSequence? {
val userId = membershipContent.userId
@ -38,34 +38,34 @@ class RoomMembershipContentFormatter @Inject constructor(
MembershipChange.JOINED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_join_by_you)
} else {
sp.getString(R.string.state_event_room_join, userId.value)
sp.getString(R.string.state_event_room_join, senderDisambiguatedDisplayName)
}
MembershipChange.LEFT -> if (memberIsYou) {
sp.getString(R.string.state_event_room_leave_by_you)
} else {
sp.getString(R.string.state_event_room_leave, userId.value)
sp.getString(R.string.state_event_room_leave, senderDisambiguatedDisplayName)
}
MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_ban_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_ban, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.UNBANNED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_unban_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_unban, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KICKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_remove_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_remove, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.INVITED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_invite_by_you, userId.value)
} else if (memberIsYou) {
sp.getString(R.string.state_event_room_invite_you, senderDisplayName)
sp.getString(R.string.state_event_room_invite_you, senderDisambiguatedDisplayName)
} else {
sp.getString(R.string.state_event_room_invite, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_invite_accepted_by_you)
@ -80,34 +80,34 @@ class RoomMembershipContentFormatter @Inject constructor(
MembershipChange.INVITATION_REVOKED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KNOCKED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_by_you)
} else {
sp.getString(R.string.state_event_room_knock, userId.value)
sp.getString(R.string.state_event_room_knock, senderDisambiguatedDisplayName)
}
MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value)
} else {
sp.getString(R.string.state_event_room_knock_accepted, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_retracted_by_you)
} else {
sp.getString(R.string.state_event_room_knock_retracted, userId.value)
sp.getString(R.string.state_event_room_knock_retracted, senderDisambiguatedDisplayName)
}
MembershipChange.KNOCK_DENIED -> if (senderIsYou) {
sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value)
} else if (memberIsYou) {
sp.getString(R.string.state_event_room_knock_denied_you, senderDisplayName)
sp.getString(R.string.state_event_room_knock_denied_you, senderDisambiguatedDisplayName)
} else {
sp.getString(R.string.state_event_room_knock_denied, senderDisplayName, userId.value)
sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userId.value)
}
MembershipChange.NONE -> if (senderIsYou) {
sp.getString(R.string.state_event_room_none_by_you)
} else {
sp.getString(R.string.state_event_room_none, senderDisplayName)
sp.getString(R.string.state_event_room_none, senderDisambiguatedDisplayName)
}
MembershipChange.ERROR -> {
Timber.v("Filtering timeline item for room membership: $membershipContent")

View file

@ -29,7 +29,7 @@ class StateContentFormatter @Inject constructor(
) {
fun format(
stateContent: StateContent,
senderDisplayName: String,
senderDisambiguatedDisplayName: String,
senderIsYou: Boolean,
renderingMode: RenderingMode,
): CharSequence? {
@ -39,15 +39,15 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed_by_you)
senderIsYou && !hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_removed_by_you)
!senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisplayName)
else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisplayName)
!senderIsYou && hasAvatarUrl -> sp.getString(R.string.state_event_room_avatar_changed, senderDisambiguatedDisplayName)
else -> sp.getString(R.string.state_event_room_avatar_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomCreate -> {
if (senderIsYou) {
sp.getString(R.string.state_event_room_created_by_you)
} else {
sp.getString(R.string.state_event_room_created, senderDisplayName)
sp.getString(R.string.state_event_room_created, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomEncryption -> sp.getString(CommonStrings.common_encryption_enabled)
@ -56,8 +56,8 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed_by_you, content.name)
senderIsYou && !hasRoomName -> sp.getString(R.string.state_event_room_name_removed_by_you)
!senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisplayName, content.name)
else -> sp.getString(R.string.state_event_room_name_removed, senderDisplayName)
!senderIsYou && hasRoomName -> sp.getString(R.string.state_event_room_name_changed, senderDisambiguatedDisplayName, content.name)
else -> sp.getString(R.string.state_event_room_name_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.RoomThirdPartyInvite -> {
@ -68,7 +68,7 @@ class StateContentFormatter @Inject constructor(
if (senderIsYou) {
sp.getString(R.string.state_event_room_third_party_invite_by_you, content.displayName)
} else {
sp.getString(R.string.state_event_room_third_party_invite, senderDisplayName, content.displayName)
sp.getString(R.string.state_event_room_third_party_invite, senderDisambiguatedDisplayName, content.displayName)
}
}
is OtherState.RoomTopic -> {
@ -76,8 +76,8 @@ class StateContentFormatter @Inject constructor(
when {
senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed_by_you, content.topic)
senderIsYou && !hasRoomTopic -> sp.getString(R.string.state_event_room_topic_removed_by_you)
!senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisplayName, content.topic)
else -> sp.getString(R.string.state_event_room_topic_removed, senderDisplayName)
!senderIsYou && hasRoomTopic -> sp.getString(R.string.state_event_room_topic_changed, senderDisambiguatedDisplayName, content.topic)
else -> sp.getString(R.string.state_event_room_topic_removed, senderDisambiguatedDisplayName)
}
}
is OtherState.Custom -> when (renderingMode) {

View file

@ -5,12 +5,12 @@
<string name="state_event_avatar_url_changed_by_you">"Вы змянілі свой аватар"</string>
<string name="state_event_demoted_to_member">"%1$s быў паніжаны(-на) да ўдзельніка"</string>
<string name="state_event_demoted_to_moderator">"%1$s быў паніжаны(-на) да мадэратара"</string>
<string name="state_event_display_name_changed_from">"%1$s змяніў(-ла) сваё адлюстраванае імя з %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы змянілі сваё адлюстраванае імя з %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s выдаліў(-ла) сваё адлюстраванае імя (яно было %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Вы выдалілі сваё адлюстраванае імя (яно было %1$s)"</string>
<string name="state_event_display_name_set">"%1$s усталявалі сваё адлюстраванае імя на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Вы ўстанавілі адлюстраванае імя на %1$s"</string>
<string name="state_event_display_name_changed_from">"%1$s змяніў(-ла) сваё бачнае імя з %2$s на %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Вы змянілі сваё бачнае імя з %1$s на %2$s"</string>
<string name="state_event_display_name_removed">"%1$s выдаліў(-ла) сваё бачнае імя (яно было %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Вы выдалілі сваё бачнае імя (яно было %1$s)"</string>
<string name="state_event_display_name_set">"%1$s усталявалі сваё бачнае імя на %2$s"</string>
<string name="state_event_display_name_set_by_you">"Вы ўстанавілі бачнае імя на %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s быў(-ла) павышаны(-на) да адміністратара"</string>
<string name="state_event_promoted_to_moderator">"%1$s быў(-ла) павышаны(-на) да мадэратара"</string>
<string name="state_event_room_avatar_changed">"%1$s змяніў(-ла) аватар пакоя"</string>

View file

@ -30,7 +30,7 @@
<string name="state_event_room_join_by_you">"You joined the room"</string>
<string name="state_event_room_knock">"%1$s requested to join"</string>
<string name="state_event_room_knock_accepted">"%1$s allowed %2$s to join"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s allowed you to join"</string>
<string name="state_event_room_knock_accepted_by_you">"You allowed %1$s to join"</string>
<string name="state_event_room_knock_by_you">"You requested to join"</string>
<string name="state_event_room_knock_denied">"%1$s rejected %2$s\'s request to join"</string>
<string name="state_event_room_knock_denied_by_you">"You rejected %1$s\'s request to join"</string>

View file

@ -38,7 +38,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
@ -54,6 +53,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import org.junit.Before
@ -264,7 +264,7 @@ class DefaultRoomLastMessageFormatterTest {
val someoneJoinedRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneJoinedRoom = formatter.format(someoneJoinedRoomEvent, false)
assertThat(someoneJoinedRoom).isEqualTo("${someoneContent.userId} joined the room")
assertThat(someoneJoinedRoom).isEqualTo("$otherName joined the room")
}
@Test
@ -280,7 +280,7 @@ class DefaultRoomLastMessageFormatterTest {
val someoneLeftRoomEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneLeftRoom = formatter.format(someoneLeftRoomEvent, false)
assertThat(someoneLeftRoom).isEqualTo("${someoneContent.userId} left the room")
assertThat(someoneLeftRoom).isEqualTo("$otherName left the room")
}
@Test
@ -421,7 +421,7 @@ class DefaultRoomLastMessageFormatterTest {
val someoneKnockedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneKnocked = formatter.format(someoneKnockedEvent, false)
assertThat(someoneKnocked).isEqualTo("${someoneContent.userId} requested to join")
assertThat(someoneKnocked).isEqualTo("$otherName requested to join")
}
@Test
@ -432,7 +432,7 @@ class DefaultRoomLastMessageFormatterTest {
val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent)
val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false)
assertThat(youAcceptedKnock).isEqualTo("${someoneContent.userId} allowed you to join")
assertThat(youAcceptedKnock).isEqualTo("You allowed ${someoneContent.userId} to join")
val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false)
@ -452,7 +452,7 @@ class DefaultRoomLastMessageFormatterTest {
val someoneRetractedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent)
val someoneRetractedKnock = formatter.format(someoneRetractedKnockEvent, false)
assertThat(someoneRetractedKnock).isEqualTo("${someoneContent.userId} is no longer interested in joining")
assertThat(someoneRetractedKnock).isEqualTo("$otherName is no longer interested in joining")
}
@Test
@ -829,9 +829,13 @@ class DefaultRoomLastMessageFormatterTest {
// endregion
private fun createRoomEvent(sentByYou: Boolean, senderDisplayName: String?, content: EventContent): EventTimelineItem {
private fun createRoomEvent(
sentByYou: Boolean,
senderDisplayName: String?,
content: EventContent,
): EventTimelineItem {
val sender = if (sentByYou) A_USER_ID else someoneElseId
val profile = ProfileTimelineDetails.Ready(senderDisplayName, false, null)
val profile = aProfileTimelineDetails(senderDisplayName)
return anEventTimelineItem(
content = content,
senderProfile = profile,

View file

@ -75,13 +75,6 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
RoomListFilters(
key = "feature.roomlistfilters",
title = "Room list filters",
description = "Allow user to filter the room list",
defaultValue = true,
isFinished = false,
),
RoomDirectorySearch(
key = "feature.roomdirectorysearch",
title = "Room directory search",

View file

@ -40,7 +40,6 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.PinUnlock -> true
FeatureFlags.Mentions -> true
FeatureFlags.MarkAsUnread -> true
FeatureFlags.RoomListFilters -> true
FeatureFlags.RoomDirectorySearch -> false
}
} else {

View file

@ -65,7 +65,8 @@ interface MatrixClient : Closeable {
suspend fun setDisplayName(displayName: String): Result<Unit>
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
suspend fun joinRoom(roomId: RoomId): Result<RoomId>
suspend fun joinRoom(roomId: RoomId): Result<Unit>
suspend fun knockRoom(roomId: RoomId): Result<Unit>
fun syncService(): SyncService
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService

View file

@ -27,7 +27,7 @@ data class NotificationData(
val roomId: RoomId,
// mxc url
val senderAvatarUrl: String?,
// private, must use `getSenderName`
// private, must use `getDisambiguatedDisplayName`
private val senderDisplayName: String?,
private val senderIsNameAmbiguous: Boolean,
val roomAvatarUrl: String?,
@ -39,7 +39,7 @@ data class NotificationData(
val content: NotificationContent,
val hasMention: Boolean,
) {
fun getSenderName(userId: UserId): String = when {
fun getDisambiguatedDisplayName(userId: UserId): String = when {
senderDisplayName.isNullOrBlank() -> userId.value
senderIsNameAmbiguous -> "$senderDisplayName ($userId)"
else -> senderDisplayName
@ -52,6 +52,7 @@ sealed interface NotificationContent {
data class CallInvite(
val senderId: UserId,
) : MessageLike
data object CallHangup : MessageLike
data object CallCandidates : MessageLike
data object KeyVerificationReady : MessageLike

View file

@ -0,0 +1,23 @@
/*
* 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.libraries.matrix.api.room
sealed interface RoomType {
data object Space : RoomType
data object Room : RoomType
data class Other(val type: String) : RoomType
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.room.preview
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomType
data class RoomPreview(
/** The room id for this room. */
@ -33,7 +34,7 @@ data class RoomPreview(
/** The number of joined members. */
val numberOfJoinedMembers: Long,
/** The room type (space, custom) or nothing, if it's a regular room. */
val roomType: String?,
val roomType: RoomType,
/** Is the history world-readable for this room? */
val isHistoryWorldReadable: Boolean,
/** Is the room joined by the current user? */

View file

@ -37,7 +37,7 @@ sealed interface RoomSummary {
data class RoomSummaryDetails(
val roomId: RoomId,
val name: String,
val name: String?,
val canonicalAlias: RoomAlias?,
val isDirect: Boolean,
val avatarUrl: String?,

View file

@ -33,8 +33,7 @@ sealed interface InReplyTo {
val eventId: EventId,
val content: EventContent,
val senderId: UserId,
val senderDisplayName: String?,
val senderAvatarUrl: String?,
val senderProfile: ProfileTimelineDetails,
) : InReplyTo
/**

View file

@ -52,3 +52,10 @@ fun ProfileTimelineDetails.getDisambiguatedDisplayName(userId: UserId): String {
else -> userId.value
}
}
fun ProfileTimelineDetails.getAvatarUrl(): String? {
return when (this) {
is ProfileTimelineDetails.Ready -> avatarUrl
else -> null
}
}

View file

@ -21,17 +21,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService {
/**
* This flow stores the local verification status of the current session.
*
* We should ideally base the verified status in the Rust SDK info, but there are several issues with that approach:
*
* - The SDK takes a while to report this value, resulting in a delay of 1-2s in displaying the UI.
* - We need to add a 'Skip' option for testing purposes, which would not be possible if we relied only on the SDK.
* - The SDK sometimes doesn't report the verification state if there is no network connection when the app boots.
*/
val needsVerificationFlow: StateFlow<Boolean>
/**
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
*/
@ -83,11 +72,6 @@ interface SessionVerificationService {
* Returns the verification service state to the initial step.
*/
suspend fun reset()
/**
* Saves the current session state as [verified].
*/
suspend fun saveVerifiedState(verified: Boolean)
}
/** Verification status of the current session. */

View file

@ -29,7 +29,7 @@ class NotificationDataTest {
senderDisplayName = null,
senderIsNameAmbiguous = false,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("@alice:server.org")
assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("@alice:server.org")
}
@Test
@ -38,7 +38,7 @@ class NotificationDataTest {
senderDisplayName = "Alice",
senderIsNameAmbiguous = false,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("Alice")
assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("Alice")
}
@Test
@ -47,7 +47,7 @@ class NotificationDataTest {
senderDisplayName = "Alice",
senderIsNameAmbiguous = true,
)
assertThat(sut.getSenderName(A_USER_ID)).isEqualTo("Alice (@alice:server.org)")
assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("Alice (@alice:server.org)")
}
private fun aNotificationData(

View file

@ -61,6 +61,7 @@ import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
@ -159,7 +160,6 @@ class RustMatrixClient(
syncService = rustSyncService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
sessionStore = sessionStore,
)
private val roomDirectoryService = RustRoomDirectoryService(
@ -187,7 +187,6 @@ class RustMatrixClient(
isTokenValid = false,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Removed session data with token: '...$anonymizedToken'.")
@ -215,7 +214,6 @@ class RustMatrixClient(
isTokenValid = true,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
needsVerification = existingData.needsVerification,
)
sessionStore.updateData(newData)
Timber.d("Saved new session data with token: '...$anonymizedToken'.")
@ -241,7 +239,6 @@ class RustMatrixClient(
client = client,
isSyncServiceReady = rustSyncService.syncState.map { it == SyncState.Running },
sessionCoroutineScope = sessionCoroutineScope,
sessionStore = sessionStore,
)
private val eventFilters = TimelineConfig.excludedEvents
@ -439,7 +436,7 @@ class RustMatrixClient(
runCatching { client.removeAvatar() }
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomId> = withContext(sessionDispatcher) {
override suspend fun joinRoom(roomId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatching {
client.joinRoomById(roomId.value).destroy()
try {
@ -447,10 +444,13 @@ class RustMatrixClient(
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
}
roomId
}
}
override suspend fun knockRoom(roomId: RoomId): Result<Unit> {
return Result.failure(NotImplementedError("Not yet implemented"))
}
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatching {
client.trackRecentlyVisitedRoom(roomId.value)
@ -463,21 +463,15 @@ class RustMatrixClient(
}
}
@Suppress("TooGenericExceptionThrown")
override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<RoomId> = withContext(sessionDispatcher) {
runCatching {
// TODO Waiting for SDK to be released
throw Exception("Not implemented")
// client.resolveRoomAlias(roomAlias.value).let(::RoomId)
client.resolveRoomAlias(roomAlias.value).let(::RoomId)
}
}
@Suppress("TooGenericExceptionThrown")
override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result<RoomPreview> = withContext(sessionDispatcher) {
runCatching {
// TODO Waiting for SDK to be released
throw Exception("Not implemented")
// client.getRoomPreview(roomIdOrAlias.identifier).let(RoomPreviewMapper::map)
client.getRoomPreview(roomIdOrAlias.identifier).let(RoomPreviewMapper::map)
}
}

View file

@ -138,7 +138,6 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
needsVerification = true,
)
}
sessionStore.storeData(sessionData)
@ -187,7 +186,6 @@ class RustMatrixAuthenticationService @Inject constructor(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase,
needsVerification = true,
)
}
pendingOidcAuthenticationData?.close()

View file

@ -25,7 +25,6 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
@ -49,11 +48,10 @@ import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecover
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
internal class RustEncryptionService(
private val client: Client,
client: Client,
syncService: RustSyncService,
sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
) : EncryptionService {
private val service: Encryption = client.encryption()
@ -188,9 +186,6 @@ internal class RustEncryptionService(
override suspend fun recover(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
runCatching {
service.recover(recoveryKey)
val existingSession = sessionStore.getSession(client.userId())
?: error("Failed to save verification state. No session with id ${client.userId()}")
sessionStore.updateData(existingSession.copy(needsVerification = false))
}.mapFailure {
it.mapRecoveryException()
}

View file

@ -25,7 +25,6 @@ internal fun Session.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
needsVerification: Boolean,
) = SessionData(
userId = userId,
deviceId = deviceId,
@ -38,5 +37,4 @@ internal fun Session.toSessionData(
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
needsVerification = needsVerification,
)

View file

@ -0,0 +1,27 @@
/*
* 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.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.RoomType
fun String?.toRoomType(): RoomType {
return when (this) {
null -> RoomType.Room
"m.space" -> RoomType.Space
else -> RoomType.Other(this)
}
}

View file

@ -0,0 +1,42 @@
/*
* 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.libraries.matrix.impl.room.preview
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.impl.room.toRoomType
import org.matrix.rustcomponents.sdk.RoomPreview as RustRoomPreview
object RoomPreviewMapper {
fun map(roomPreview: RustRoomPreview): RoomPreview {
return RoomPreview(
roomId = RoomId(roomPreview.roomId),
canonicalAlias = roomPreview.canonicalAlias?.let(::RoomAlias),
name = roomPreview.name,
topic = roomPreview.topic,
avatarUrl = roomPreview.avatarUrl,
numberOfJoinedMembers = roomPreview.numJoinedMembers.toLong(),
roomType = roomPreview.roomType.toRoomType(),
isHistoryWorldReadable = roomPreview.isHistoryWorldReadable,
isJoined = roomPreview.isJoined,
isInvited = roomPreview.isInvited,
isPublic = roomPreview.isPublic,
canKnock = roomPreview.canKnock
)
}
}

View file

@ -40,7 +40,7 @@ val RoomListFilter.predicate
(roomSummary.details.numUnreadNotifications > 0 || roomSummary.details.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary is RoomSummary.Filled && roomSummary.details.name.contains(pattern, ignoreCase = true)
roomSummary is RoomSummary.Filled && roomSummary.details.name.orEmpty().contains(pattern, ignoreCase = true)
}
RoomListFilter.Invite -> { roomSummary: RoomSummary ->
roomSummary.isInvited()

View file

@ -33,7 +33,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
}
return RoomSummaryDetails(
roomId = RoomId(roomInfo.id),
name = roomInfo.name ?: roomInfo.id,
name = roomInfo.name,
canonicalAlias = roomInfo.canonicalAlias?.let(::RoomAlias),
isDirect = roomInfo.isDirect,
avatarUrl = roomInfo.avatarUrl,

View file

@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RepliedToEventDetails
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
@ -51,13 +50,11 @@ class EventMessageMapper {
val inReplyToId = EventId(details.eventId)
when (val event = details.event) {
is RepliedToEventDetails.Ready -> {
val senderProfile = event.senderProfile as? ProfileDetails.Ready
InReplyTo.Ready(
eventId = inReplyToId,
content = timelineEventContentMapper.map(event.content),
senderId = UserId(event.sender),
senderDisplayName = senderProfile?.displayName,
senderAvatarUrl = senderProfile?.avatarUrl,
senderProfile = event.senderProfile.map(),
)
}
is RepliedToEventDetails.Error -> InReplyTo.Error

View file

@ -16,7 +16,6 @@
package io.element.android.libraries.matrix.impl.verification
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -24,7 +23,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@ -33,10 +31,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -58,7 +53,6 @@ class RustSessionVerificationService(
private val client: Client,
isSyncServiceReady: Flow<Boolean>,
private val sessionCoroutineScope: CoroutineScope,
private val sessionStore: SessionStore,
) : SessionVerificationService, SessionVerificationControllerDelegate {
private val encryptionService: Encryption = client.encryption()
private lateinit var verificationController: SessionVerificationController
@ -80,11 +74,6 @@ class RustSessionVerificationService(
}
})
override val needsVerificationFlow: StateFlow<Boolean> = sessionStore.sessionsFlow()
.map { sessions -> sessions.firstOrNull { it.userId == client.userId() }?.needsVerification.orFalse() }
.distinctUntilChanged()
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow()
@ -98,6 +87,9 @@ class RustSessionVerificationService(
}
init {
// Update initial state in case sliding sync isn't ready
updateVerificationStatus(encryptionService.verificationState())
isReady.onEach { isReady ->
if (isReady) {
Timber.d("Starting verification service")
@ -165,7 +157,6 @@ class RustSessionVerificationService(
}
}
.onSuccess {
saveVerifiedState(true)
updateVerificationStatus(VerificationState.VERIFIED)
_verificationFlowState.value = VerificationFlowState.Finished
}
@ -195,14 +186,6 @@ class RustSessionVerificationService(
_verificationFlowState.value = VerificationFlowState.Initial
}
override suspend fun saveVerifiedState(verified: Boolean) = tryOrFail {
val existingSession = sessionStore.getSession(client.userId())
?: error("Failed to save verification state. No session with id ${client.userId()}")
sessionStore.updateData(existingSession.copy(needsVerification = !verified))
// Wait until the new state is saved
needsVerificationFlow.first { needsVerification -> !needsVerification }
}
fun destroy() {
Timber.d("Destroying RustSessionVerificationService")
verificationStateListenerTaskHandle.cancelAndDestroy()

View file

@ -22,13 +22,20 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.services.analytics.test.FakeAnalyticsService
import org.junit.Test
import org.matrix.rustcomponents.sdk.UnableToDecryptInfo
import uniffi.matrix_sdk_crypto.UtdCause
class UtdTrackerTest {
@Test
fun `when onUtd is called with null timeToDecryptMs, the expected analytics Event is sent`() {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(UnableToDecryptInfo(eventId = AN_EVENT_ID.value, timeToDecryptMs = null))
sut.onUtd(
UnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = null,
cause = UtdCause.UNKNOWN,
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
Error(
context = null,
@ -47,7 +54,13 @@ class UtdTrackerTest {
fun `when onUtd is called with timeToDecryptMs, the expected analytics Event is sent`() {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(UnableToDecryptInfo(eventId = AN_EVENT_ID.value, timeToDecryptMs = 123.toULong()))
sut.onUtd(
UnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = 123.toULong(),
cause = UtdCause.UNKNOWN,
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
Error(
context = null,

View file

@ -103,10 +103,12 @@ class FakeMatrixClient(
private var setDisplayNameResult: Result<Unit> = Result.success(Unit)
private var uploadAvatarResult: Result<Unit> = Result.success(Unit)
private var removeAvatarResult: Result<Unit> = Result.success(Unit)
var joinRoomLambda: (RoomId) -> Result<RoomId> = {
Result.success(it)
var joinRoomLambda: (RoomId) -> Result<Unit> = {
Result.success(Unit)
}
var knockRoomLambda: (RoomId) -> Result<Unit> = {
Result.success(Unit)
}
var getRoomInfoFlowLambda = { _: RoomId ->
flowOf<Optional<MatrixRoomInfo>>(Optional.empty())
}
@ -197,7 +199,9 @@ class FakeMatrixClient(
return removeAvatarResult
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomId> = joinRoomLambda(roomId)
override suspend fun joinRoom(roomId: RoomId): Result<Unit> = joinRoomLambda(roomId)
override suspend fun knockRoom(roomId: RoomId): Result<Unit> = knockRoomLambda(roomId)
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService

View file

@ -63,7 +63,7 @@ fun aRoomSummaryFilled(
fun aRoomSummaryDetails(
roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME,
name: String? = A_ROOM_NAME,
isDirect: Boolean = false,
avatarUrl: String? = null,
lastMessage: RoomMessage? = aRoomMessage(),

View file

@ -20,22 +20,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService(
var saveVerifiedStateResult: LambdaOneParamRecorder<Boolean, Unit> = lambdaRecorder<Boolean, Unit> {}
) : SessionVerificationService {
class FakeSessionVerificationService : SessionVerificationService {
private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var _canVerifySessionFlow = MutableStateFlow(true)
var shouldFail = false
override val needsVerificationFlow: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val verificationFlowState: StateFlow<VerificationFlowState> = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val canVerifySessionFlow: Flow<Boolean> = _canVerifySessionFlow
@ -94,15 +89,7 @@ class FakeSessionVerificationService(
_isReady.value = value
}
fun givenNeedsVerification(value: Boolean) {
needsVerificationFlow.value = value
}
override suspend fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial
}
override suspend fun saveVerifiedState(verified: Boolean) {
saveVerifiedStateResult(verified)
}
}

View file

@ -34,6 +34,7 @@ dependencies {
anvil(projects.anvilcodegen)
implementation(projects.libraries.di)
implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)

View file

@ -0,0 +1,70 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.InviteSender
@Composable
fun InviteSenderView(
inviteSender: InviteSender,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
Avatar(avatarData = inviteSender.avatarData)
Text(
text = inviteSender.annotatedString(),
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@PreviewsDayNight
@Composable
internal fun InviteSenderViewPreview() = ElementPreview {
InviteSenderView(
inviteSender = InviteSender(
userId = UserId("@bob:example.com"),
displayName = "Bob",
avatarData = AvatarData(
id = "@bob:example.com",
name = "Bob",
url = null,
size = AvatarSize.InviteSender
)
)
)
}

View file

@ -0,0 +1,70 @@
/*
* 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.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.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
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
open class RoomSummaryDetailsProvider : PreviewParameterProvider<RoomSummaryDetails> {
override val values: Sequence<RoomSummaryDetails>
get() = sequenceOf(
aRoomSummaryDetails(),
aRoomSummaryDetails(name = null),
)
}
fun aRoomSummaryDetails(
roomId: RoomId = RoomId("!room:domain"),
name: String? = "roomName",
canonicalAlias: RoomAlias? = null,
isDirect: Boolean = true,
avatarUrl: String? = null,
lastMessage: RoomMessage? = null,
inviter: RoomMember? = null,
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
isDm: Boolean = false,
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
inviter = inviter,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDm = isDm,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
)

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
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.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -43,12 +44,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.RoomAlias
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
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.ui.strings.CommonStrings
@ -67,7 +62,8 @@ fun SelectedRoom(
) {
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarUrl, AvatarSize.SelectedRoom))
Text(
text = roomSummary.name,
// If name is null, we do not have space to render "No room name", so just use `#` here.
text = roomSummary.name ?: "#",
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
@ -97,45 +93,11 @@ fun SelectedRoom(
@PreviewsDayNight
@Composable
internal fun SelectedRoomPreview() = ElementPreview {
internal fun SelectedRoomPreview(
@PreviewParameter(RoomSummaryDetailsProvider::class) roomSummaryDetails: RoomSummaryDetails
) = ElementPreview {
SelectedRoom(
roomSummary = aRoomSummaryDetails(),
roomSummary = roomSummaryDetails,
onRoomRemoved = {},
)
}
fun aRoomSummaryDetails(
roomId: RoomId = RoomId("!room:domain"),
name: String = "roomName",
canonicalAlias: RoomAlias? = null,
isDirect: Boolean = true,
avatarUrl: String? = null,
lastMessage: RoomMessage? = null,
inviter: RoomMember? = null,
notificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
isDm: Boolean = false,
numUnreadMentions: Int = 0,
numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
) = RoomSummaryDetails(
roomId = roomId,
name = name,
canonicalAlias = canonicalAlias,
isDirect = isDirect,
avatarUrl = avatarUrl,
lastMessage = lastMessage,
inviter = inviter,
userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall,
isDm = isDm,
numUnreadMentions = numUnreadMentions,
numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
)

View file

@ -132,7 +132,7 @@ fun SelectedUsersRowList(
@PreviewsDayNight
@Composable
internal fun SelectedUsersListPreview() = ElementPreview {
internal fun SelectedUsersRowListPreview() = ElementPreview {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Two users that will be visible with no scrolling
SelectedUsersRowList(

View file

@ -0,0 +1,69 @@
/*
* 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.libraries.matrix.ui.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.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.R
@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
)
)
)
}
}
}
fun RoomMember.toInviteSender() = InviteSender(
userId = userId,
displayName = displayName ?: "",
avatarData = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.InviteSender,
),
)

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) запрасіў вас"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) ви покани"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) te invitó"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité(e)"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) meghívta"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) ti ha invitato"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) v-a invitat."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил вас"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval/a"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) bjöd in dig"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) запросив (-ла) Вас"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s%2$s邀請您"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
</resources>

View file

@ -34,5 +34,8 @@ interface SessionPreferencesStore {
suspend fun setRenderTypingNotifications(enabled: Boolean)
fun isRenderTypingNotificationsEnabled(): Flow<Boolean>
suspend fun setSkipSessionVerification(skip: Boolean)
fun isSessionVerificationSkipped(): Flow<Boolean>
suspend fun clear()
}

View file

@ -29,9 +29,7 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import java.io.File
class DefaultSessionPreferencesStore(
@ -51,9 +49,18 @@ class DefaultSessionPreferencesStore(
private val renderReadReceiptsKey = booleanPreferencesKey("renderReadReceipts")
private val sendTypingNotificationsKey = booleanPreferencesKey("sendTypingNotifications")
private val renderTypingNotificationsKey = booleanPreferencesKey("renderTypingNotifications")
private val skipSessionVerification = booleanPreferencesKey("skipSessionVerification")
private val dataStoreFile = storeFile(context, sessionId)
private val store = PreferenceDataStoreFactory.create(scope = sessionCoroutineScope) { dataStoreFile }
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
migrations = listOf(
SessionPreferencesStoreMigration(
sharePresenceKey,
sendPublicReadReceiptsKey,
)
),
) { dataStoreFile }
override suspend fun setSharePresence(enabled: Boolean) {
update(sharePresenceKey, enabled)
@ -65,8 +72,7 @@ class DefaultSessionPreferencesStore(
}
override fun isSharePresenceEnabled(): Flow<Boolean> {
// Migration, if sendPublicReadReceiptsKey was false, consider that sharing presence is false.
return get(sharePresenceKey) { runBlocking { isSendPublicReadReceiptsEnabled().first() } }
return get(sharePresenceKey) { true }
}
override suspend fun setSendPublicReadReceipts(enabled: Boolean) = update(sendPublicReadReceiptsKey, enabled)
@ -81,6 +87,9 @@ class DefaultSessionPreferencesStore(
override suspend fun setRenderTypingNotifications(enabled: Boolean) = update(renderTypingNotificationsKey, enabled)
override fun isRenderTypingNotificationsEnabled(): Flow<Boolean> = get(renderTypingNotificationsKey) { true }
override suspend fun setSkipSessionVerification(skip: Boolean) = update(skipSessionVerification, skip)
override fun isSessionVerificationSkipped(): Flow<Boolean> = get(skipSessionVerification) { false }
override suspend fun clear() {
dataStoreFile.safeDelete()
}

View file

@ -0,0 +1,39 @@
/*
* 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.libraries.preferences.impl.store
import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.Preferences
class SessionPreferencesStoreMigration(
private val sharePresenceKey: Preferences.Key<Boolean>,
private val sendPublicReadReceiptsKey: Preferences.Key<Boolean>,
) : DataMigration<Preferences> {
override suspend fun cleanUp() = Unit
override suspend fun shouldMigrate(currentData: Preferences): Boolean {
return currentData[sharePresenceKey] == null
}
override suspend fun migrate(currentData: Preferences): Preferences {
// If sendPublicReadReceiptsKey was false, consider that sharing presence is false.
val defaultValue = currentData[sendPublicReadReceiptsKey] ?: true
return currentData.toMutablePreferences().apply {
set(sharePresenceKey, defaultValue)
}.toPreferences()
}
}

View file

@ -26,12 +26,14 @@ class InMemorySessionPreferencesStore(
isRenderReadReceiptsEnabled: Boolean = true,
isSendTypingNotificationsEnabled: Boolean = true,
isRenderTypingNotificationsEnabled: Boolean = true,
isSessionVerificationSkipped: Boolean = false,
) : SessionPreferencesStore {
private val isSharePresenceEnabled = MutableStateFlow(isSharePresenceEnabled)
private val isSendPublicReadReceiptsEnabled = MutableStateFlow(isSendPublicReadReceiptsEnabled)
private val isRenderReadReceiptsEnabled = MutableStateFlow(isRenderReadReceiptsEnabled)
private val isSendTypingNotificationsEnabled = MutableStateFlow(isSendTypingNotificationsEnabled)
private val isRenderTypingNotificationsEnabled = MutableStateFlow(isRenderTypingNotificationsEnabled)
private val isSessionVerificationSkipped = MutableStateFlow(isSessionVerificationSkipped)
var clearCallCount = 0
private set
@ -65,6 +67,14 @@ class InMemorySessionPreferencesStore(
override fun isRenderTypingNotificationsEnabled(): Flow<Boolean> = isRenderTypingNotificationsEnabled
override suspend fun setSkipSessionVerification(skip: Boolean) {
isSessionVerificationSkipped.tryEmit(skip)
}
override fun isSessionVerificationSkipped(): Flow<Boolean> {
return isSessionVerificationSkipped
}
override suspend fun clear() {
clearCallCount++
isSendPublicReadReceiptsEnabled.tryEmit(true)

View file

@ -93,8 +93,8 @@ class NotifiableEventResolver @Inject constructor(
): NotifiableEvent? {
return when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
val senderName = getSenderName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderName)
val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
val notificationBody = if (hasMention) {
stringProvider.getString(R.string.notification_mentioned_you_body, messageBody)
} else {
@ -107,7 +107,7 @@ class NotifiableEventResolver @Inject constructor(
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderName = senderName,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = notificationBody,
imageUriString = fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName,
@ -154,7 +154,7 @@ class NotifiableEventResolver @Inject constructor(
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderName = null,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = stringProvider.getString(CommonStrings.common_call_invite),
imageUriString = fetchImageIfPresent(client)?.toString(),
roomName = roomDisplayName,
@ -180,7 +180,7 @@ class NotifiableEventResolver @Inject constructor(
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderName = getSenderName(content.senderId),
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = stringProvider.getString(CommonStrings.common_poll_summary, content.question),
imageUriString = null,
roomName = roomDisplayName,
@ -244,12 +244,12 @@ class NotifiableEventResolver @Inject constructor(
private fun descriptionFromMessageContent(
content: NotificationContent.MessageLike.RoomMessage,
senderDisplayName: String,
senderDisambiguatedDisplayName: String,
): String {
return when (val messageType = content.messageType) {
is AudioMessageType -> messageType.body
is VoiceMessageType -> stringProvider.getString(CommonStrings.common_voice_message)
is EmoteMessageType -> "* $senderDisplayName ${messageType.body}"
is EmoteMessageType -> "* $senderDisambiguatedDisplayName ${messageType.body}"
is FileMessageType -> messageType.body
is ImageMessageType -> messageType.body
is StickerMessageType -> messageType.body
@ -310,7 +310,7 @@ private fun buildNotifiableMessageEvent(
canBeReplaced: Boolean = false,
noisy: Boolean,
timestamp: Long,
senderName: String?,
senderDisambiguatedDisplayName: String?,
body: String?,
// We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage
@ -335,7 +335,7 @@ private fun buildNotifiableMessageEvent(
canBeReplaced = canBeReplaced,
noisy = noisy,
timestamp = timestamp,
senderName = senderName,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
body = body,
imageUriString = imageUriString,
threadId = threadId,

View file

@ -45,7 +45,7 @@ class RoomGroupMessageCreator @Inject constructor(
imageLoader: ImageLoader,
): RoomNotification.Message {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
@ -60,9 +60,9 @@ class RoomGroupMessageCreator @Inject constructor(
}
val tickerText = if (roomIsGroup) {
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description)
} else {
stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderDisambiguatedDisplayName, events.last().description)
}
val largeBitmap = getRoomBitmap(events, imageLoader)
@ -108,7 +108,7 @@ class RoomGroupMessageCreator @Inject constructor(
null
} else {
Person.Builder()
.setName(event.senderName?.annotateForDebug(70))
.setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader))
.setKey(event.senderId.value)
.build()
@ -152,7 +152,7 @@ class RoomGroupMessageCreator @Inject constructor(
private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence {
return if (roomIsDirect) {
buildSpannedString {
event.senderName?.let {
event.senderDisambiguatedDisplayName?.let {
inSpans(StyleSpan(Typeface.BOLD)) {
append(it)
append(": ")
@ -165,7 +165,7 @@ class RoomGroupMessageCreator @Inject constructor(
inSpans(StyleSpan(Typeface.BOLD)) {
append(roomName)
append(": ")
event.senderName?.let {
event.senderDisambiguatedDisplayName?.let {
append(it)
append(" ")
}

View file

@ -36,7 +36,7 @@ data class NotifiableMessageEvent(
val senderId: UserId,
val noisy: Boolean,
val timestamp: Long,
val senderName: String?,
val senderDisambiguatedDisplayName: String?,
val body: String?,
// We cannot use Uri? type here, as that could trigger a
// NotSerializableException when persisting this to storage
@ -55,7 +55,6 @@ data class NotifiableMessageEvent(
) : NotifiableEvent {
val type: String = EventType.MESSAGE
override val description: String = body ?: ""
val title: String = senderName ?: ""
// Example of value:
// content://io.element.android.x.debug.notifications.fileprovider/downloads/temp/notif/matrix.org/XGItzSDOnSyXjYtOPfiKexDJ

View file

@ -462,7 +462,7 @@ class NotifiableEventResolverTest {
senderId = A_USER_ID_2,
noisy = false,
timestamp = A_TIMESTAMP,
senderName = null,
senderDisambiguatedDisplayName = "Bob",
body = "Call in progress (unsupported)",
imageUriString = null,
threadId = null,
@ -586,7 +586,7 @@ class NotifiableEventResolverTest {
senderId = A_USER_ID_2,
noisy = false,
timestamp = A_TIMESTAMP,
senderName = "Bob",
senderDisambiguatedDisplayName = "Bob",
body = body,
imageUriString = null,
threadId = null,

View file

@ -85,7 +85,7 @@ fun aNotifiableMessageEvent(
editedEventId = null,
noisy = false,
timestamp = timestamp,
senderName = "sender-name",
senderDisambiguatedDisplayName = "sender-name",
senderId = UserId("@sending-id:domain.com"),
body = "message-body",
roomId = roomId,

View file

@ -57,7 +57,7 @@ class RoomSelectPresenter @AssistedInject constructor(
LaunchedEffect(query, summaries) {
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>()
.map { it.details }
.filter { it.name.contains(query, ignoreCase = true) }
.filter { it.name.orEmpty().contains(query, ignoreCase = true) }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
.toPersistentList()
results = if (filteredSummaries.isNotEmpty()) {

View file

@ -68,4 +68,8 @@ private fun aForwardMessagesRoomList() = persistentListOf(
name = "Room with alias",
canonicalAlias = RoomAlias("#alias:example.org"),
),
aRoomSummaryDetails(
roomId = RoomId("!room3:domain"),
name = null,
),
)

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -235,7 +236,8 @@ private fun RoomSummaryView(
// Name
Text(
style = ElementTheme.typography.fontBodyLgRegular,
text = summary.name,
text = summary.name ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { summary.name == null },
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis

View file

@ -44,6 +44,4 @@ data class SessionData(
val loginType: LoginType,
/** The optional passphrase used to encrypt data in the SDK local store. */
val passphrase: String?,
/** Whether the session needs verification. */
val needsVerification: Boolean,
)

View file

@ -34,7 +34,6 @@ internal fun SessionData.toDbModel(): DbSessionData {
isTokenValid = if (isTokenValid) 1L else 0L,
loginType = loginType.name,
passphrase = passphrase,
needsVerification = if (needsVerification) 1L else 0L,
)
}
@ -51,6 +50,5 @@ internal fun DbSessionData.toApiModel(): SessionData {
isTokenValid = isTokenValid == 1L,
loginType = LoginType.fromName(loginType ?: LoginType.UNKNOWN.name),
passphrase = passphrase,
needsVerification = needsVerification == 1L,
)
}

View file

@ -23,9 +23,7 @@ CREATE TABLE SessionData (
isTokenValid INTEGER NOT NULL DEFAULT 1,
loginType TEXT,
-- added in version 5
passphrase TEXT,
-- added in version 6
needsVerification INTEGER NOT NULL DEFAULT 0
passphrase TEXT
);

View file

@ -0,0 +1,20 @@
-- Migrate DB from version 6
-- Remove DB value for verified status, we're back to using the Rust SDK as a source of truth
CREATE TABLE SessionData_bak (
userId TEXT NOT NULL PRIMARY KEY,
deviceId TEXT NOT NULL,
accessToken TEXT NOT NULL,
refreshToken TEXT,
homeserverUrl TEXT NOT NULL,
slidingSyncProxy TEXT,
loginTimestamp INTEGER,
oidcData TEXT,
isTokenValid INTEGER NOT NULL DEFAULT 1,
loginType TEXT,
passphrase TEXT
);
INSERT INTO SessionData_bak SELECT userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy, loginTimestamp, oidcData, isTokenValid, loginType, passphrase FROM SessionData;
DROP TABLE SessionData;
ALTER TABLE SessionData_bak RENAME TO SessionData;

View file

@ -144,7 +144,6 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphrase",
needsVerification = 1L,
)
val secondSessionData = SessionData(
userId = "userId",
@ -158,7 +157,6 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
needsVerification = 0L,
)
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@ -179,7 +177,6 @@ class DatabaseSessionStoreTests {
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
assertThat(alteredSession.needsVerification).isEqualTo(secondSessionData.needsVerification)
}
@Test
@ -196,7 +193,6 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphrase",
needsVerification = 1L,
)
val secondSessionData = SessionData(
userId = "userIdUnknown",
@ -210,7 +206,6 @@ class DatabaseSessionStoreTests {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
needsVerification = 0L,
)
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
@ -229,6 +224,5 @@ class DatabaseSessionStoreTests {
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
assertThat(notAlteredSession.needsVerification).isEqualTo(firstSessionData.needsVerification)
}
}

View file

@ -31,5 +31,4 @@ internal fun aSessionData() = SessionData(
isTokenValid = 1,
loginType = LoginType.UNKNOWN.name,
passphrase = null,
needsVerification = 0L,
)

View file

@ -566,6 +566,8 @@ private fun ReplyToModeView(
modifier = Modifier
.fillMaxWidth()
.clipToBounds(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,
color = ElementTheme.materialColors.primary,
@ -731,7 +733,7 @@ internal fun TextComposerReplyPreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
isThreaded = true,
senderName = "Alice",
senderName = "Alice with a very long name to test overflow in the composer",
eventId = EventId("$1234"),
attachmentThumbnailInfo = null,
defaultContent = "A message\n" +

View file

@ -89,7 +89,7 @@ internal fun FormattingOption(
@PreviewsDayNight
@Composable
internal fun FormattingButtonPreview() = ElementPreview {
internal fun FormattingOptionPreview() = ElementPreview {
Row {
FormattingOption(
state = FormattingOptionState.Default,

View file

@ -164,7 +164,7 @@
<string name="common_offline">"Па-за сеткай"</string>
<string name="common_or">"або"</string>
<string name="common_password">"Пароль"</string>
<string name="common_people">"Удзельнікі"</string>
<string name="common_people">"Людзі"</string>
<string name="common_permalink">"Пастаянная спасылка"</string>
<string name="common_permission">"Дазвол"</string>
<string name="common_poll_end_confirmation">"Вы ўпэўнены, што хочаце скончыць гэтае апытанне?"</string>

View file

@ -196,6 +196,7 @@
<string name="common_settings">"Pengaturan"</string>
<string name="common_shared_location">"Lokasi terbagi"</string>
<string name="common_signing_out">"Mengeluarkan dari akun"</string>
<string name="common_something_went_wrong">"Ada yang salah"</string>
<string name="common_starting_chat">"Memulai obrolan…"</string>
<string name="common_sticker">"Stiker"</string>
<string name="common_success">"Berhasil"</string>

View file

@ -158,12 +158,14 @@
<string name="common_modern">"Modern"</string>
<string name="common_mute">"Mute"</string>
<string name="common_no_results">"No results"</string>
<string name="common_no_room_name">"No room name"</string>
<string name="common_offline">"Offline"</string>
<string name="common_or">"or"</string>
<string name="common_password">"Password"</string>
<string name="common_people">"People"</string>
<string name="common_permalink">"Permalink"</string>
<string name="common_permission">"Permission"</string>
<string name="common_please_wait">"Please wait…"</string>
<string name="common_poll_end_confirmation">"Are you sure you want to end this poll?"</string>
<string name="common_poll_summary">"Poll: %1$s"</string>
<string name="common_poll_total_votes">"Total votes: %1$s"</string>
@ -213,6 +215,7 @@
<string name="common_topic">"Topic"</string>
<string name="common_topic_placeholder">"What is this room about?"</string>
<string name="common_unable_to_decrypt">"Unable to decrypt"</string>
<string name="common_unable_to_decrypt_no_access">"You don\'t have access to this message"</string>
<string name="common_unable_to_invite_message">"Invites couldn\'t be sent to one or more users."</string>
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string>
<string name="common_unlock">"Unlock"</string>