Merge branch 'develop' into feature/fga/draft_support

This commit is contained in:
ganfra 2024-06-26 14:39:44 +02:00
commit 1b56d1b97a
485 changed files with 2939 additions and 1591 deletions

View file

@ -26,19 +26,18 @@ import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Period
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
// TODO rework this date formatting
class DateFormatters @Inject constructor(
private val locale: Locale,
private val clock: Clock,
private val timeZone: TimeZone,
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm"
DateTimeFormatter.ofPattern(pattern, locale)
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
@ -51,6 +50,10 @@ class DateFormatters @Inject constructor(
DateTimeFormatter.ofPattern(pattern, locale)
}
private val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
}
internal fun formatTime(localDateTime: LocalDateTime): String {
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
}
@ -63,6 +66,10 @@ class DateFormatters @Inject constructor(
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String {
return dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDate(
dateToFormat: LocalDateTime,
currentDate: LocalDateTime,

View file

@ -28,6 +28,7 @@ class DefaultDaySeparatorFormatter @Inject constructor(
) : DaySeparatorFormatter {
override fun format(timestamp: Long): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return dateFormatters.formatDateWithYear(dateToFormat)
// TODO use relative formatting once iOS uses it too
return dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.dateformatter.test.FakeClock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.junit.Test
import java.util.Locale
@ -44,7 +45,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35")
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35 PM")
}
@Test
@ -52,7 +53,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35")
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35 PM")
}
@Test
@ -60,7 +61,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:34")
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:34 PM")
}
@Test
@ -68,7 +69,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("17:35")
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("5:35 PM")
}
@Test
@ -96,6 +97,15 @@ class DefaultLastMessageTimestampFormatterTest {
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979")
}
@Test
fun `test full format`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val clock = FakeClock().apply { givenInstant(Instant.parse(now)) }
val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC)
assertThat(dateFormatters.formatDateWithFullFormat(Instant.parse(dat).toLocalDateTime(TimeZone.UTC))).isEqualTo("Friday, April 6, 1979")
}
/**
* Create DefaultLastMessageFormatter and set current time to the provided date.
*/

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@ -52,18 +53,22 @@ fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null,
) {
val commonModifier = modifier
.size(avatarData.size.dp)
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url.isNullOrBlank()) {
InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
)
} else {
ImageAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
contentDescription = contentDescription,
)
@ -73,6 +78,7 @@ fun Avatar(
@Composable
private fun ImageAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
@ -98,9 +104,15 @@ private fun ImageAvatar(
SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
}
InitialsAvatar(avatarData = avatarData)
InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
)
}
else -> InitialsAvatar(avatarData = avatarData)
else -> InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
)
}
}
}
@ -109,13 +121,14 @@ private fun ImageAvatar(
@Composable
private fun InitialsAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
Box(
modifier.background(color = avatarColors.background)
) {
val fontSize = avatarData.size.dp.toSp() / 2
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).toSp() / 2
val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio

View file

@ -36,6 +36,8 @@ enum class AvatarSize(val dp: Dp) {
SelectedUser(56.dp),
SelectedRoom(56.dp),
DmCluster(75.dp),
TimelineRoom(32.dp),
TimelineSender(32.dp),
TimelineReadReceipt(16.dp),
@ -55,4 +57,8 @@ enum class AvatarSize(val dp: Dp) {
CustomRoomNotificationSetting(36.dp),
RoomDirectoryItem(36.dp),
EditProfileDetails(96.dp),
Suggestion(32.dp),
}

View file

@ -0,0 +1,137 @@
/*
* 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.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun CompositeAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
if (avatarData.url != null || heroes.isEmpty()) {
Avatar(avatarData, modifier, contentDescription)
} else {
val limitedHeroes = heroes.take(4)
val numberOfHeroes = limitedHeroes.size
if (numberOfHeroes == 4) {
// Swap 2 and 3 so that the 4th hero is at the bottom right
Collections.swap(limitedHeroes, 2, 3)
}
when (numberOfHeroes) {
0 -> {
error("Unsupported number of heroes: 0")
}
1 -> {
Avatar(heroes[0], modifier, contentDescription)
}
else -> {
val angle = 2 * Math.PI / numberOfHeroes
val offsetRadius = when (numberOfHeroes) {
2 -> avatarData.size.dp.value / 4.2
3 -> avatarData.size.dp.value / 4.0
4 -> avatarData.size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val heroAvatarSize = when (numberOfHeroes) {
2 -> avatarData.size.dp / 2.2f
3 -> avatarData.size.dp / 2.4f
4 -> avatarData.size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val angleOffset = when (numberOfHeroes) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
Box(
modifier = modifier
.size(avatarData.size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedHeroes.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
heroAvatar,
forcedAvatarSize = heroAvatarSize,
)
}
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun CompositeAvatarPreview() = ElementThemedPreview {
val mainAvatar = anAvatarData(
id = "Zac",
name = "Zac",
size = AvatarSize.RoomListItem,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(6) { nbOfHeroes ->
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(nbOfHeroes) { aHeroAvatarData(it) }.toPersistentList(),
)
}
}
}
private fun aHeroAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)

View file

@ -0,0 +1,117 @@
/*
* 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.components.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */
private const val SIZE_RATIO = 1.6f
/**
* https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333
*/
@Composable
fun DmAvatars(
userAvatarData: AvatarData,
otherUserAvatarData: AvatarData,
openAvatarPreview: (url: String) -> Unit,
openOtherAvatarPreview: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val boxSize = userAvatarData.size.dp * SIZE_RATIO
val boxSizePx = boxSize.toPx()
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
Box(
modifier = modifier.size(boxSize),
) {
// Draw user avatar and cut top right corner
Avatar(
avatarData = userAvatarData,
modifier = Modifier
.align(Alignment.BottomStart)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = boxSizePx - otherAvatarRadius,
y = size.height - (boxSizePx - otherAvatarRadius),
),
radius = otherAvatarRadius / 0.9f,
blendMode = BlendMode.Clear,
)
}
.clip(CircleShape)
.clickable(enabled = userAvatarData.url != null) {
userAvatarData.url?.let { openAvatarPreview(it) }
}
)
// Draw other user avatar
Avatar(
avatarData = otherUserAvatarData,
modifier = Modifier
.align(Alignment.TopEnd)
.clip(CircleShape)
.clickable(enabled = otherUserAvatarData.url != null) {
otherUserAvatarData.url?.let { openOtherAvatarPreview(it) }
}
.testTag(TestTags.memberDetailAvatar)
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsPreview() = ElementThemedPreview {
val size = AvatarSize.DmCluster
DmAvatars(
userAvatarData = anAvatarData(
id = "Alice",
name = "Alice",
size = size,
),
otherUserAvatarData = anAvatarData(
id = "Bob",
name = "Bob",
size = size,
),
openAvatarPreview = {},
openOtherAvatarPreview = {},
)
}

View file

@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(avatar ändrades också)"</string>
<string name="state_event_avatar_url_changed">"%1$s bytte sin avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Du bytte din avatar"</string>
<string name="state_event_demoted_to_member">"%1$s degraderades till medlem"</string>
<string name="state_event_demoted_to_moderator">"%1$s degraderades till moderator"</string>
<string name="state_event_display_name_changed_from">"%1$s bytte sitt visningsnamn från %2$s till %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Du bytte ditt visningsnamn från %1$s till %2$s"</string>
<string name="state_event_display_name_removed">"%1$s tog bort sitt visningsnamn (det var %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du tog bort ditt visningsnamn (det var %1$s)"</string>
<string name="state_event_display_name_set">"%1$s satte sitt visningsnamn till %2$s"</string>
<string name="state_event_display_name_set_by_you">"Du satte ditt visningsnamn till %1$s"</string>
<string name="state_event_promoted_to_administrator">"%1$s befordrades till admin"</string>
<string name="state_event_promoted_to_moderator">"%1$s befordrades till moderator"</string>
<string name="state_event_room_avatar_changed">"%1$s bytte rummets avatar"</string>
<string name="state_event_room_avatar_changed_by_you">"Du bytte rummets avatar"</string>
<string name="state_event_room_avatar_removed">"%1$s tog bort rummets avatar"</string>

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@ -49,5 +50,6 @@ data class MatrixRoomInfo(
val notificationCount: Long,
val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean,
val activeRoomCallParticipants: ImmutableList<String>
val activeRoomCallParticipants: ImmutableList<String>,
val heroes: ImmutableList<MatrixUser>,
)

View file

@ -39,3 +39,7 @@ fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
fun MatrixRoomMembersState.joinedRoomMembers(): List<RoomMember> {
return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN }
}
fun MatrixRoomMembersState.activeRoomMembers(): List<RoomMember> {
return roomMembers().orEmpty().filter { it.membership.isActive() }
}

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.matrix.api.room.powerlevels
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.joinedRoomMembers
import io.element.android.libraries.matrix.api.room.activeRoomMembers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
@ -27,14 +27,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Return a flow of the list of room members who are still in the room (with membership == RoomMembershipState.JOIN)
* and who have the given role.
* Return a flow of the list of active room members who have the given role.
*/
fun MatrixRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
return roomInfoFlow
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
.combine(membersStateFlow) { powerLevels, membersState ->
membersState.joinedRoomMembers()
membersState.activeRoomMembers()
.filter { powerLevels.containsKey(it.userId) }
.toPersistentList()
}

View file

@ -22,6 +22,7 @@ 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.user.MatrixUser
sealed interface RoomSummary {
data class Empty(val identifier: String) : RoomSummary
@ -52,6 +53,7 @@ data class RoomSummaryDetails(
val isDm: Boolean,
val isFavorite: Boolean,
val currentUserMembership: CurrentUserMembership,
val heroes: List<MatrixUser>,
) {
val lastMessageTimestamp = lastMessage?.originServerTs
}

View file

@ -22,11 +22,10 @@ import io.element.android.libraries.matrix.api.core.EventId
@Immutable
sealed interface LocalEventSendState {
data object NotSentYet : LocalEventSendState
data class SendingFailed(
val error: String
) : LocalEventSendState
sealed class SendingFailed(open val error: String) : LocalEventSendState {
data class Recoverable(override val error: String) : SendingFailed(error)
data class Unrecoverable(override val error: String) : SendingFailed(error)
}
data class Sent(
val eventId: EventId
) : LocalEventSendState

View file

@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap
import org.matrix.rustcomponents.sdk.RoomHero
import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
@ -55,7 +57,8 @@ class MatrixRoomInfoMapper {
notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall,
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList()
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(),
heroes = it.elementHeroes().toImmutableList()
)
}
}
@ -72,6 +75,15 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
/**
* Map a RoomHero to a MatrixUser. There is not need to create a RoomHero type on the application side.
*/
fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl
)
fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
}

View file

@ -0,0 +1,33 @@
/*
* 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.user.MatrixUser
import org.matrix.rustcomponents.sdk.RoomInfo
/**
* Extract the heroes from the room info.
* For now we only use heroes for direct rooms with 2 members.
* Also we keep the heroes only if there is one single hero.
*/
fun RoomInfo.elementHeroes(): List<MatrixUser> {
return heroes
.takeIf { isDirect && activeMembersCount.toLong() == 2L }
?.takeIf { it.size == 1 }
?.map { it.map() }
.orEmpty()
}

View file

@ -20,6 +20,7 @@ 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.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.elementHeroes
import io.element.android.libraries.matrix.impl.room.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
@ -49,6 +50,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
isFavorite = roomInfo.isFavourite,
currentUserMembership = roomInfo.membership.map(),
heroes = roomInfo.elementHeroes(),
)
}
}

View file

@ -77,7 +77,13 @@ fun RustEventSendState?.map(): LocalEventSendState? {
return when (this) {
null -> null
RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet
is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error)
is RustEventSendState.SendingFailed -> {
if (this.isRecoverable) {
LocalEventSendState.SendingFailed.Recoverable(this.error)
} else {
LocalEventSendState.SendingFailed.Unrecoverable(this.error)
}
}
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
}
}

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerL
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -764,7 +765,8 @@ fun aRoomInfo(
userDefinedNotificationMode: RoomNotificationMode? = null,
hasRoomCall: Boolean = false,
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
activeRoomCallParticipants: List<String> = emptyList()
activeRoomCallParticipants: List<String> = emptyList(),
heroes: List<MatrixUser> = emptyList(),
) = MatrixRoomInfo(
id = id,
name = name,
@ -789,6 +791,7 @@ fun aRoomInfo(
hasRoomCall = hasRoomCall,
userPowerLevels = userPowerLevels,
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
heroes = heroes.toImmutableList(),
)
fun defaultRoomPowerLevels() = MatrixRoomPowerLevels(

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.message.RoomMessage
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
@ -78,6 +79,7 @@ fun aRoomSummaryDetails(
isDm: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
heroes: List<MatrixUser> = emptyList(),
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@ -95,6 +97,7 @@ fun aRoomSummaryDetails(
isDm = isDm,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
heroes = heroes,
)
fun aRoomMessage(

View file

@ -51,6 +51,7 @@ dependencies {
ksp(libs.showkase.processor)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
@ -62,4 +63,5 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testImplementation(projects.libraries.sessionStorage.test)
}

View file

@ -24,6 +24,7 @@ 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.matrix.api.user.MatrixUser
open class RoomSummaryDetailsProvider : PreviewParameterProvider<RoomSummaryDetails> {
override val values: Sequence<RoomSummaryDetails>
@ -50,6 +51,7 @@ fun aRoomSummaryDetails(
isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
heroes: List<MatrixUser> = emptyList(),
) = RoomSummaryDetails(
roomId = roomId,
name = name,
@ -67,4 +69,5 @@ fun aRoomSummaryDetails(
isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
currentUserMembership = currentUserMembership,
heroes = heroes,
)

View file

@ -36,16 +36,17 @@ 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
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.preview.ElementPreview
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.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectedRoom(
@ -60,7 +61,12 @@ fun SelectedRoom(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(AvatarData(roomSummary.roomId.value, roomSummary.name, roomSummary.avatarUrl, AvatarSize.SelectedRoom))
CompositeAvatar(
avatarData = roomSummary.getAvatarData(size = AvatarSize.SelectedRoom),
heroes = roomSummary.heroes.map { user ->
user.getAvatarData(size = AvatarSize.SelectedRoom)
}.toImmutableList()
)
Text(
// If name is null, we do not have space to render "No room name", so just use `#` here.
text = roomSummary.name ?: "#",

View file

@ -22,18 +22,24 @@ import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
class LoggedInImageLoaderFactory(
private val context: Context,
private val matrixClient: MatrixClient,
interface LoggedInImageLoaderFactory {
fun newImageLoader(matrixClient: MatrixClient): ImageLoader
}
@ContributesBinding(AppScope::class)
class DefaultLoggedInImageLoaderFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
) : LoggedInImageLoaderFactory {
override fun newImageLoader(matrixClient: MatrixClient): ImageLoader {
return ImageLoader
.Builder(context)
.okHttpClient { okHttpClient.get() }

View file

@ -16,29 +16,25 @@
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import coil.ImageLoader
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Provider
interface ImageLoaderHolder {
fun get(client: MatrixClient): ImageLoader
fun remove(sessionId: SessionId)
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultImageLoaderHolder @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
private val loggedInImageLoaderFactory: LoggedInImageLoaderFactory,
private val sessionObserver: SessionObserver,
) : ImageLoaderHolder {
private val map = mutableMapOf<SessionId, ImageLoader>()
@ -52,7 +48,7 @@ class DefaultImageLoaderHolder @Inject constructor(
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
map.remove(SessionId(userId))
remove(SessionId(userId))
}
})
}
@ -60,12 +56,15 @@ class DefaultImageLoaderHolder @Inject constructor(
override fun get(client: MatrixClient): ImageLoader {
return synchronized(map) {
map.getOrPut(client.sessionId) {
LoggedInImageLoaderFactory(
context = context,
matrixClient = client,
okHttpClient = okHttpClient,
).newImageLoader()
loggedInImageLoaderFactory
.newImageLoader(client)
}
}
}
override fun remove(sessionId: SessionId) {
synchronized(map) {
map.remove(sessionId)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.messages
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
class RoomMemberProfilesCache @Inject constructor() {
private val cache = MutableStateFlow(mapOf<UserId, RoomMember>())
private val _lastCacheUpdate = MutableStateFlow(0L)
val lastCacheUpdate: StateFlow<Long> = _lastCacheUpdate
fun replace(items: List<RoomMember>) {
cache.value = items.associateBy { it.userId }
_lastCacheUpdate.tryEmit(_lastCacheUpdate.value + 1)
}
fun getDisplayName(userId: UserId): String? {
return cache.value[userId]?.disambiguatedDisplayName
}
}
val LocalRoomMemberProfilesCache = staticCompositionLocalOf {
RoomMemberProfilesCache()
}

View file

@ -60,10 +60,5 @@ data class InviteSender(
fun RoomMember.toInviteSender() = InviteSender(
userId = userId,
displayName = displayName ?: "",
avatarData = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.InviteSender,
),
avatarData = getAvatarData(size = AvatarSize.InviteSender),
)

View file

@ -0,0 +1,28 @@
/*
* 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 io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomMember
fun RoomMember.getAvatarData(size: AvatarSize) = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = size,
)

View file

@ -0,0 +1,28 @@
/*
* 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 io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
fun RoomSummaryDetails.getAvatarData(size: AvatarSize) = AvatarData(
id = roomId.value,
name = name,
url = avatarUrl,
size = size,
)

View file

@ -63,3 +63,14 @@ fun MatrixRoom.getDirectRoomMember(roomMembersState: MatrixRoomMembersState): St
}
}
}
@Composable
fun MatrixRoom.getCurrentRoomMember(roomMembersState: MatrixRoomMembersState): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembersState) {
derivedStateOf {
roomMembers
?.find { it.userId == sessionId }
}
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.media
import androidx.test.platform.app.InstrumentationRegistry
import coil.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultImageLoaderHolderTest {
@Test
fun `get - returns the same ImageLoader for the same client`() {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda = lambdaRecorder<MatrixClient, ImageLoader> { ImageLoader.Builder(context).build() }
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = NoOpSessionObserver()
)
val client = FakeMatrixClient()
val imageLoader1 = holder.get(client)
val imageLoader2 = holder.get(client)
assert(imageLoader1 === imageLoader2)
lambda.assertions()
.isCalledOnce()
.with(value(client))
}
@Test
fun `when session is deleted, the image loader is deleted`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda =
lambdaRecorder<MatrixClient, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
val client = FakeMatrixClient()
holder.get(client)
sessionObserver.onSessionDeleted(client.sessionId.value)
holder.get(client)
lambda.assertions()
.isCalledExactly(2)
}
@Test
fun `when session is created, nothing happen`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda =
lambdaRecorder<MatrixClient, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
sessionObserver.onSessionCreated(A_SESSION_ID.value)
}
}

View file

@ -0,0 +1,28 @@
/*
* 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.media
import coil.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
class FakeLoggedInImageLoaderFactory(
private val newImageLoaderLambda: (MatrixClient) -> ImageLoader
) : LoggedInImageLoaderFactory {
override fun newImageLoader(matrixClient: MatrixClient): ImageLoader {
return newImageLoaderLambda(matrixClient)
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages
package io.element.android.libraries.matrix.ui.messages
import android.net.Uri
import com.google.common.truth.Truth.assertThat
@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

View file

@ -14,14 +14,13 @@
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages
package io.element.android.libraries.matrix.ui.messages
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.messages.toPlainText
import org.jsoup.Jsoup
import org.junit.Test
import org.junit.runner.RunWith

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages.reply
package io.element.android.libraries.matrix.ui.messages.reply
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
@ -28,8 +28,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map
import org.junit.Test
class InReplyToDetailTest {

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.matrixui.messages.reply
package io.element.android.libraries.matrix.ui.messages.reply
import android.content.res.Configuration
import androidx.compose.runtime.Composable
@ -60,9 +60,6 @@ import io.element.android.libraries.matrix.test.timeline.aProfileTimelineDetails
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata
import io.element.android.libraries.matrix.ui.messages.reply.metadata
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_check_permission_description">"Kontrollera att applikationen kan visa aviseringar."</string>
<string name="troubleshoot_notifications_test_check_permission_title">"Kontrollera behörigheter"</string>
</resources>

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Apel"</string>
<string name="notification_channel_listening_for_events">"Ascultare evenimente"</string>
<string name="notification_channel_noisy">"Notificări zgomotoase"</string>
<string name="notification_channel_ringing_calls">"Apeluri care sună"</string>
<string name="notification_channel_silent">"Notificări silențioase"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d mesaj"</item>
@ -13,6 +14,7 @@
<item quantity="other">"%d notificări"</item>
</plurals>
<string name="notification_fallback_content">"Notificare"</string>
<string name="notification_incoming_call">"Apel primit"</string>
<string name="notification_inline_reply_failed">"** Trimiterea eșuată - vă rugăm să deschideți camera"</string>
<string name="notification_invitation_action_join">"Alăturați-vă"</string>
<string name="notification_invitation_action_reject">"Respingeți"</string>

View file

@ -48,4 +48,28 @@
<string name="push_distributor_background_sync_android">"Bakgrundssynkronisering"</string>
<string name="push_distributor_firebase_android">"Google-tjänster"</string>
<string name="push_no_valid_google_play_services_apk_android">"Inga giltiga Google Play-tjänster hittades. Aviseringar kanske inte fungerar korrekt."</string>
<string name="troubleshoot_notifications_test_current_push_provider_description">"Hämta namnet på den nuvarande leverantören."</string>
<string name="troubleshoot_notifications_test_current_push_provider_failure">"Inga push-leverantörer valda."</string>
<string name="troubleshoot_notifications_test_current_push_provider_success">"Nuvarande push-leverantör: %1$s."</string>
<string name="troubleshoot_notifications_test_current_push_provider_title">"Nuvarande push-leverantör"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Se till att applikationen har minst en push-leverantör."</string>
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Inga push-leverantörer hittades."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"Hittade %1$d push-leverantör: %2$s"</item>
<item quantity="other">"Hittade %1$d push-leverantörer: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Upptäck push-leverantörer"</string>
<string name="troubleshoot_notifications_test_display_notification_description">"Kontrollera att applikationen kan visa avisering."</string>
<string name="troubleshoot_notifications_test_display_notification_failure">"Aviseringen har inte klickats på."</string>
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Kan inte visa aviseringen."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Aviseringen har klickats på!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Visa avisering"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Vänligen klicka på aviseringen för att fortsätta testet."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Kontrollera att applikationen tar emot push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Fel: pusher har avvisat begäran."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Fel: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Fel, kan inte testa push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Fel, tidsgräns överskriden vid väntan på push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Returnering av push tog %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Testa returnering av push"</string>
</resources>

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.push.test.notifications
import coil.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
class FakeImageLoaderHolder : ImageLoaderHolder {
@ -25,4 +26,8 @@ class FakeImageLoaderHolder : ImageLoaderHolder {
override fun get(client: MatrixClient): ImageLoader {
return fakeImageLoader.getImageLoader()
}
override fun remove(sessionId: SessionId) {
// No-op
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_firebase_availability_description">"Se till att Firebase är tillgängligt."</string>
<string name="troubleshoot_notifications_test_firebase_availability_failure">"Firebase är inte tillgängligt."</string>
<string name="troubleshoot_notifications_test_firebase_availability_success">"Firebase är tillgänglig."</string>
<string name="troubleshoot_notifications_test_firebase_availability_title">"Kontrollera Firebase"</string>
<string name="troubleshoot_notifications_test_firebase_token_description">"Se till att Firebase-token är tillgänglig."</string>
<string name="troubleshoot_notifications_test_firebase_token_failure">"Firebase-token är inte känd."</string>
<string name="troubleshoot_notifications_test_firebase_token_success">"Firebase-token: %1$s."</string>
<string name="troubleshoot_notifications_test_firebase_token_title">"Kontrollera Firebase-token"</string>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_test_unified_push_description">"Se till att UnifiedPush-distributörer är tillgängliga."</string>
<string name="troubleshoot_notifications_test_unified_push_failure">"Inga push-distributörer hittades."</string>
<plurals name="troubleshoot_notifications_test_unified_push_success">
<item quantity="one">"%1$d distributör hittades:%2$s."</item>
<item quantity="other">"%1$d distributörer hittade:%2$s."</item>
</plurals>
<string name="troubleshoot_notifications_test_unified_push_title">"Kontrollera UnifiedPush"</string>
</resources>

View file

@ -41,9 +41,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.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.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -59,9 +58,11 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.components.SelectedRoom
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -221,13 +222,11 @@ private fun RoomSummaryView(
.heightIn(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = AvatarData(
id = summary.roomId.value,
name = summary.name,
url = summary.avatarUrl,
size = AvatarSize.RoomSelectRoomListItem,
),
CompositeAvatar(
avatarData = summary.getAvatarData(size = AvatarSize.RoomSelectRoomListItem),
heroes = summary.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem)
}.toPersistentList()
)
Column(
modifier = Modifier

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.sessionstorage.test.observer
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
class FakeSessionObserver : SessionObserver {
private val _listeners = mutableListOf<SessionListener>()
val listeners: List<SessionListener>
get() = _listeners
override fun addListener(listener: SessionListener) {
_listeners.add(listener)
}
override fun removeListener(listener: SessionListener) {
_listeners.remove(listener)
}
suspend fun onSessionCreated(userId: String) {
listeners.forEach { it.onSessionCreated(userId) }
}
suspend fun onSessionDeleted(userId: String) {
listeners.forEach { it.onSessionDeleted(userId) }
}
}

View file

@ -17,6 +17,7 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}
@ -27,7 +28,13 @@ android {
}
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)

View file

@ -52,9 +52,9 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider
import io.element.android.libraries.testtags.TestTags
@ -70,7 +70,7 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageRecordin
import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput
import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.libraries.textcomposer.model.TextEditorState
@ -93,7 +93,6 @@ fun TextComposer(
permalinkParser: PermalinkParser,
composerMode: MessageComposerMode,
enableVoiceMessages: Boolean,
currentUserId: UserId,
onRequestFocus: () -> Unit,
onSendMessage: () -> Unit,
onResetComposerMode: () -> Unit,
@ -145,6 +144,8 @@ fun TextComposer(
}
}
val userProfileCache = LocalRoomMemberProfilesCache.current
val placeholder = if (composerMode.inThread) {
stringResource(id = CommonStrings.action_reply_in_thread)
} else {
@ -154,17 +155,22 @@ fun TextComposer(
is TextEditorState.Rich -> {
remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) {
@Composable {
val mentionSpanProvider = rememberMentionSpanProvider(
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val mentionSpanProvider = LocalMentionSpanProvider.current
TextInput(
state = state.richTextEditorState,
subcomposing = subcomposing,
placeholder = placeholder,
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) },
resolveMentionDisplay = { text, url ->
val permalinkData = permalinkParser.parse(url)
if (permalinkData is PermalinkData.UserLink) {
val displayNameOrId = userProfileCache.getDisplayName(permalinkData.userId) ?: permalinkData.userId.value
TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(displayNameOrId, url))
} else {
TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
}
},
resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) },
onError = onError,
onTyping = onTyping,
@ -518,7 +524,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost"),
)
},
{
@ -527,7 +532,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
@ -541,7 +545,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
},
{
@ -550,7 +553,6 @@ internal fun TextComposerSimplePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}
)
@ -567,7 +569,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}, {
ATextComposer(
@ -576,7 +577,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}, {
ATextComposer(
@ -589,7 +589,6 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
showTextFormatting = true,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}))
}
@ -603,7 +602,6 @@ internal fun TextComposerEditPreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), TransactionId("1234"), "Some text"),
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}))
}
@ -617,7 +615,6 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Edit(EventId("$1234"), TransactionId("1234"), "Some text"),
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}))
}
@ -626,13 +623,12 @@ internal fun MarkdownTextComposerEditPreview() = ElementPreview {
@Composable
internal fun TextComposerReplyPreview(@PreviewParameter(InReplyToDetailsProvider::class) inReplyToDetails: InReplyToDetails) = ElementPreview {
ATextComposer(
TextEditorState.Rich(aRichTextEditorState()),
state = TextEditorState.Rich(aRichTextEditorState()),
voiceMessageState = VoiceMessageState.Idle,
composerMode = MessageComposerMode.Reply(
replyToDetails = inReplyToDetails,
),
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
}
@ -647,7 +643,6 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = voiceMessageState,
composerMode = MessageComposerMode.Normal,
enableVoiceMessages = true,
currentUserId = UserId("@alice:localhost")
)
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, createFakeWaveform()))
@ -708,7 +703,6 @@ private fun ATextComposer(
voiceMessageState: VoiceMessageState,
composerMode: MessageComposerMode,
enableVoiceMessages: Boolean,
currentUserId: UserId,
showTextFormatting: Boolean = false,
) {
TextComposer(
@ -720,7 +714,6 @@ private fun ATextComposer(
},
composerMode = composerMode,
enableVoiceMessages = enableVoiceMessages,
currentUserId = currentUserId,
onRequestFocus = {},
onSendMessage = {},
onResetComposerMode = {},

View file

@ -21,12 +21,14 @@ import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.style.ReplacementSpan
import androidx.core.text.getSpans
import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlin.math.min
import kotlin.math.roundToInt
class MentionSpan(
val text: String,
text: String,
val rawValue: String,
val type: Type,
val backgroundColor: Int,
@ -39,23 +41,27 @@ class MentionSpan(
private const val MAX_LENGTH = 20
}
private var actualText: CharSequence? = null
private var textWidth = 0
private val backgroundPaint = Paint().apply {
isAntiAlias = true
color = backgroundColor
}
var text: String = text
set(value) {
field = value
mentionText = getActualText(text)
}
private var mentionText: CharSequence = getActualText(text)
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
val mentionText = getActualText(this.text)
paint.typeface = typeface
textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt()
return textWidth + startPadding + endPadding
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val mentionText = getActualText(this.text)
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
@ -68,7 +74,6 @@ class MentionSpan(
}
private fun getActualText(text: String): CharSequence {
if (actualText != null) return actualText!!
return buildString {
val mentionText = text.orEmpty()
when (type) {
@ -87,7 +92,6 @@ class MentionSpan(
if (mentionText.length > MAX_LENGTH) {
append("")
}
actualText = this
}
}
@ -96,3 +100,18 @@ class MentionSpan(
ROOM,
}
}
fun CharSequence.getMentionSpans(): List<MentionSpan> {
return if (this is android.text.Spanned) {
val customMentionSpans = getSpans<CustomMentionSpan>()
if (customMentionSpans.isNotEmpty()) {
// If we have custom mention spans created by the RTE, we need to extract the provided spans and filter them
customMentionSpans.map { it.providedSpan }.filterIsInstance<MentionSpan>()
} else {
// Otherwise try to get the spans directly
getSpans<MentionSpan>().toList()
}
} else {
emptyList()
}
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.textcomposer.mentions
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.layout.PaddingValues
@ -25,12 +26,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.buildSpannedString
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -40,7 +45,6 @@ import io.element.android.libraries.designsystem.theme.currentUserMentionPillTex
import io.element.android.libraries.designsystem.theme.mentionPillBackground
import io.element.android.libraries.designsystem.theme.mentionPillText
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@ -48,22 +52,28 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.persistentListOf
@Stable
class MentionSpanProvider(
private val currentSessionId: SessionId,
class MentionSpanProvider @AssistedInject constructor(
@Assisted private val currentSessionId: String,
private val permalinkParser: PermalinkParser,
private var currentUserTextColor: Int = 0,
private var currentUserBackgroundColor: Int = Color.WHITE,
private var otherTextColor: Int = 0,
private var otherBackgroundColor: Int = Color.WHITE,
) {
@AssistedFactory
interface Factory {
fun create(currentSessionId: String): MentionSpanProvider
}
private val paddingValues = PaddingValues(start = 4.dp, end = 6.dp)
private val paddingValuesPx = mutableStateOf(0 to 0)
private val typeface = mutableStateOf(Typeface.DEFAULT)
internal var currentUserTextColor: Int = 0
internal var currentUserBackgroundColor: Int = Color.WHITE
internal var otherTextColor: Int = 0
internal var otherBackgroundColor: Int = Color.WHITE
@Suppress("ComposableNaming")
@Composable
internal fun setup() {
fun updateStyles() {
currentUserTextColor = ElementTheme.colors.currentUserMentionPillText.toArgb()
currentUserBackgroundColor = ElementTheme.colors.currentUserMentionPillBackground.toArgb()
otherTextColor = ElementTheme.colors.mentionPillText.toArgb()
@ -82,7 +92,7 @@ class MentionSpanProvider(
val (startPaddingPx, endPaddingPx) = paddingValuesPx.value
return when {
permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId == currentSessionId
val isCurrentUser = permalinkData.userId.value == currentSessionId
MentionSpan(
text = text,
rawValue = permalinkData.userId.toString(),
@ -134,43 +144,30 @@ class MentionSpanProvider(
}
}
@Composable
fun rememberMentionSpanProvider(
currentUserId: SessionId,
permalinkParser: PermalinkParser,
): MentionSpanProvider {
val provider = remember(currentUserId) {
MentionSpanProvider(
currentSessionId = currentUserId,
permalinkParser = permalinkParser,
)
}
provider.setup()
return provider
}
@PreviewsDayNight
@Composable
internal fun MentionSpanPreview() {
val provider = rememberMentionSpanProvider(
currentUserId = SessionId("@me:matrix.org"),
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
"https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
eventId = null,
viaParameters = persistentListOf(),
)
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
ElementPreview {
provider.setup()
val provider = remember {
MentionSpanProvider(
currentSessionId = "@me:matrix.org",
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return when (uriString) {
"https://matrix.to/#/@me:matrix.org" -> PermalinkData.UserLink(UserId("@me:matrix.org"))
"https://matrix.to/#/@other:matrix.org" -> PermalinkData.UserLink(UserId("@other:matrix.org"))
"https://matrix.to/#/#room:matrix.org" -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias("#room:matrix.org").toRoomIdOrAlias(),
eventId = null,
viaParameters = persistentListOf(),
)
else -> throw AssertionError("Unexpected value $uriString")
}
}
},
)
}
provider.updateStyles()
val textColor = ElementTheme.colors.textPrimary.toArgb()
fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org")
@ -199,3 +196,14 @@ internal fun MentionSpanPreview() {
})
}
}
val LocalMentionSpanProvider = staticCompositionLocalOf {
MentionSpanProvider(
currentSessionId = "@dummy:value.org",
permalinkParser = object : PermalinkParser {
override fun parse(uriString: String): PermalinkData {
return PermalinkData.FallbackLink(Uri.EMPTY)
}
},
)
}

View file

@ -98,7 +98,7 @@ class MarkdownTextEditorState(
replace(start, end, "@room")
} else {
val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue
replace(start, end, "[${mention.text}]($link)")
replace(start, end, "[${mention.rawValue}]($link)")
}
}
}

View file

@ -164,7 +164,7 @@ class MarkdownTextInputTest {
editor = it.findEditor()
state.insertMention(
ResolvedMentionSuggestion.Member(roomMember = aRoomMember()),
MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser),
MentionSpanProvider(currentSessionId = A_SESSION_ID.value, permalinkParser = permalinkParser),
permalinkBuilder,
)
}

View file

@ -43,13 +43,14 @@ class MentionSpanProviderTest {
private val permalinkParser = FakePermalinkParser()
private val mentionSpanProvider = MentionSpanProvider(
currentSessionId = currentUserId,
currentSessionId = currentUserId.value,
permalinkParser = permalinkParser,
currentUserBackgroundColor = myUserColor,
currentUserTextColor = myUserColor,
otherBackgroundColor = otherColor,
otherTextColor = otherColor,
)
).apply {
currentUserBackgroundColor = myUserColor
currentUserTextColor = myUserColor
otherBackgroundColor = otherColor
otherTextColor = otherColor
}
@Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() {

View file

@ -124,7 +124,7 @@ class MarkdownTextEditorStateTest {
val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder)
assertThat(markdown).isEqualTo(
"Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
"Hello [@alice:matrix.org](https://matrix.to/#/@alice:matrix.org) and everyone in @room"
)
}
@ -151,7 +151,7 @@ class MarkdownTextEditorStateTest {
currentSessionId: SessionId = A_SESSION_ID,
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
): MentionSpanProvider {
return MentionSpanProvider(currentSessionId, permalinkParser)
return MentionSpanProvider(currentSessionId.value, permalinkParser)
}
private fun aMarkdownTextWithMentions(): CharSequence {

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="troubleshoot_notifications_screen_action">"Kör tester"</string>
<string name="troubleshoot_notifications_screen_action_again">"Kör tester igen"</string>
<string name="troubleshoot_notifications_screen_notice">"Kör testerna för att upptäcka eventuella problem i din konfiguration som kan göra att aviseringar inte fungerar som förväntat."</string>
<string name="troubleshoot_notifications_screen_quick_fix_action">"Försök att fixa"</string>
<string name="troubleshoot_notifications_screen_title">"Felsök aviseringar"</string>
</resources>

View file

@ -131,12 +131,16 @@
<string name="common_decryption_error">"Памылка расшыфроўкі"</string>
<string name="common_developer_options">"Параметры распрацоўшчыка"</string>
<string name="common_direct_chat">"Прамы чат"</string>
<string name="common_do_not_show_this_again">"Не паказваць гэта зноў"</string>
<string name="common_edited_suffix">"(Адрэдагавана)"</string>
<string name="common_editing">"Рэдагаванне"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Шыфраванне ўключана"</string>
<string name="common_enter_your_pin">"Увядзіце свой PIN-код"</string>
<string name="common_error">"Памылка"</string>
<string name="common_error_registering_pusher_android">"Адбылася памылка, вы можаце не атрымліваць апавяшчэнні аб новых паведамленнях. Ліквідуйце непаладкі з апавяшчэннямі ў наладах.
Прычына: %1$s."</string>
<string name="common_everyone">"Усе"</string>
<string name="common_failed">"Памылка"</string>
<string name="common_favourite">"Абраць"</string>
@ -255,8 +259,6 @@
<string name="error_missing_microphone_voice_rationale_android">"%1$s не мае дазволу на доступ да вашага мікрафона. Дазвольце доступ да запісу галасавога паведамлення."</string>
<string name="error_some_messages_have_not_been_sent">"Некаторыя паведамленні не былі адпраўлены"</string>
<string name="error_unknown">"На жаль, адбылася памылка"</string>
<string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string>
<string name="full_screen_intent_banner_title">"Палепшыце якасць званкоў"</string>
<string name="invite_friends_rich_title">"🔐️ Далучайцеся да мяне %1$s"</string>
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>

View file

@ -131,12 +131,16 @@
<string name="common_decryption_error">"Chyba dešifrování"</string>
<string name="common_developer_options">"Možnosti pro vývojáře"</string>
<string name="common_direct_chat">"Přímý chat"</string>
<string name="common_do_not_show_this_again">"Znovu nezobrazovat"</string>
<string name="common_edited_suffix">"(upraveno)"</string>
<string name="common_editing">"Úpravy"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Šifrování povoleno"</string>
<string name="common_enter_your_pin">"Zadejte svůj PIN"</string>
<string name="common_error">"Chyba"</string>
<string name="common_error_registering_pusher_android">"Došlo k chybě, nemusíte dostávat oznámení o nových zprávách. Vyřešte prosím problémy s oznámeními z nastavení.
Důvod: %1$s."</string>
<string name="common_everyone">"Všichni"</string>
<string name="common_failed">"Selhalo"</string>
<string name="common_favourite">"Oblíbené"</string>
@ -255,8 +259,6 @@
<string name="error_missing_microphone_voice_rationale_android">"%1$s nemá oprávnění k přístupu k mikrofonu. Povolte přístup k nahrávání hlasové zprávy."</string>
<string name="error_some_messages_have_not_been_sent">"Některé zprávy nebyly odeslány"</string>
<string name="error_unknown">"Omlouváme se, došlo k chybě"</string>
<string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string>
<string name="full_screen_intent_banner_title">"Vylepšete si zážitek z volání"</string>
<string name="invite_friends_rich_title">"🔐️ Připojte se ke mně na %1$s"</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>

View file

@ -129,12 +129,16 @@
<string name="common_decryption_error">"Erreur de déchiffrement"</string>
<string name="common_developer_options">"Options pour les développeurs"</string>
<string name="common_direct_chat">"Discussion à deux"</string>
<string name="common_do_not_show_this_again">"Ne plus afficher"</string>
<string name="common_edited_suffix">"(modifié)"</string>
<string name="common_editing">"Édition"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Chiffrement activé"</string>
<string name="common_enter_your_pin">"Saisissez votre code PIN"</string>
<string name="common_error">"Erreur"</string>
<string name="common_error_registering_pusher_android">"Une erreur sest produite, il est possible que vous ne receviez pas de notifications pour les nouveaux messages. Veuillez résoudre les problèmes liés aux notifications depuis les paramètres.
Raison: %1$s."</string>
<string name="common_everyone">"Tout le monde"</string>
<string name="common_failed">"Échec"</string>
<string name="common_favourite">"Favori"</string>
@ -251,8 +255,6 @@
<string name="error_missing_microphone_voice_rationale_android">"%1$s na pas lautorisation daccéder au microphone. Autorisez laccès pour enregistrer un message."</string>
<string name="error_some_messages_have_not_been_sent">"Certains messages nont pas été envoyés"</string>
<string name="error_unknown">"Désolé, une erreur sest produite"</string>
<string name="full_screen_intent_banner_message">"Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."</string>
<string name="full_screen_intent_banner_title">"Améliorez votre expérience dappel"</string>
<string name="invite_friends_rich_title">"🔐️ Rejoignez-moi sur %1$s"</string>
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>

View file

@ -85,6 +85,7 @@
<string name="action_quick_reply">"Raspuns rapid"</string>
<string name="action_quote">"Citat"</string>
<string name="action_react">"Reacționați"</string>
<string name="action_reject">"Respinge"</string>
<string name="action_remove">"Ștergeți"</string>
<string name="action_reply">"Răspundeți"</string>
<string name="action_reply_in_thread">"Răspundeți în fir"</string>
@ -121,6 +122,7 @@
<string name="common_blocked_users">"Utilizatori blocați"</string>
<string name="common_bubbles">"Baloane"</string>
<string name="common_call_invite">"Apel în curs (nesuportat)"</string>
<string name="common_call_started">"A început un apel"</string>
<string name="common_chat_backup">"Backup conversații"</string>
<string name="common_copyright">"Drepturi de autor"</string>
<string name="common_creating_room">"Se creează camera…"</string>
@ -200,6 +202,7 @@
<string name="common_search_results">"Rezultatele căutării"</string>
<string name="common_security">"Securitate"</string>
<string name="common_seen_by">"Văzut de"</string>
<string name="common_send_to">"Trimiteți către"</string>
<string name="common_sending">"Se trimite…"</string>
<string name="common_sending_failed">"Trimiterea a eșuat"</string>
<string name="common_sent">"Trimis"</string>

View file

@ -131,12 +131,16 @@
<string name="common_decryption_error">"Ошибка расшифровки"</string>
<string name="common_developer_options">"Для разработчика"</string>
<string name="common_direct_chat">"Личный чат"</string>
<string name="common_do_not_show_this_again">"Не показывать больше"</string>
<string name="common_edited_suffix">"(изменено)"</string>
<string name="common_editing">"Редактирование"</string>
<string name="common_emote">"%1$s%2$s"</string>
<string name="common_encryption_enabled">"Шифрование включено"</string>
<string name="common_enter_your_pin">"Введите свой PIN-код"</string>
<string name="common_error">"Ошибка"</string>
<string name="common_error_registering_pusher_android">"Произошла ошибка, возможно, вы не будете получать уведомления о новых сообщениях. Устраните неполадки с уведомлениями в настройках.
Причина:%1$s."</string>
<string name="common_everyone">"Для всех"</string>
<string name="common_failed">"Ошибка"</string>
<string name="common_favourite">"Избранное"</string>
@ -257,8 +261,6 @@
<string name="error_missing_microphone_voice_rationale_android">"%1$s не имеет разрешения на доступ к вашему микрофону. Разрешите доступ к записи голосового сообщения."</string>
<string name="error_some_messages_have_not_been_sent">"Некоторые сообщения не были отправлены"</string>
<string name="error_unknown">"Извините, произошла ошибка"</string>
<string name="full_screen_intent_banner_message">"Чтобы никогда не пропустить важный звонок, измените настройки, чтобы разрешить полноэкранные уведомления, когда ваш телефон заблокирован."</string>
<string name="full_screen_intent_banner_title">"Улучшите качество звонков"</string>
<string name="invite_friends_rich_title">"🔐️ Присоединяйтесь ко мне в %1$s"</string>
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>

View file

@ -131,12 +131,16 @@
<string name="common_decryption_error">"Chyba dešifrovania"</string>
<string name="common_developer_options">"Možnosti pre vývojárov"</string>
<string name="common_direct_chat">"Priama konverzácia"</string>
<string name="common_do_not_show_this_again">"Nezobrazovať toto znova"</string>
<string name="common_edited_suffix">"(upravené)"</string>
<string name="common_editing">"Upravuje sa"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Šifrovanie zapnuté"</string>
<string name="common_enter_your_pin">"Zadajte svoj PIN"</string>
<string name="common_error">"Chyba"</string>
<string name="common_error_registering_pusher_android">"Vyskytla sa chyba, nemusíte dostávať upozornenia na nové správy. Prosím, vyriešte problémy s upozorneniami v nastaveniach.
Dôvod: %1$s."</string>
<string name="common_everyone">"Všetci"</string>
<string name="common_failed">"Zlyhalo"</string>
<string name="common_favourite">"Obľúbené"</string>
@ -255,8 +259,6 @@
<string name="error_missing_microphone_voice_rationale_android">"%1$s nemá povolenie na prístup k vášmu mikrofónu. Povoľte prístup na nahrávanie hlasovej správy."</string>
<string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string>
<string name="full_screen_intent_banner_message">"Aby ste už nikdy nezmeškali dôležitý hovor, zmeňte svoje nastavenia a povoľte upozornenia na celú obrazovku, keď je váš telefón uzamknutý."</string>
<string name="full_screen_intent_banner_title">"Vylepšite svoj zážitok z hovoru"</string>
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string>
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>

View file

@ -49,6 +49,7 @@
<string name="action_decline">"Neka"</string>
<string name="action_delete_poll">"Radera omröstning"</string>
<string name="action_disable">"Inaktivera"</string>
<string name="action_discard">"Kassera"</string>
<string name="action_done">"Klar"</string>
<string name="action_edit">"Redigera"</string>
<string name="action_edit_poll">"Redigera omröstning"</string>
@ -57,6 +58,7 @@
<string name="action_enter_pin">"Ange PIN-kod"</string>
<string name="action_forgot_password">"Glömt lösenordet?"</string>
<string name="action_forward">"Vidarebefordra"</string>
<string name="action_go_back">"Gå tillbaka"</string>
<string name="action_invite">"Bjud in"</string>
<string name="action_invite_friends">"Bjud in personer"</string>
<string name="action_invite_friends_to_app">"Bjud in personer till %1$s"</string>
@ -84,6 +86,7 @@
<string name="action_reply_in_thread">"Svara i tråd"</string>
<string name="action_report_bug">"Rapportera bugg"</string>
<string name="action_report_content">"Rapportera innehåll"</string>
<string name="action_reset">"Återställ"</string>
<string name="action_retry">"Försök igen"</string>
<string name="action_retry_decryption">"Försök att avkryptera igen"</string>
<string name="action_save">"Spara"</string>
@ -113,6 +116,7 @@
<string name="common_audio">"Ljud"</string>
<string name="common_blocked_users">"Blockerade användare"</string>
<string name="common_bubbles">"Bubblor"</string>
<string name="common_call_invite">"Samtal pågår (stöds inte)"</string>
<string name="common_chat_backup">"Chattsäkerhetskopia"</string>
<string name="common_copyright">"Upphovsrätt"</string>
<string name="common_creating_room">"Skapar rum …"</string>
@ -181,6 +185,7 @@
<string name="common_room">"Rum"</string>
<string name="common_room_name">"Rumsnamn"</string>
<string name="common_room_name_placeholder">"t.ex. ditt projektnamn"</string>
<string name="common_saved_changes">"Sparade ändringar"</string>
<string name="common_saving">"Sparar"</string>
<string name="common_screen_lock">"Skärmlås"</string>
<string name="common_search_for_someone">"Sök efter någon"</string>
@ -224,6 +229,8 @@
<string name="dialog_title_error">"Fel"</string>
<string name="dialog_title_success">"Lyckades"</string>
<string name="dialog_title_warning">"Varning"</string>
<string name="dialog_unsaved_changes_description_android">"Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?"</string>
<string name="dialog_unsaved_changes_title">"Spara ändringar?"</string>
<string name="error_failed_creating_the_permalink">"Misslyckades att skapa permalänken"</string>
<string name="error_failed_loading_map">"%1$s kunde inte ladda kartan. Vänligen försök igen senare."</string>
<string name="error_failed_loading_messages">"Misslyckades att ladda meddelanden"</string>

View file

@ -255,8 +255,6 @@ Reason: %1$s."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s does not have permission to access your microphone. Enable access to record a voice message."</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string>
<string name="full_screen_intent_banner_message">"To ensure you never miss an important call, please change your settings to allow full-screen notifications when your phone is locked."</string>
<string name="full_screen_intent_banner_title">"Enhance your call experience"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>