Merge branch 'develop' into feature/fga/draft_support
This commit is contained in:
commit
1b56d1b97a
485 changed files with 2939 additions and 1591 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ?: "#",
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 s’est 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 n’a pas l’autorisation d’accéder au microphone. Autorisez l’accès pour enregistrer un message."</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Certains messages n’ont pas été envoyés"</string>
|
||||
<string name="error_unknown">"Désolé, une erreur s’est 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 d’appel"</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue