Merge branch 'develop' into feature/fga/draft_support

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

View file

@ -11,7 +11,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@12.3.2 uses: danger/danger-js@12.3.3
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:

View file

@ -245,7 +245,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@12.3.2 uses: danger/danger-js@12.3.3
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:

View file

@ -24,14 +24,14 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@VisibleForTesting @VisibleForTesting
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 1500L const val SEND_QUEUES_RETRY_DELAY_MILLIS = 500L
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class SendQueues @Inject constructor( class SendQueues @Inject constructor(
@ -45,14 +45,12 @@ class SendQueues @Inject constructor(
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@OptIn(FlowPreview::class)
matrixClient.sendQueueDisabledFlow() matrixClient.sendQueueDisabledFlow()
.onEach { roomId: RoomId -> .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
Timber.d("Send queue disabled for room $roomId") .onEach { _: RoomId ->
if (networkMonitor.connectivity.value == NetworkStatus.Online) { if (networkMonitor.connectivity.value == NetworkStatus.Online) {
delay(SEND_QUEUES_RETRY_DELAY_MILLIS) matrixClient.setAllSendQueuesEnabled(enabled = true)
matrixClient.getRoom(roomId)?.use { room ->
room.setSendQueueEnabled(enabled = true)
}
} }
} }
.launchIn(coroutineScope) .launchIn(coroutineScope)

View file

@ -22,6 +22,7 @@ import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.features.login.api.LoginUserStory import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -39,6 +40,7 @@ class RootNavStateFlowFactory @Inject constructor(
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService, private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder, private val matrixClientsHolder: MatrixClientsHolder,
private val imageLoaderHolder: ImageLoaderHolder,
private val loginUserStory: LoginUserStory, private val loginUserStory: LoginUserStory,
) { ) {
private var currentCacheIndex = 0 private var currentCacheIndex = 0
@ -69,6 +71,8 @@ class RootNavStateFlowFactory @Inject constructor(
return cacheService.clearedCacheEventFlow return cacheService.clearedCacheEventFlow
.onEach { sessionId -> .onEach { sessionId ->
matrixClientsHolder.remove(sessionId) matrixClientsHolder.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId)
} }
.toIndexFlow(initialCacheIndex) .toIndexFlow(initialCacheIndex)
.onEach { cacheIndex -> .onEach { cacheIndex ->

View file

@ -55,12 +55,13 @@ import org.junit.Test
runCurrent() runCurrent()
assert(setAllSendQueuesEnabledLambda) assert(setAllSendQueuesEnabledLambda)
.isCalledOnce() .isCalledExactly(2)
.with(value(true)) .withSequence(
listOf(value(true)),
listOf(value(true)),
)
assert(setRoomSendQueueEnabledLambda) assert(setRoomSendQueueEnabledLambda).isNeverCalled()
.isCalledOnce()
.with(value(true))
} }
@Test @Test

1
changelog.d/2916.misc Normal file
View file

@ -0,0 +1 @@
Use a more natural date format for day dividers in the timeline. Also improve the time format for last messages in the room list.

1
changelog.d/3051.misc Normal file
View file

@ -0,0 +1 @@
Resolve display names in mentions in real time, also send mentions with user ids as the fallback text for the link representation of the mentions.

1
changelog.d/3081.bugfix Normal file
View file

@ -0,0 +1 @@
Let roles and permissions screens work for invited room members too.

1
changelog.d/3082.bugfix Normal file
View file

@ -0,0 +1 @@
Fix image rendering after clear cache

1
changelog.d/3083.bugfix Normal file
View file

@ -0,0 +1 @@
Improve room filters behavior

1
changelog.d/3085.bugfix Normal file
View file

@ -0,0 +1 @@
Make sure we replace the 'answer call' pending intent on ringing call notifications.

1
changelog.d/3086.bugfix Normal file
View file

@ -0,0 +1 @@
Make sure we don't use the main dispatcher while closing the bug report request, as it can lead to crashes in strict mode.

View file

@ -35,7 +35,7 @@ internal object IntentProvider {
context, context,
DefaultElementCallEntryPoint.REQUEST_CODE, DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType), createIntent(context, callType),
0, PendingIntent.FLAG_CANCEL_CURRENT,
false false
)!! )!!
} }

View file

@ -3,4 +3,5 @@
<string name="call_foreground_service_channel_title_android">"Apel în curs"</string> <string name="call_foreground_service_channel_title_android">"Apel în curs"</string>
<string name="call_foreground_service_message_android">"Atingeți pentru a reveni la apel."</string> <string name="call_foreground_service_message_android">"Atingeți pentru a reveni la apel."</string>
<string name="call_foreground_service_title_android">"☎️ Apel în curs"</string> <string name="call_foreground_service_title_android">"☎️ Apel în curs"</string>
<string name="screen_incoming_call_subtitle_android">"Primiți un apel Element Call"</string>
</resources> </resources>

View file

@ -39,6 +39,20 @@
<string name="screen_qr_code_login_connection_note_secure_state_title">"Conexiunea nu este sigură"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Conexiunea nu este sigură"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv."</string> <string name="screen_qr_code_login_device_code_subtitle">"Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv."</string>
<string name="screen_qr_code_login_device_code_title">"Introduceți numărul de mai jos pe celălalt dispozitiv"</string> <string name="screen_qr_code_login_device_code_title">"Introduceți numărul de mai jos pe celălalt dispozitiv"</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_description">"Autentificați-vă pe celălalt dispozitiv și apoi încercați din nou sau utilizați un alt dispozitiv care este deja conectat."</string>
<string name="screen_qr_code_login_device_not_signed_in_scan_state_subtitle">"Celălalt dispozitiv nu este conectat"</string>
<string name="screen_qr_code_login_error_cancelled_subtitle">"Autentificarea a fost anulată de pe celălalt dispozitiv."</string>
<string name="screen_qr_code_login_error_cancelled_title">"Cererea de autentificare a fost anulată"</string>
<string name="screen_qr_code_login_error_declined_subtitle">"Autentificarea a fost refuzată pe celălalt dispozitiv."</string>
<string name="screen_qr_code_login_error_declined_title">"Autentificarea a fost refuzată"</string>
<string name="screen_qr_code_login_error_expired_subtitle">"Autentificarea a expirat. Vă rugăm să încercați din nou."</string>
<string name="screen_qr_code_login_error_expired_title">"Autentificarea nu a fost finalizată la timp"</string>
<string name="screen_qr_code_login_error_linking_not_suported_subtitle">"Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR.
Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv."</string>
<string name="screen_qr_code_login_error_linking_not_suported_title">"Formatul codului QR nu este acceptat."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_subtitle">"Furnizorul dumneavoastră de cont nu acceptă %1$s."</string>
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s nu este acceptat"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Gata de scanare"</string> <string name="screen_qr_code_login_initial_state_button_title">"Gata de scanare"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Deschideți %1$s pe un dispozitiv desktop"</string> <string name="screen_qr_code_login_initial_state_item_1">"Deschideți %1$s pe un dispozitiv desktop"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Faceți clic pe avatarul dumneavoastră"</string> <string name="screen_qr_code_login_initial_state_item_2">"Faceți clic pe avatarul dumneavoastră"</string>

View file

@ -18,7 +18,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node import com.bumble.appyx.core.node.node
@ -64,12 +66,20 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -82,6 +92,9 @@ class MessagesFlowNode @AssistedInject constructor(
private val createPollEntryPoint: CreatePollEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint,
private val elementCallEntryPoint: ElementCallEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val room: MatrixRoom,
private val roomMemberProfilesCache: RoomMemberProfilesCache,
mentionSpanProviderFactory: MentionSpanProvider.Factory,
) : BaseFlowNode<MessagesFlowNode.NavTarget>( ) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Messages, initialElement = NavTarget.Messages,
@ -137,6 +150,18 @@ class MessagesFlowNode @AssistedInject constructor(
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull() private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
private val mentionSpanProvider = mentionSpanProviderFactory.create(room.sessionId.value)
override fun onBuilt() {
super.onBuilt()
room.membersStateFlow
.onEach { membersState ->
roomMemberProfilesCache.replace(membersState.joinedRoomMembers())
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.Messages -> { is NavTarget.Messages -> {
@ -345,6 +370,13 @@ class MessagesFlowNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier) mentionSpanProvider.updateStyles()
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
LocalMentionSpanProvider provides mentionSpanProvider,
) {
BackstackWithOverlayBox(modifier)
}
} }
} }

View file

@ -73,12 +73,14 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState import io.element.android.libraries.matrix.ui.room.canRedactOtherAsState
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -138,6 +140,9 @@ class MessagesPresenter @AssistedInject constructor(
val roomAvatar: AsyncData<AvatarData> by remember { val roomAvatar: AsyncData<AvatarData> by remember {
derivedStateOf { roomInfo?.avatarData()?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized } derivedStateOf { roomInfo?.avatarData()?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
} }
val heroes by remember {
derivedStateOf { roomInfo?.heroes().orEmpty().toPersistentList() }
}
var hasDismissedInviteDialog by rememberSaveable { var hasDismissedInviteDialog by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
@ -204,6 +209,7 @@ class MessagesPresenter @AssistedInject constructor(
roomId = room.roomId, roomId = room.roomId,
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = heroes,
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther, userHasPermissionToRedactOther = userHasPermissionToRedactOther,
@ -237,6 +243,12 @@ class MessagesPresenter @AssistedInject constructor(
) )
} }
private fun MatrixRoomInfo.heroes(): List<AvatarData> {
return heroes.map { user ->
user.getAvatarData(size = AvatarSize.TimelineRoom)
}
}
private fun CoroutineScope.handleTimelineAction( private fun CoroutineScope.handleTimelineAction(
action: TimelineItemAction, action: TimelineItemAction,
targetEvent: TimelineItem.Event, targetEvent: TimelineItem.Event,

View file

@ -29,12 +29,14 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class MessagesState( data class MessagesState(
val roomId: RoomId, val roomId: RoomId,
val roomName: AsyncData<String>, val roomName: AsyncData<String>,
val roomAvatar: AsyncData<AvatarData>, val roomAvatar: AsyncData<AvatarData>,
val heroes: ImmutableList<AvatarData>,
val userHasPermissionToSendMessage: Boolean, val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToRedactOwn: Boolean, val userHasPermissionToRedactOwn: Boolean,
val userHasPermissionToRedactOther: Boolean, val userHasPermissionToRedactOther: Boolean,

View file

@ -99,8 +99,8 @@ fun aMessagesState(
userHasPermissionToSendReaction: Boolean = true, userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState( composerState: MessageComposerState = aMessageComposerState(
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
isFullScreen = false, isFullScreen = false,
mode = MessageComposerMode.Normal, mode = MessageComposerMode.Normal,
), ),
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
timelineState: TimelineState = aTimelineState( timelineState: TimelineState = aTimelineState(
@ -121,6 +121,7 @@ fun aMessagesState(
roomId = RoomId("!id:domain"), roomId = RoomId("!id:domain"),
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = persistentListOf(),
userHasPermissionToSendMessage = userHasPermissionToSendMessage, userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn, userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
userHasPermissionToRedactOther = userHasPermissionToRedactOther, userHasPermissionToRedactOther = userHasPermissionToRedactOther,

View file

@ -83,9 +83,9 @@ import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType import io.element.android.libraries.designsystem.components.ProgressDialogType
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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -187,6 +187,7 @@ fun MessagesView(
MessagesViewTopBar( MessagesViewTopBar(
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(),
heroes = state.heroes,
callState = state.callState, callState = state.callState,
onBackClick = { onBackClick = {
// Since the textfield is now based on an Android view, this is no longer done automatically. // Since the textfield is now based on an Android view, this is no longer done automatically.
@ -442,6 +443,7 @@ private fun MessagesViewComposerBottomSheetContents(
private fun MessagesViewTopBar( private fun MessagesViewTopBar(
roomName: String?, roomName: String?,
roomAvatar: AvatarData?, roomAvatar: AvatarData?,
heroes: ImmutableList<AvatarData>,
callState: RoomCallState, callState: RoomCallState,
onRoomDetailsClick: () -> Unit, onRoomDetailsClick: () -> Unit,
onJoinCallClick: () -> Unit, onJoinCallClick: () -> Unit,
@ -457,6 +459,7 @@ private fun MessagesViewTopBar(
RoomAvatarAndNameRow( RoomAvatarAndNameRow(
roomName = roomName, roomName = roomName,
roomAvatar = roomAvatar, roomAvatar = roomAvatar,
heroes = heroes,
modifier = titleModifier modifier = titleModifier
) )
} else { } else {
@ -500,13 +503,17 @@ private fun CallMenuItem(
private fun RoomAvatarAndNameRow( private fun RoomAvatarAndNameRow(
roomName: String, roomName: String,
roomAvatar: AvatarData, roomAvatar: AvatarData,
heroes: ImmutableList<AvatarData>,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar(roomAvatar) CompositeAvatar(
avatarData = roomAvatar,
heroes = heroes,
)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = roomName, text = roomName,

View file

@ -104,7 +104,9 @@ class ActionListPresenter @Inject constructor(
is TimelineItemStateContent -> { is TimelineItemStateContent -> {
buildList { buildList {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
add(TimelineItemAction.CopyLink) if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -128,7 +130,9 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
} }
add(TimelineItemAction.CopyLink) if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -145,8 +149,8 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.isRemote) { if (timelineItem.isRemote) {
add(TimelineItemAction.Reply) add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward) add(TimelineItemAction.Forward)
add(TimelineItemAction.CopyLink)
} }
add(TimelineItemAction.CopyLink)
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }
@ -187,7 +191,9 @@ class ActionListPresenter @Inject constructor(
if (timelineItem.content.canBeCopied()) { if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy) add(TimelineItemAction.Copy)
} }
add(TimelineItemAction.CopyLink) if (timelineItem.isRemote) {
add(TimelineItemAction.CopyLink)
}
if (isDeveloperModeEnabled) { if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource) add(TimelineItemAction.ViewSource)
} }

View file

@ -37,7 +37,8 @@ class DefaultComposerDraftService @Inject constructor(
.onSuccess { draft -> .onSuccess { draft ->
room.clearComposerDraft() room.clearComposerDraft()
Timber.d("Loaded composer draft for room $roomId : $draft") Timber.d("Loaded composer draft for room $roomId : $draft")
}.getOrNull() }
.getOrNull()
} }
} }

View file

@ -93,14 +93,14 @@ private fun RoomMemberSuggestionItemView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val avatarSize = AvatarSize.TimelineRoom
val avatarData = when (memberSuggestion) { val avatarData = when (memberSuggestion) {
is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = AvatarSize.Suggestion)
?: AvatarData(roomId, roomName, null, AvatarSize.Suggestion)
is ResolvedMentionSuggestion.Member -> AvatarData( is ResolvedMentionSuggestion.Member -> AvatarData(
memberSuggestion.roomMember.userId.value, id = memberSuggestion.roomMember.userId.value,
memberSuggestion.roomMember.displayName, name = memberSuggestion.roomMember.displayName,
memberSuggestion.roomMember.avatarUrl, url = memberSuggestion.roomMember.avatarUrl,
avatarSize, size = AvatarSize.Suggestion,
) )
} }
val title = when (memberSuggestion) { val title = when (memberSuggestion) {

View file

@ -57,7 +57,6 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
@ -66,8 +65,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -111,7 +110,6 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: DefaultMessageComposerContext, private val messageComposerContext: DefaultMessageComposerContext,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
private val currentSessionIdHolder: CurrentSessionIdHolder,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@ -208,7 +206,7 @@ class MessageComposerPresenter @Inject constructor(
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() } val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
LaunchedEffect(isMentionsEnabled) { LaunchedEffect(isMentionsEnabled) {
if (!isMentionsEnabled) return@LaunchedEffect if (!isMentionsEnabled) return@LaunchedEffect
val currentUserId = currentSessionIdHolder.current val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean { suspend fun canSendRoomMention(): Boolean {
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
@ -258,14 +256,7 @@ class MessageComposerPresenter @Inject constructor(
loadDraft(markdownTextEditorState, richTextEditorState) loadDraft(markdownTextEditorState, richTextEditorState)
} }
val mentionSpanProvider = if (isTesting) { val mentionSpanProvider = LocalMentionSpanProvider.current
null
} else {
rememberMentionSpanProvider(
currentUserId = room.sessionId,
permalinkParser = permalinkParser,
)
}
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
@ -379,19 +370,17 @@ class MessageComposerPresenter @Inject constructor(
richTextEditorState.insertAtRoomMentionAtSuggestion() richTextEditorState.insertAtRoomMentionAtSuggestion()
} }
is ResolvedMentionSuggestion.Member -> { is ResolvedMentionSuggestion.Member -> {
val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value val text = mention.roomMember.userId.value
val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch
richTextEditorState.insertMentionAtSuggestion(text = text, link = link) richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
} }
} }
} else if (markdownTextEditorState.currentMentionSuggestion != null) { } else if (markdownTextEditorState.currentMentionSuggestion != null) {
mentionSpanProvider?.let { markdownTextEditorState.insertMention(
markdownTextEditorState.insertMention( mention = event.mention,
mention = event.mention, mentionSpanProvider = mentionSpanProvider,
mentionSpanProvider = it, permalinkBuilder = permalinkBuilder,
permalinkBuilder = permalinkBuilder, )
)
}
suggestionSearchTrigger.value = null suggestionSearchTrigger.value = null
} }
} }
@ -413,7 +402,6 @@ class MessageComposerPresenter @Inject constructor(
canCreatePoll = canCreatePoll.value, canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value, attachmentsState = attachmentsState.value,
memberSuggestions = memberSuggestions.toPersistentList(), memberSuggestions = memberSuggestions.toPersistentList(),
currentUserId = currentSessionIdHolder.current,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
@ -38,7 +37,6 @@ data class MessageComposerState(
val canCreatePoll: Boolean, val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState, val attachmentsState: AttachmentsState,
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>, val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
val currentUserId: UserId,
val eventSink: (MessageComposerEvents) -> Unit, val eventSink: (MessageComposerEvents) -> Unit,
) )

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.aRichTextEditorState
@ -57,6 +56,5 @@ fun aMessageComposerState(
canCreatePoll = canCreatePoll, canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState, attachmentsState = attachmentsState,
memberSuggestions = memberSuggestions, memberSuggestions = memberSuggestions,
currentUserId = UserId("@alice:localhost"),
eventSink = {}, eventSink = {},
) )

View file

@ -124,7 +124,6 @@ internal fun MessageComposerView(
onReceiveSuggestion = ::onSuggestionReceived, onReceiveSuggestion = ::onSuggestionReceived,
onError = ::onError, onError = ::onError,
onTyping = ::onTyping, onTyping = ::onTyping,
currentUserId = state.currentUserId,
onSelectRichContent = ::sendUri, onSelectRichContent = ::sendUri,
) )
} }

View file

@ -28,9 +28,8 @@ import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay import io.element.android.wysiwyg.display.TextDisplay
@ -40,9 +39,7 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class) @SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor( class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
private val permalinkParser: PermalinkParser,
) : HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null) private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable @Composable
@ -53,10 +50,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
} }
val editorStyle = ElementRichTextEditorStyle.textStyle() val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider( val mentionSpanProvider = LocalMentionSpanProvider.current
currentUserId = currentUserId,
permalinkParser = permalinkParser,
)
val context = LocalContext.current val context = LocalContext.current

View file

@ -81,7 +81,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = false, isMine = false,
content = content, content = content,
groupPosition = TimelineItemGroupPosition.Middle, groupPosition = TimelineItemGroupPosition.Middle,
sendState = LocalEventSendState.SendingFailed("Message failed to send"), sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
), ),
aTimelineItemEvent( aTimelineItemEvent(
isMine = false, isMine = false,
@ -104,7 +104,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
isMine = true, isMine = true,
content = content, content = content,
groupPosition = TimelineItemGroupPosition.Middle, groupPosition = TimelineItemGroupPosition.Middle,
sendState = LocalEventSendState.SendingFailed("Message failed to send"), sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
), ),
aTimelineItemEvent( aTimelineItemEvent(
isMine = true, isMine = true,

View file

@ -32,7 +32,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@ -40,8 +46,10 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider import io.element.android.features.messages.impl.timeline.model.bubble.BubbleStateProvider
import io.element.android.libraries.core.extensions.to01 import io.element.android.libraries.core.extensions.to01
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromMeBackground
@ -51,6 +59,7 @@ import io.element.android.libraries.testtags.testTag
private val BUBBLE_RADIUS = 12.dp private val BUBBLE_RADIUS = 12.dp
internal val BUBBLE_INCOMING_OFFSET = 16.dp internal val BUBBLE_INCOMING_OFFSET = 16.dp
private val avatarRadius = AvatarSize.TimelineSender.dp / 2
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now. // Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f private const val BUBBLE_WIDTH_RATIO = 0.85f
@ -66,11 +75,12 @@ fun MessageEventBubble(
content: @Composable () -> Unit = {}, content: @Composable () -> Unit = {},
) { ) {
fun bubbleShape(): Shape { fun bubbleShape(): Shape {
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
return when (state.groupPosition) { return when (state.groupPosition) {
TimelineItemGroupPosition.First -> if (state.isMine) { TimelineItemGroupPosition.First -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS) RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
} else { } else {
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp) RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
} }
TimelineItemGroupPosition.Middle -> if (state.isMine) { TimelineItemGroupPosition.Middle -> if (state.isMine) {
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS) RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
@ -84,7 +94,7 @@ fun MessageEventBubble(
} }
TimelineItemGroupPosition.None -> TimelineItemGroupPosition.None ->
RoundedCornerShape( RoundedCornerShape(
BUBBLE_RADIUS, topLeftCorner,
BUBBLE_RADIUS, BUBBLE_RADIUS,
BUBBLE_RADIUS, BUBBLE_RADIUS,
BUBBLE_RADIUS BUBBLE_RADIUS
@ -106,11 +116,30 @@ fun MessageEventBubble(
else -> ElementTheme.colors.messageFromOtherBackground else -> ElementTheme.colors.messageFromOtherBackground
} }
val bubbleShape = bubbleShape() val bubbleShape = bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth(BUBBLE_WIDTH_RATIO) .fillMaxWidth(BUBBLE_WIDTH_RATIO)
.padding(horizontal = 16.dp) .padding(start = avatarRadius, end = 16.dp)
.offsetForItem(), .offsetForItem()
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
if (state.cutTopStart) {
drawCircle(
color = Color.Black,
center = Offset(
x = 0f,
y = yOffsetPx,
),
radius = radiusPx,
blendMode = BlendMode.Clear,
)
}
},
// Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case // Need to set the contentAlignment again (it's already set in TimelineItemEventRow), for the case
// when content width is low. // when content width is low.
contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart

View file

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -29,45 +30,60 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.isEdited import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineEventTimestampView( fun TimelineEventTimestampView(
formattedTime: String, event: TimelineItem.Event,
isMessageEdited: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val formattedTime = event.sentTime
val hasUnrecoverableError = event.localSendState is LocalEventSendState.SendingFailed.Unrecoverable
val isMessageEdited = event.content.isEdited()
val tint = if (hasUnrecoverableError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.secondary
Row( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.then(modifier), .then(modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isMessageEdited) { if (isMessageEdited) {
Text( Text(
stringResource(CommonStrings.common_edited_suffix), stringResource(CommonStrings.common_edited_suffix),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary, color = tint,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
Text( Text(
formattedTime, formattedTime,
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = MaterialTheme.colorScheme.secondary, color = tint,
) )
if (hasUnrecoverableError) {
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = CompoundIcons.Error(),
contentDescription = stringResource(id = CommonStrings.common_sending_failed),
tint = tint,
modifier = Modifier.size(15.dp, 18.dp),
)
}
} }
} }
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview { internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
TimelineEventTimestampView(formattedTime = event.sentTime, isMessageEdited = event.content.isEdited()) TimelineEventTimestampView(event = event)
} }
object TimelineEventTimestampViewDefaults { object TimelineEventTimestampViewDefaults {

View file

@ -26,13 +26,15 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
override val values: Sequence<TimelineItem.Event> override val values: Sequence<TimelineItem.Event>
get() = sequenceOf( get() = sequenceOf(
aTimelineItemEvent(), aTimelineItemEvent(),
// Sending failed // Sending failed recoverable
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")), aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Recoverable("AN_ERROR")),
// Sending failed unrecoverable
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR")),
// Edited // Edited
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)), aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test) // Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
aTimelineItemEvent().copy( aTimelineItemEvent().copy(
localSendState = LocalEventSendState.SendingFailed("AN_ERROR"), localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
content = aTimelineItemTextContent().copy(isEdited = true), content = aTimelineItemTextContent().copy(isEdited = true),
), ),
) )

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.timeline.components package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
@ -32,11 +31,9 @@ import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -45,8 +42,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -55,7 +50,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -84,7 +78,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
import io.element.android.features.messages.impl.timeline.model.event.isEdited
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -110,6 +103,13 @@ import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
// The bubble has a negative margin to be placed a bit upper regarding the sender
// information and overlap the avatar.
val NEGATIVE_MARGIN_FOR_BUBBLE = (-8).dp
// Width of the transparent border around the sender avatar
val SENDER_AVATAR_BORDER_WIDTH = 3.dp
@Composable @Composable
fun TimelineItemEventRow( fun TimelineItemEventRow(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -281,13 +281,11 @@ private fun TimelineItemEventRowContent(
) = createRefs() ) = createRefs()
// Sender // Sender
val avatarStrokeSize = 3.dp
if (event.showSenderInformation && !timelineRoomInfo.isDm) { if (event.showSenderInformation && !timelineRoomInfo.isDm) {
MessageSenderInformation( MessageSenderInformation(
event.senderId, event.senderId,
event.senderProfile, event.senderProfile,
event.senderAvatar, event.senderAvatar,
avatarStrokeSize,
Modifier Modifier
.constrainAs(sender) { .constrainAs(sender) {
top.linkTo(parent.top) top.linkTo(parent.top)
@ -313,7 +311,7 @@ private fun TimelineItemEventRowContent(
MessageEventBubble( MessageEventBubble(
modifier = Modifier modifier = Modifier
.constrainAs(message) { .constrainAs(message) {
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp) top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
this.linkStartOrEnd(event) this.linkStartOrEnd(event)
}, },
state = bubbleState, state = bubbleState,
@ -365,37 +363,17 @@ private fun MessageSenderInformation(
senderId: UserId, senderId: UserId,
senderProfile: ProfileTimelineDetails, senderProfile: ProfileTimelineDetails,
senderAvatar: AvatarData, senderAvatar: AvatarData,
avatarStrokeSize: Dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val avatarStrokeColor = MaterialTheme.colorScheme.background
val avatarSize = senderAvatar.size.dp
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme) val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme)
Box( Row(modifier = modifier) {
modifier = modifier Avatar(senderAvatar)
) { Spacer(modifier = Modifier.width(4.dp))
// Background of Avatar, to erase the corner of the message content SenderName(
Canvas( senderId = senderId,
modifier = Modifier senderProfile = senderProfile,
.size(size = avatarSize + avatarStrokeSize) senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
.clipToBounds() )
) {
drawCircle(
color = avatarStrokeColor,
center = Offset(x = (avatarSize / 2).toPx(), y = (avatarSize / 2).toPx()),
radius = (avatarSize / 2 + avatarStrokeSize).toPx()
)
}
// Content
Row {
Avatar(senderAvatar)
Spacer(modifier = Modifier.width(4.dp))
SenderName(
senderId = senderId,
senderProfile = senderProfile,
senderNameMode = SenderNameMode.Timeline(avatarColors.foreground),
)
}
} }
} }
@ -451,8 +429,7 @@ private fun MessageEventBubbleContent(
Box(modifier, contentAlignment = Alignment.Center) { Box(modifier, contentAlignment = Alignment.Center) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
formattedTime = event.sentTime, event = event,
isMessageEdited = event.content.isEdited(),
modifier = Modifier modifier = Modifier
// Outer padding // Outer padding
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = 4.dp)
@ -472,8 +449,7 @@ private fun MessageEventBubbleContent(
content = { content(this::onContentLayoutChange) }, content = { content(this::onContentLayoutChange) },
overlay = { overlay = {
TimelineEventTimestampView( TimelineEventTimestampView(
formattedTime = event.sentTime, event = event,
isMessageEdited = event.content.isEdited(),
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) )
@ -483,8 +459,7 @@ private fun MessageEventBubbleContent(
Column(modifier) { Column(modifier) {
content {} content {}
TimelineEventTimestampView( TimelineEventTimestampView(
formattedTime = event.sentTime, event = event,
isMessageEdited = event.content.isEdited(),
modifier = Modifier modifier = Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)

View file

@ -17,11 +17,15 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString import android.text.SpannableString
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@ -33,7 +37,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
@Composable @Composable
@ -47,10 +56,8 @@ fun TimelineItemTextView(
LocalContentColor provides ElementTheme.colors.textPrimary, LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) { ) {
val formattedBody = content.formattedBody val body = getTextWithResolvedMentions(content)
val body = SpannableString(formattedBody ?: content.body) Box(modifier.semantics { contentDescription = content.plainText }) {
Box(modifier.semantics { contentDescription = body.toString() }) {
EditorStyledText( EditorStyledText(
text = body, text = body,
onLinkClickedListener = onLinkClick, onLinkClickedListener = onLinkClick,
@ -62,6 +69,39 @@ fun TimelineItemTextView(
} }
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
@Composable
internal fun getTextWithResolvedMentions(content: TimelineItemTextBasedContent): CharSequence {
val userProfileCache = LocalRoomMemberProfilesCache.current
val lastCacheUpdate by userProfileCache.lastCacheUpdate.collectAsState()
val formattedBody = remember(content.htmlBody, lastCacheUpdate) {
updateMentionSpans(content.formattedBody, userProfileCache)
SpannableString(content.formattedBody ?: content.body)
}
return formattedBody
}
private fun updateMentionSpans(text: CharSequence?, cache: RoomMemberProfilesCache): Boolean {
var changedContents = false
if (text != null) {
for (mentionSpan in text.getMentionSpans()) {
when (mentionSpan.type) {
MentionSpan.Type.USER -> {
val displayName = cache.getDisplayName(UserId(mentionSpan.rawValue)) ?: mentionSpan.rawValue
if (mentionSpan.text != displayName) {
changedContents = true
mentionSpan.text = displayName
}
}
// Nothing yet for room mentions
else -> Unit
}
}
}
return changedContents
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineItemTextViewPreview( internal fun TimelineItemTextViewPreview(

View file

@ -81,8 +81,8 @@ fun TimelineItemReadReceiptView(
} }
} else { } else {
when (state.sendState) { when (state.sendState) {
is LocalEventSendState.SendingFailed, LocalEventSendState.NotSentYet,
is LocalEventSendState.NotSentYet -> { is LocalEventSendState.SendingFailed.Recoverable -> {
ReadReceiptsRow(modifier) { ReadReceiptsRow(modifier) {
Icon( Icon(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
@ -92,6 +92,9 @@ fun TimelineItemReadReceiptView(
) )
} }
} }
is LocalEventSendState.SendingFailed.Unrecoverable -> {
// Error? The timestamp is already displayed in red
}
null, null,
is LocalEventSendState.Sent -> { is LocalEventSendState.Sent -> {
if (state.isLastOutgoingMessage) { if (state.isLastOutgoingMessage) {

View file

@ -24,4 +24,7 @@ data class BubbleState(
val isMine: Boolean, val isMine: Boolean,
val isHighlighted: Boolean, val isHighlighted: Boolean,
val timelineRoomInfo: TimelineRoomInfo, val timelineRoomInfo: TimelineRoomInfo,
) ) {
/** True to cut out the top start corner of the bubble, to give margin for the sender avatar. */
val cutTopStart: Boolean = groupPosition.isNew() && !isMine && !timelineRoomInfo.isDm
}

View file

@ -70,13 +70,11 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID 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_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -780,10 +778,9 @@ class MessagesPresenterTest {
messageComposerContext = DefaultMessageComposerContext(), messageComposerContext = DefaultMessageComposerContext(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = permissionsPresenterFactory, permissionsPresenterFactory = permissionsPresenterFactory,
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), permalinkParser = FakePermalinkParser(),
permalinkBuilder = FakePermalinkBuilder(), permalinkBuilder = FakePermalinkBuilder(),
timelineController = TimelineController(matrixRoom), timelineController = TimelineController(matrixRoom),
permalinkParser = permalinkParser,
draftService = FakeComposerDraftService(), draftService = FakeComposerDraftService(),
).apply { ).apply {
showTextFormatting = true showTextFormatting = true

View file

@ -470,7 +470,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
) { ) {
setContent { setContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(
LocalInspectionMode provides true
) {
MessagesView( MessagesView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -615,7 +615,6 @@ class ActionListPresenterTest {
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.Copy, TimelineItemAction.Copy,
TimelineItemAction.CopyLink,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) )
) )

View file

@ -53,19 +53,16 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -1290,7 +1287,6 @@ class MessageComposerPresenterTest {
analyticsService, analyticsService,
DefaultMessageComposerContext(), DefaultMessageComposerContext(),
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,

View file

@ -21,7 +21,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -33,9 +32,7 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide without calling Update first should throw an exception`() { fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider( val provider = DefaultHtmlConverterProvider()
permalinkParser = FakePermalinkParser(),
)
val exception = runCatching { provider.provide() }.exceptionOrNull() val exception = runCatching { provider.provide() }.exceptionOrNull()
@ -44,9 +41,7 @@ class DefaultHtmlConverterProviderTest {
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider( val provider = DefaultHtmlConverterProvider()
permalinkParser = FakePermalinkParser(),
)
composeTestRule.setContent { composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID) provider.Update(currentUserId = A_USER_ID)

View file

@ -0,0 +1,175 @@
/*
* 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.features.messages.impl.timeline.components.event
import android.text.SpannableString
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
import io.element.android.libraries.textcomposer.mentions.MentionSpan
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TimelineTextViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
}
@Test
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest {
val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>")
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty()
}
@Test
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val result = rule.getText(aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
assertThat(result.getMentionSpans()).isEmpty()
assertThat(result.toString()).isEqualTo(charSequence)
}
@Test
fun `getTextWithResolvedMentions - with Room mention does nothing`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_ROOM_ID_2.value, type = MentionSpan.Type.ROOM)) {
append(A_ROOM_ID.value)
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEmpty()
assertThat(result).isEqualTo(charSequence)
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID.value)) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(CustomMentionSpan(aMentionSpan(rawValue = A_USER_ID.value))) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo("alice")
}
@Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text with user id if no display name is cached`() = runTest {
val charSequence = buildSpannedString {
append("Hello ")
inSpans(aMentionSpan(rawValue = A_USER_ID_2.value)) {
append("@NotAlice")
}
}
val result = rule.getText(aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.text).isEqualTo(A_USER_ID_2.value)
}
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText(
content: TimelineItemTextBasedContent,
): CharSequence {
val completable = CompletableDeferred<CharSequence>()
setContent {
val roomMemberProfilesCache = RoomMemberProfilesCache().apply {
replace(listOf(aRoomMember(userId = A_USER_ID, displayName = A_USER_NAME)))
}
CompositionLocalProvider(
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
) {
completable.complete(getTextWithResolvedMentions(content = content))
}
}
return completable.await()
}
private fun aMentionSpan(
rawValue: String,
text: String = "",
type: MentionSpan.Type = MentionSpan.Type.USER
) = MentionSpan(
text = text,
rawValue = rawValue,
type = type,
backgroundColor = 0,
textColor = 0,
startPadding = 0,
endPadding = 0,
)
private fun aTextContentWithFormattedBody(formattedBody: CharSequence?, body: String = "") =
TimelineItemTextContent(
body = body,
htmlDocument = null,
formattedBody = formattedBody,
isEdited = false
)
}

View file

@ -25,9 +25,8 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
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.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@ -37,7 +36,9 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toPersistentList
/** /**
* A view that allows a user to edit the default notification setting for rooms. This can be set separately * A view that allows a user to edit the default notification setting for rooms. This can be set separately
@ -96,12 +97,6 @@ fun EditDefaultNotificationSettingView(
RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
null -> "" null -> ""
} }
val avatarData = AvatarData(
id = summary.identifier(),
name = summary.details.name,
url = summary.details.avatarUrl,
size = AvatarSize.CustomRoomNotificationSetting,
)
ListItem( ListItem(
headlineContent = { headlineContent = {
val roomName = summary.details.name val roomName = summary.details.name
@ -114,7 +109,12 @@ fun EditDefaultNotificationSettingView(
Text(text = subtitle) Text(text = subtitle)
}, },
leadingContent = ListItemContent.Custom { leadingContent = ListItemContent.Custom {
Avatar(avatarData = avatarData) CompositeAvatar(
avatarData = summary.details.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting),
heroes = summary.details.heroes.map { user ->
user.getAvatarData(size = AvatarSize.CustomRoomNotificationSetting)
}.toPersistentList()
)
}, },
onClick = { onClick = {
openRoomNotificationSettings(summary.details.roomId) openRoomNotificationSettings(summary.details.roomId)

View file

@ -109,7 +109,7 @@ fun EditUserProfileView(
matrixId = state.userId.value, matrixId = state.userId.value,
displayName = state.displayName, displayName = state.displayName,
avatarUrl = state.userAvatarUrl, avatarUrl = state.userAvatarUrl,
avatarSize = AvatarSize.RoomHeader, avatarSize = AvatarSize.EditProfileDetails,
onAvatarClick = { onAvatarClick() }, onAvatarClick = { onAvatarClick() },
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) )

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string>
<string name="full_screen_intent_banner_title">"Палепшыце якасць званкоў"</string>
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Выберыце спосаб атрымання апавяшчэнняў"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Выберыце спосаб атрымання апавяшчэнняў"</string>
<string name="screen_advanced_settings_developer_mode">"Рэжым распрацоўшчыка"</string> <string name="screen_advanced_settings_developer_mode">"Рэжым распрацоўшчыка"</string>
<string name="screen_advanced_settings_developer_mode_description">"Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям."</string> <string name="screen_advanced_settings_developer_mode_description">"Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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="screen_advanced_settings_choose_distributor_dialog_title_android">"Vyberte, jak chcete přijímat oznámení"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Vyberte, jak chcete přijímat oznámení"</string>
<string name="screen_advanced_settings_developer_mode">"Vývojářský režim"</string> <string name="screen_advanced_settings_developer_mode">"Vývojářský režim"</string>
<string name="screen_advanced_settings_developer_mode_description">"Povolením získáte přístup k funkcím a funkcím pro vývojáře."</string> <string name="screen_advanced_settings_developer_mode_description">"Povolením získáte přístup k funkcím a funkcím pro vývojáře."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="full_screen_intent_banner_message">"Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."</string>
<string name="full_screen_intent_banner_title">"Améliorez votre expérience dappel"</string>
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Choisissez le mode de réception des notifications"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Choisissez le mode de réception des notifications"</string>
<string name="screen_advanced_settings_developer_mode">"Mode développeur"</string> <string name="screen_advanced_settings_developer_mode">"Mode développeur"</string>
<string name="screen_advanced_settings_developer_mode_description">"Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs."</string> <string name="screen_advanced_settings_developer_mode_description">"Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs."</string>

View file

@ -1,11 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="full_screen_intent_banner_message">"Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat."</string>
<string name="full_screen_intent_banner_title">"Îmbunătățiți-vă experiența in timpul unui apel"</string>
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Alegeți modul de primire a notificărilor"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Alegeți modul de primire a notificărilor"</string>
<string name="screen_advanced_settings_developer_mode">"Modul dezvoltator"</string> <string name="screen_advanced_settings_developer_mode">"Modul dezvoltator"</string>
<string name="screen_advanced_settings_developer_mode_description">"Activați pentru a avea acces la funcționalități pentru dezvoltatori."</string> <string name="screen_advanced_settings_developer_mode_description">"Activați pentru a avea acces la funcționalități pentru dezvoltatori."</string>
<string name="screen_advanced_settings_element_call_base_url">"Adresa URL de bază Element Call"</string> <string name="screen_advanced_settings_element_call_base_url">"Adresa URL de bază Element Call"</string>
<string name="screen_advanced_settings_element_call_base_url_description">"Setați o adresă URL de bază personalizată pentru Element Call."</string> <string name="screen_advanced_settings_element_call_base_url_description">"Setați o adresă URL de bază personalizată pentru Element Call."</string>
<string name="screen_advanced_settings_element_call_base_url_validation_error">"URL invalid, vă rugăm să vă asigurați că includeți protocolul (http/https) și adresa corectă."</string> <string name="screen_advanced_settings_element_call_base_url_validation_error">"URL invalid, vă rugăm să vă asigurați că includeți protocolul (http/https) și adresa corectă."</string>
<string name="screen_advanced_settings_push_provider_android">"Furnizor de notificări push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Dezactivați editorul avansat pentru a tasta manual Markdown."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Dezactivați editorul avansat pentru a tasta manual Markdown."</string>
<string name="screen_advanced_settings_send_read_receipts">"Chitanțe de citire"</string> <string name="screen_advanced_settings_send_read_receipts">"Chitanțe de citire"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Dacă dezactivată, chitanțele dumneavoastră de citire nu vor fi trimise nimănui. Veți primi în continuare chitanțe de citire de la alți utilizatori."</string> <string name="screen_advanced_settings_send_read_receipts_description">"Dacă dezactivată, chitanțele dumneavoastră de citire nu vor fi trimise nimănui. Veți primi în continuare chitanțe de citire de la alți utilizatori."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="full_screen_intent_banner_message">"Чтобы никогда не пропустить важный звонок, измените настройки, чтобы разрешить полноэкранные уведомления, когда ваш телефон заблокирован."</string>
<string name="full_screen_intent_banner_title">"Улучшите качество звонков"</string>
<string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Выберите способ получения уведомлений"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Выберите способ получения уведомлений"</string>
<string name="screen_advanced_settings_developer_mode">"Режим разработчика"</string> <string name="screen_advanced_settings_developer_mode">"Режим разработчика"</string>
<string name="screen_advanced_settings_developer_mode_description">"Предоставьте разработчикам доступ к функциям и функциональным возможностям."</string> <string name="screen_advanced_settings_developer_mode_description">"Предоставьте разработчикам доступ к функциям и функциональным возможностям."</string>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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="screen_advanced_settings_choose_distributor_dialog_title_android">"Vyberte spôsob prijímania oznámení"</string> <string name="screen_advanced_settings_choose_distributor_dialog_title_android">"Vyberte spôsob prijímania oznámení"</string>
<string name="screen_advanced_settings_developer_mode">"Vývojársky režim"</string> <string name="screen_advanced_settings_developer_mode">"Vývojársky režim"</string>
<string name="screen_advanced_settings_developer_mode_description">"Umožniť prístup k možnostiam a funkciám pre vývojárov."</string> <string name="screen_advanced_settings_developer_mode_description">"Umožniť prístup k možnostiam a funkciám pre vývojárov."</string>

View file

@ -50,4 +50,6 @@ Om du fortsätter kan vissa av dina inställningar ändras."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"systeminställningar"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"systeminställningar"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Systemaviseringar avstängda"</string> <string name="screen_notification_settings_system_notifications_turned_off">"Systemaviseringar avstängda"</string>
<string name="screen_notification_settings_title">"Aviseringar"</string> <string name="screen_notification_settings_title">"Aviseringar"</string>
<string name="troubleshoot_notifications_entry_point_section">"Felsök"</string>
<string name="troubleshoot_notifications_entry_point_title">"Felsök aviseringar"</string>
</resources> </resources>

View file

@ -282,11 +282,13 @@ class DefaultBugReporter @Inject constructor(
listener.onUploadFailed(serverError) listener.onUploadFailed(serverError)
} }
} finally { } finally {
// delete the generated files when the bug report process has finished withContext(coroutineDispatchers.io) {
for (file in bugReportFiles) { // delete the generated files when the bug report process has finished
file.safeDelete() for (file in bugReportFiles) {
file.safeDelete()
}
response?.close()
} }
response?.close()
} }
} }

View file

@ -45,10 +45,12 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.canCall import io.element.android.libraries.matrix.ui.room.canCall
import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -97,8 +99,9 @@ class RoomDetailsPresenter @Inject constructor(
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp) val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
val dmMember by room.getDirectRoomMember(membersState) val dmMember by room.getDirectRoomMember(membersState)
val currentMember by room.getCurrentRoomMember(membersState)
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
val roomType by getRoomType(dmMember) val roomType by getRoomType(dmMember, currentMember)
val topicState = remember(canEditTopic, roomTopic, roomType) { val topicState = remember(canEditTopic, roomTopic, roomType) {
val topic = roomTopic val topic = roomTopic
@ -151,6 +154,7 @@ class RoomDetailsPresenter @Inject constructor(
isFavorite = isFavorite, isFavorite = isFavorite,
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin, displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
isPublic = isPublic, isPublic = isPublic,
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
@ -163,10 +167,16 @@ class RoomDetailsPresenter @Inject constructor(
} }
@Composable @Composable
private fun getRoomType(dmMember: RoomMember?): State<RoomDetailsType> = remember(dmMember) { private fun getRoomType(
dmMember: RoomMember?,
currentMember: RoomMember?,
): State<RoomDetailsType> = remember(dmMember, currentMember) {
derivedStateOf { derivedStateOf {
if (dmMember != null) { if (dmMember != null && currentMember != null) {
RoomDetailsType.Dm(dmMember) RoomDetailsType.Dm(
me = currentMember,
otherMember = dmMember,
)
} else { } else {
RoomDetailsType.Room RoomDetailsType.Room
} }

View file

@ -23,6 +23,8 @@ 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.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class RoomDetailsState( data class RoomDetailsState(
val roomId: RoomId, val roomId: RoomId,
@ -43,13 +45,17 @@ data class RoomDetailsState(
val isFavorite: Boolean, val isFavorite: Boolean,
val displayRolesAndPermissionsSettings: Boolean, val displayRolesAndPermissionsSettings: Boolean,
val isPublic: Boolean, val isPublic: Boolean,
val heroes: ImmutableList<MatrixUser>,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )
@Immutable @Immutable
sealed interface RoomDetailsType { sealed interface RoomDetailsType {
data object Room : RoomDetailsType data object Room : RoomDetailsType
data class Dm(val roomMember: RoomMember) : RoomDetailsType data class Dm(
val me: RoomMember,
val otherMember: RoomMember,
) : RoomDetailsType
} }
@Immutable @Immutable

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.features.userprofile.shared.UserProfileState
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
@ -28,6 +29,9 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toPersistentList
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> { open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState> override val values: Sequence<RoomDetailsState>
@ -48,6 +52,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
), ),
aRoomDetailsState(canCall = false, canInvite = false), aRoomDetailsState(canCall = false, canInvite = false),
aRoomDetailsState(isPublic = false), aRoomDetailsState(isPublic = false),
aRoomDetailsState(heroes = aMatrixUserList()),
// Add other state here // Add other state here
) )
} }
@ -99,6 +104,7 @@ fun aRoomDetailsState(
isFavorite: Boolean = false, isFavorite: Boolean = false,
displayAdminSettings: Boolean = false, displayAdminSettings: Boolean = false,
isPublic: Boolean = true, isPublic: Boolean = true,
heroes: List<MatrixUser> = emptyList(),
eventSink: (RoomDetailsEvent) -> Unit = {}, eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState( ) = RoomDetailsState(
roomId = roomId, roomId = roomId,
@ -119,6 +125,7 @@ fun aRoomDetailsState(
isFavorite = isFavorite, isFavorite = isFavorite,
displayRolesAndPermissionsSettings = displayAdminSettings, displayRolesAndPermissionsSettings = displayAdminSettings,
isPublic = isPublic, isPublic = isPublic,
heroes = heroes.toPersistentList(),
eventSink = eventSink eventSink = eventSink
) )
@ -135,6 +142,10 @@ fun aDmRoomDetailsState(
roomName: String = "Daniel", roomName: String = "Daniel",
) = aRoomDetailsState( ) = aRoomDetailsState(
roomName = roomName, roomName = roomName,
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)), isPublic = false,
roomType = RoomDetailsType.Dm(
aRoomMember(),
aDmRoomMember(isIgnored = isDmMemberIgnored),
),
roomMemberDetailsState = aUserProfileState() roomMemberDetailsState = aUserProfileState()
) )

View file

@ -20,13 +20,13 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -49,14 +49,14 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomdetails.impl.components.RoomBadge import io.element.android.features.roomdetails.impl.components.RoomBadge
import io.element.android.features.userprofile.shared.UserProfileHeaderSection
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.avatar.DmAvatars
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.MainActionButton import io.element.android.libraries.designsystem.components.button.MainActionButton
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@ -79,11 +79,16 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.RoomAlias 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.RoomId
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.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
@Composable @Composable
fun RoomDetailsView( fun RoomDetailsView(
@ -125,38 +130,35 @@ fun RoomDetailsView(
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName, roomName = state.roomName,
roomAlias = state.roomAlias, roomAlias = state.roomAlias,
isEncrypted = state.isEncrypted, heroes = state.heroes,
isPublic = state.isPublic,
openAvatarPreview = { avatarUrl -> openAvatarPreview = { avatarUrl ->
openAvatarPreview(state.roomName, avatarUrl) openAvatarPreview(state.roomName, avatarUrl)
}, },
) )
MainActionsSection(
state = state,
onShareRoom = onShareRoom,
onInvitePeople = invitePeople,
onCall = onJoinCallClick,
)
} }
is RoomDetailsType.Dm -> { is RoomDetailsType.Dm -> {
val member = state.roomType.roomMember DmHeaderSection(
UserProfileHeaderSection( me = state.roomType.me,
avatarUrl = state.roomAvatarUrl ?: member.avatarUrl, otherMember = state.roomType.otherMember,
userId = member.userId, roomName = state.roomName,
userName = state.roomName, openAvatarPreview = { name, avatarUrl ->
openAvatarPreview = { avatarUrl -> openAvatarPreview(name, avatarUrl)
openAvatarPreview(member.getBestName(), avatarUrl)
}, },
) )
MainActionsSection(
state = state,
onShareRoom = onShareRoom,
onInvitePeople = invitePeople,
onCall = onJoinCallClick,
)
} }
} }
BadgeList(
isEncrypted = state.isEncrypted,
isPublic = state.isPublic,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.height(32.dp))
MainActionsSection(
state = state,
onShareRoom = onShareRoom,
onInvitePeople = invitePeople,
onCall = onJoinCallClick,
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (state.roomTopic !is RoomTopicState.Hidden) { if (state.roomTopic !is RoomTopicState.Hidden) {
@ -322,8 +324,7 @@ private fun RoomHeaderSection(
roomId: RoomId, roomId: RoomId,
roomName: String, roomName: String,
roomAlias: RoomAlias?, roomAlias: RoomAlias?,
isEncrypted: Boolean, heroes: ImmutableList<MatrixUser>,
isPublic: Boolean,
openAvatarPreview: (url: String) -> Unit, openAvatarPreview: (url: String) -> Unit,
) { ) {
Column( Column(
@ -332,30 +333,65 @@ private fun RoomHeaderSection(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar( CompositeAvatar(
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader), avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
heroes = heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomHeader)
}.toPersistentList(),
modifier = Modifier modifier = Modifier
.size(70.dp)
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.testTag(TestTags.roomDetailAvatar) .testTag(TestTags.roomDetailAvatar)
) )
Spacer(modifier = Modifier.height(24.dp)) TitleAndSubtitle(title = roomName, subtitle = roomAlias?.value)
}
}
@Composable
private fun DmHeaderSection(
me: RoomMember,
otherMember: RoomMember,
roomName: String,
openAvatarPreview: (name: String, url: String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DmAvatars(
userAvatarData = me.getAvatarData(size = AvatarSize.DmCluster),
otherUserAvatarData = otherMember.getAvatarData(size = AvatarSize.DmCluster),
openAvatarPreview = { url -> openAvatarPreview(me.getBestName(), url) },
openOtherAvatarPreview = { url -> openAvatarPreview(roomName, url) },
)
TitleAndSubtitle(
title = roomName,
subtitle = otherMember.userId.value,
)
}
}
@Composable
private fun ColumnScope.TitleAndSubtitle(
title: String,
subtitle: String?,
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text( Text(
text = roomName, text = subtitle,
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
if (roomAlias != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = roomAlias.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
}
BadgeList(isEncrypted = isEncrypted, isPublic = isPublic)
Spacer(Modifier.height(32.dp))
} }
} }
@ -363,11 +399,12 @@ private fun RoomHeaderSection(
private fun BadgeList( private fun BadgeList(
isEncrypted: Boolean, isEncrypted: Boolean,
isPublic: Boolean, isPublic: Boolean,
modifier: Modifier = Modifier,
) { ) {
if (isEncrypted || isPublic) { if (isEncrypted || isPublic) {
Spacer(modifier = Modifier.height(8.dp))
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp), modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (isEncrypted) { if (isEncrypted) {

View file

@ -67,7 +67,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -276,11 +276,7 @@ private fun RoomMemberListItem(
} }
MatrixUserRow( MatrixUserRow(
modifier = modifier.clickable(onClick = onClick), modifier = modifier.clickable(onClick = onClick),
matrixUser = MatrixUser( matrixUser = roomMember.toMatrixUser(),
userId = roomMember.userId,
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
),
avatarSize = AvatarSize.UserListItem, avatarSize = AvatarSize.UserListItem,
trailingContent = roleText?.let { trailingContent = roleText?.let {
@Composable { @Composable {

View file

@ -45,7 +45,6 @@ import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
@ -59,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -217,12 +217,7 @@ private fun RoomMemberActionsBottomSheet(
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) { ) {
Avatar( Avatar(
avatarData = AvatarData( avatarData = roomMember.getAvatarData(size = AvatarSize.RoomListManageUser),
id = roomMember.userId.value,
name = roomMember.displayName,
url = roomMember.avatarUrl,
size = AvatarSize.RoomListManageUser,
),
modifier = Modifier modifier = Modifier
.padding(bottom = 28.dp) .padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally) .align(Alignment.CenterHorizontally)

View file

@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember 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 io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -50,21 +50,21 @@ class RolesAndPermissionsPresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState(initial = null) val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val roomMembers by room.membersStateFlow.collectAsState() val roomMembers by room.membersStateFlow.collectAsState()
// Get the list of joined room members, in order to filter members present in the power // Get the list of active room members (joined or invited), in order to filter members present in the power
// level state Event, but not member of the room anymore. // level state Event.
val joinedRoomMemberIds by remember { val activeRoomMemberIds by remember {
derivedStateOf { derivedStateOf {
roomMembers.joinedRoomMembers().map { it.userId } roomMembers.activeRoomMembers().map { it.userId }
} }
} }
val moderatorCount by remember { val moderatorCount by remember {
derivedStateOf { derivedStateOf {
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.MODERATOR) roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.MODERATOR)
} }
} }
val adminCount by remember { val adminCount by remember {
derivedStateOf { derivedStateOf {
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.ADMIN) roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.ADMIN)
} }
} }
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
@ -118,9 +118,9 @@ class RolesAndPermissionsPresenter @Inject constructor(
} }
} }
private fun MatrixRoomInfo?.userCountWithRole(joinedRoomMemberIds: List<UserId>, role: RoomMember.Role): Int { private fun MatrixRoomInfo?.userCountWithRole(userIds: List<UserId>, role: RoomMember.Role): Int {
return this?.userPowerLevels.orEmpty().count { (userId, level) -> return this?.userPowerLevels.orEmpty().count { (userId, level) ->
RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds RoomMember.Role.forPowerLevel(level) == role && userId in userIds
} }
} }
} }

View file

@ -81,6 +81,7 @@ import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -327,7 +328,7 @@ private fun ListMemberItem(
} }
MemberRow( MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }), modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }),
avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem), avatarData = roomMember.getAvatarData(size = AvatarSize.UserListItem),
name = roomMember.getBestName(), name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true }, userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE, isPending = roomMember.membership == RoomMembershipState.INVITE,

View file

@ -23,10 +23,13 @@
<string name="screen_room_change_role_confirm_demote_self_action">"Degradera"</string> <string name="screen_room_change_role_confirm_demote_self_action">"Degradera"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier."</string> <string name="screen_room_change_role_confirm_demote_self_description">"Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Degradera dig själv?"</string> <string name="screen_room_change_role_confirm_demote_self_title">"Degradera dig själv?"</string>
<string name="screen_room_change_role_invited_member_name">"%1$s (Väntar)"</string>
<string name="screen_room_change_role_moderators_title">"Redigera moderatorer"</string> <string name="screen_room_change_role_moderators_title">"Redigera moderatorer"</string>
<string name="screen_room_change_role_section_administrators">"Administratörer"</string> <string name="screen_room_change_role_section_administrators">"Administratörer"</string>
<string name="screen_room_change_role_section_moderators">"Moderatorer"</string> <string name="screen_room_change_role_section_moderators">"Moderatorer"</string>
<string name="screen_room_change_role_section_users">"Medlemmar"</string> <string name="screen_room_change_role_section_users">"Medlemmar"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Du har osparade ändringar."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Spara ändringar?"</string>
<string name="screen_room_details_add_topic_title">"Lägg till ämne"</string> <string name="screen_room_details_add_topic_title">"Lägg till ämne"</string>
<string name="screen_room_details_already_a_member">"Redan medlem"</string> <string name="screen_room_details_already_a_member">"Redan medlem"</string>
<string name="screen_room_details_already_invited">"Redan inbjuden"</string> <string name="screen_room_details_already_invited">"Redan inbjuden"</string>
@ -53,11 +56,13 @@
<string name="screen_room_member_list_ban_member_confirmation_action">"Banna"</string> <string name="screen_room_member_list_ban_member_confirmation_action">"Banna"</string>
<string name="screen_room_member_list_ban_member_confirmation_description">"Denne kommer inte att kunna gå med i det här rummet igen om denne bjuds in."</string> <string name="screen_room_member_list_ban_member_confirmation_description">"Denne kommer inte att kunna gå med i det här rummet igen om denne bjuds in."</string>
<string name="screen_room_member_list_ban_member_confirmation_title">"Är du säker på att du vill banna den här medlemmen?"</string> <string name="screen_room_member_list_ban_member_confirmation_title">"Är du säker på att du vill banna den här medlemmen?"</string>
<string name="screen_room_member_list_banned_empty">"Det finns inga bannade användare i det här rummet."</string>
<string name="screen_room_member_list_banning_user">"Bannar %1$s"</string> <string name="screen_room_member_list_banning_user">"Bannar %1$s"</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item> <item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d personer"</item> <item quantity="other">"%1$d personer"</item>
</plurals> </plurals>
<string name="screen_room_member_list_manage_member_ban">"Ta bort och banna medlem"</string>
<string name="screen_room_member_list_manage_member_remove">"Ta bort från rummet"</string> <string name="screen_room_member_list_manage_member_remove">"Ta bort från rummet"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ta bort och banna medlem"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string> <string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Ta bara bort medlem"</string>
@ -89,11 +94,16 @@
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Endast omnämnanden och nyckelord"</string> <string name="screen_room_notification_settings_mode_mentions_and_keywords">"Endast omnämnanden och nyckelord"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"I det här rummet, meddela mig för"</string> <string name="screen_room_notification_settings_room_custom_settings_title">"I det här rummet, meddela mig för"</string>
<string name="screen_room_roles_and_permissions_admins">"Administratörer"</string> <string name="screen_room_roles_and_permissions_admins">"Administratörer"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Ändra min roll"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Degradera till medlem"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Degradera till moderator"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Medlemsmoderering"</string> <string name="screen_room_roles_and_permissions_member_moderation">"Medlemsmoderering"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Meddelanden och innehåll"</string> <string name="screen_room_roles_and_permissions_messages_and_content">"Meddelanden och innehåll"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderatorer"</string> <string name="screen_room_roles_and_permissions_moderators">"Moderatorer"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Behörigheter"</string> <string name="screen_room_roles_and_permissions_permissions_header">"Behörigheter"</string>
<string name="screen_room_roles_and_permissions_reset">"Återställ behörigheter"</string> <string name="screen_room_roles_and_permissions_reset">"Återställ behörigheter"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"När du har återställt behörigheterna kommer du att förlora de aktuella inställningarna."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Återställ behörigheter?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roller"</string> <string name="screen_room_roles_and_permissions_roles_header">"Roller"</string>
<string name="screen_room_roles_and_permissions_room_details">"Rumsdetaljer"</string> <string name="screen_room_roles_and_permissions_room_details">"Rumsdetaljer"</string>
<string name="screen_room_roles_and_permissions_title">"Roller och behörigheter"</string> <string name="screen_room_roles_and_permissions_title">"Roller och behörigheter"</string>

View file

@ -169,8 +169,12 @@ class RoomDetailsPresenterTest {
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember)) assertThat(initialState.roomType).isEqualTo(
RoomDetailsType.Dm(
me = myRoomMember,
otherMember = otherRoomMember,
)
)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -126,6 +127,7 @@ class RoomDetailsViewTest {
} }
} }
@Config(qualifiers = "h1024dp")
@Test @Test
fun `click on add topic emit expected event`() { fun `click on add topic emit expected event`() {
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.AddTopic) { callback -> ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.AddTopic) { callback ->
@ -176,7 +178,11 @@ class RoomDetailsViewTest {
fun `click on avatar test on DM`() { fun `click on avatar test on DM`() {
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
val state = aRoomDetailsState( val state = aRoomDetailsState(
roomType = RoomDetailsType.Dm(aDmRoomMember(avatarUrl = "an_avatar_url")), roomType = RoomDetailsType.Dm(
aRoomMember(),
aDmRoomMember(avatarUrl = "an_avatar_url"),
),
roomName = "Daniel",
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url") val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url")

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_directory_search_loading_error">"Misslyckades att ladda"</string>
<string name="screen_room_directory_search_title">"Rumskatalog"</string>
</resources>

View file

@ -53,7 +53,7 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvid
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.CompositeAvatar
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -158,7 +158,10 @@ private fun RoomSummaryScaffoldRow(
.padding(horizontal = 16.dp, vertical = 11.dp) .padding(horizontal = 16.dp, vertical = 11.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
) { ) {
Avatar(room.avatarData) CompositeAvatar(
avatarData = room.avatarData,
heroes = room.heroes,
)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View file

@ -26,7 +26,11 @@ import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
class RoomListRoomSummaryFactory @Inject constructor( class RoomListRoomSummaryFactory @Inject constructor(
@ -54,43 +58,45 @@ class RoomListRoomSummaryFactory @Inject constructor(
inviteSender = null, inviteSender = null,
isDm = false, isDm = false,
canonicalAlias = null, canonicalAlias = null,
heroes = persistentListOf(),
) )
} }
} }
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary { fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {
val roomIdentifier = roomSummary.identifier() return create(roomSummary.details)
val avatarData = AvatarData( }
id = roomIdentifier,
name = roomSummary.details.name, private fun create(details: RoomSummaryDetails): RoomListRoomSummary {
url = roomSummary.details.avatarUrl, val avatarData = details.getAvatarData(size = AvatarSize.RoomListItem)
size = AvatarSize.RoomListItem,
)
return RoomListRoomSummary( return RoomListRoomSummary(
id = roomIdentifier, id = details.roomId.value,
roomId = RoomId(roomIdentifier), roomId = details.roomId,
name = roomSummary.details.name, name = details.name,
numberOfUnreadMessages = roomSummary.details.numUnreadMessages, numberOfUnreadMessages = details.numUnreadMessages,
numberOfUnreadMentions = roomSummary.details.numUnreadMentions, numberOfUnreadMentions = details.numUnreadMentions,
numberOfUnreadNotifications = roomSummary.details.numUnreadNotifications, numberOfUnreadNotifications = details.numUnreadNotifications,
isMarkedUnread = roomSummary.details.isMarkedUnread, isMarkedUnread = details.isMarkedUnread,
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp), timestamp = lastMessageTimestampFormatter.format(details.lastMessageTimestamp),
lastMessage = roomSummary.details.lastMessage?.let { message -> lastMessage = details.lastMessage?.let { message ->
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) roomLastMessageFormatter.format(message.event, details.isDirect)
}.orEmpty(), }.orEmpty(),
avatarData = avatarData, avatarData = avatarData,
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode, userDefinedNotificationMode = details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall, hasRoomCall = details.hasRoomCall,
isDirect = roomSummary.details.isDirect, isDirect = details.isDirect,
isFavorite = roomSummary.details.isFavorite, isFavorite = details.isFavorite,
inviteSender = roomSummary.details.inviter?.toInviteSender(), inviteSender = details.inviter?.toInviteSender(),
isDm = roomSummary.details.isDm, isDm = details.isDm,
canonicalAlias = roomSummary.details.canonicalAlias, canonicalAlias = details.canonicalAlias,
displayType = if (roomSummary.details.currentUserMembership == CurrentUserMembership.INVITED) { displayType = if (details.currentUserMembership == CurrentUserMembership.INVITED) {
RoomSummaryDisplayType.INVITE RoomSummaryDisplayType.INVITE
} else { } else {
RoomSummaryDisplayType.ROOM RoomSummaryDisplayType.ROOM
} },
heroes = details.heroes.map { user ->
user.getAvatarData(size = AvatarSize.RoomListItem)
}.toImmutableList(),
) )
} }
} }

View file

@ -22,6 +22,7 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -34,8 +35,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -66,7 +71,31 @@ fun RoomListFiltersView(
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter)) state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
} }
var scrollToStart by remember { mutableIntStateOf(0) }
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
LaunchedEffect(scrollToStart) {
// Scroll until the first item start to be displayed
// Since all items have different size, there is no way to compute the amount of
// pixel to scroll to go directly to the start of the row.
// But IRL it should only happen for one item.
while (lazyListState.firstVisibleItemIndex > 0) {
lazyListState.animateScrollBy(
value = -(lazyListState.firstVisibleItemScrollOffset + 1f),
animationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
)
)
}
// Then scroll to the start of the list, a bit faster, to fully reveal the first
// item, which can be the close button to reset filter, or the first item
// if the user has scroll a bit before clicking on the close button.
lazyListState.animateScrollBy(
value = -lazyListState.firstVisibleItemScrollOffset.toFloat(),
animationSpec = spring(
stiffness = Spring.StiffnessMedium,
)
)
}
val previousFilters = remember { mutableStateOf(listOf<RoomListFilter>()) } val previousFilters = remember { mutableStateOf(listOf<RoomListFilter>()) }
LazyRow( LazyRow(
contentPadding = PaddingValues(start = 8.dp, end = 16.dp), contentPadding = PaddingValues(start = 8.dp, end = 16.dp),
@ -84,6 +113,9 @@ fun RoomListFiltersView(
onClick = { onClick = {
previousFilters.value = state.selectedFilters() previousFilters.value = state.selectedFilters()
onClearFiltersClick() onClearFiltersClick()
// When clearing filter, we want to ensure that the list
// of filters is scrolled to the start.
scrollToStart++
} }
) )
} }
@ -100,6 +132,10 @@ fun RoomListFiltersView(
onClick = { onClick = {
previousFilters.value = state.selectedFilters() previousFilters.value = state.selectedFilters()
onToggleFilter(it) onToggleFilter(it)
// When selecting a filter, we want to scroll to the start of the list
if (filterWithSelection.isSelected.not()) {
scrollToStart++
}
}, },
) )
} }

View file

@ -22,6 +22,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.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class RoomListRoomSummary( data class RoomListRoomSummary(
@ -43,6 +44,7 @@ data class RoomListRoomSummary(
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val inviteSender: InviteSender?, val inviteSender: InviteSender?,
val heroes: ImmutableList<AvatarData>,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||

View file

@ -24,6 +24,7 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.ui.model.InviteSender import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toImmutableList
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> { open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary> override val values: Sequence<RoomListRoomSummary>
@ -142,6 +143,7 @@ internal fun aRoomListRoomSummary(
inviteSender: InviteSender? = null, inviteSender: InviteSender? = null,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
canonicalAlias: RoomAlias? = null, canonicalAlias: RoomAlias? = null,
heroes: List<AvatarData> = emptyList(),
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@ -161,4 +163,5 @@ internal fun aRoomListRoomSummary(
inviteSender = inviteSender, inviteSender = inviteSender,
displayType = displayType, displayType = displayType,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
heroes = heroes.toImmutableList(),
) )

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."</string> <string name="confirm_recovery_key_banner_message">"Ваша рэзервовая копія чата зараз не сінхранізавана. Вам трэба пацвердзіць ключ аднаўлення, каб захаваць доступ да рэзервовай копіі чата."</string>
<string name="confirm_recovery_key_banner_title">"Увядзіце ключ аднаўлення"</string> <string name="confirm_recovery_key_banner_title">"Увядзіце ключ аднаўлення"</string>
<string name="full_screen_intent_banner_message">"Каб не прапусціць важны званок, зменіце налады, каб дазволіць поўнаэкранныя апавяшчэнні, калі тэлефон заблакіраваны."</string>
<string name="full_screen_intent_banner_title">"Палепшыце якасць званкоў"</string>
<string name="screen_invites_decline_chat_message">"Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"</string> <string name="screen_invites_decline_chat_message">"Вы ўпэўненыя, што хочаце адхіліць запрашэнне ў %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Адхіліць запрашэнне"</string> <string name="screen_invites_decline_chat_title">"Адхіліць запрашэнне"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Vaše záloha chatu není aktuálně synchronizována. Abyste si zachovali přístup k záloze chatu, musíte potvrdit klíč pro obnovení."</string> <string name="confirm_recovery_key_banner_message">"Vaše záloha chatu není aktuálně synchronizována. Abyste si zachovali přístup k záloze chatu, musíte potvrdit klíč pro obnovení."</string>
<string name="confirm_recovery_key_banner_title">"Potvrďte klíč pro obnovení"</string> <string name="confirm_recovery_key_banner_title">"Potvrďte klíč pro obnovení"</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="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string> <string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string> <string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string>
<string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Opravdu chcete odmítnout tuto soukromou konverzaci s %1$s?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"La sauvegarde des conversations est désynchronisée. Vous devez confirmer la clé de récupération pour accéder à votre historique."</string> <string name="confirm_recovery_key_banner_message">"La sauvegarde des conversations est désynchronisée. Vous devez confirmer la clé de récupération pour accéder à votre historique."</string>
<string name="confirm_recovery_key_banner_title">"Confirmer votre clé de récupération"</string> <string name="confirm_recovery_key_banner_title">"Confirmer votre clé de récupération"</string>
<string name="full_screen_intent_banner_message">"Afin de ne jamais manquer un appel important, veuillez modifier vos paramètres pour autoriser les notifications en plein écran lorsque votre appareil est verrouillé."</string>
<string name="full_screen_intent_banner_title">"Améliorez votre expérience dappel"</string>
<string name="screen_invites_decline_chat_message">"Êtes-vous sûr de vouloir décliner linvitation à rejoindre %1$s ?"</string> <string name="screen_invites_decline_chat_message">"Êtes-vous sûr de vouloir décliner linvitation à rejoindre %1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Refuser linvitation"</string> <string name="screen_invites_decline_chat_title">"Refuser linvitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"</string> <string name="screen_invites_decline_direct_chat_message">"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string> <string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat în prezent. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
<string name="confirm_recovery_key_banner_title">"Confirmați cheia de recuperare"</string> <string name="confirm_recovery_key_banner_title">"Confirmați cheia de recuperare"</string>
<string name="full_screen_intent_banner_message">"Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat."</string>
<string name="full_screen_intent_banner_title">"Îmbunătățiți-vă experiența in timpul unui apel"</string>
<string name="screen_invites_decline_chat_message">"Sigur doriți să refuzați alăturarea la %1$s?"</string> <string name="screen_invites_decline_chat_message">"Sigur doriți să refuzați alăturarea la %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Refuzați invitația"</string> <string name="screen_invites_decline_chat_title">"Refuzați invitația"</string>
<string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Sigur doriți să refuzați conversațiile cu %1$s?"</string>

View file

@ -5,6 +5,8 @@
"Введите " "Введите "
<b>"ключ восстановления"</b> <b>"ключ восстановления"</b>
</string> </string>
<string name="full_screen_intent_banner_message">"Чтобы никогда не пропустить важный звонок, измените настройки, чтобы разрешить полноэкранные уведомления, когда ваш телефон заблокирован."</string>
<string name="full_screen_intent_banner_title">"Улучшите качество звонков"</string>
<string name="screen_invites_decline_chat_message">"Вы уверены, что хотите отклонить приглашение в %1$s?"</string> <string name="screen_invites_decline_chat_message">"Вы уверены, что хотите отклонить приглашение в %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Отклонить приглашение"</string> <string name="screen_invites_decline_chat_title">"Отклонить приглашение"</string>
<string name="screen_invites_decline_direct_chat_message">"Вы уверены, что хотите отказаться от личного общения с %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Вы уверены, что хотите отказаться от личного общения с %1$s?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Vaša záloha konverzácie nie je momentálne synchronizovaná. Na zachovanie prístupu k zálohe konverzácie musíte potvrdiť svoj kľúč na obnovu."</string> <string name="confirm_recovery_key_banner_message">"Vaša záloha konverzácie nie je momentálne synchronizovaná. Na zachovanie prístupu k zálohe konverzácie musíte potvrdiť svoj kľúč na obnovu."</string>
<string name="confirm_recovery_key_banner_title">"Potvrďte svoj kľúč na obnovenie"</string> <string name="confirm_recovery_key_banner_title">"Potvrďte svoj kľúč na obnovenie"</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="screen_invites_decline_chat_message">"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"</string> <string name="screen_invites_decline_chat_message">"Naozaj chcete odmietnuť pozvánku na pripojenie do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string> <string name="screen_invites_decline_chat_title">"Odmietnuť pozvanie"</string>
<string name="screen_invites_decline_direct_chat_message">"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"</string> <string name="screen_invites_decline_direct_chat_message">"Naozaj chcete odmietnuť túto súkromnú konverzáciu s %1$s?"</string>

View file

@ -14,8 +14,11 @@
<string name="screen_roomlist_empty_message">"Kom igång genom att skicka meddelanden till någon."</string> <string name="screen_roomlist_empty_message">"Kom igång genom att skicka meddelanden till någon."</string>
<string name="screen_roomlist_empty_title">"Inga chattar än."</string> <string name="screen_roomlist_empty_title">"Inga chattar än."</string>
<string name="screen_roomlist_filter_favourites">"Favoriter"</string> <string name="screen_roomlist_filter_favourites">"Favoriter"</string>
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"Du kan lägga till en chatt till dina favoriter i chattinställningarna.
För tillfället kan du avmarkera filter för att se dina andra chattar"</string>
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du har inga favoritchattar än"</string> <string name="screen_roomlist_filter_favourites_empty_state_title">"Du har inga favoritchattar än"</string>
<string name="screen_roomlist_filter_low_priority">"Låg prioritet"</string> <string name="screen_roomlist_filter_low_priority">"Låg prioritet"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Du kan avmarkera filter för att se dina andra chattar"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du har inga chattar för det här valet"</string> <string name="screen_roomlist_filter_mixed_empty_state_title">"Du har inga chattar för det här valet"</string>
<string name="screen_roomlist_filter_people">"Personer"</string> <string name="screen_roomlist_filter_people">"Personer"</string>
<string name="screen_roomlist_filter_people_empty_state_title">"Du har inga DM:er än"</string> <string name="screen_roomlist_filter_people_empty_state_title">"Du har inga DM:er än"</string>
@ -27,6 +30,7 @@ Du har inga olästa meddelanden!"</string>
<string name="screen_roomlist_main_space_title">"Alla chattar"</string> <string name="screen_roomlist_main_space_title">"Alla chattar"</string>
<string name="screen_roomlist_mark_as_read">"Markera som läst"</string> <string name="screen_roomlist_mark_as_read">"Markera som läst"</string>
<string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string> <string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string>
<string name="screen_roomlist_room_directory_button_title">"Bläddra bland alla rum"</string>
<string name="session_verification_banner_message">"Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden."</string> <string name="session_verification_banner_message">"Det verkar som om du använder en ny enhet. Verifiera med en annan enhet för att komma åt dina krypterade meddelanden."</string>
<string name="session_verification_banner_title">"Verifiera att det är du"</string> <string name="session_verification_banner_title">"Verifiera att det är du"</string>
</resources> </resources>

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import kotlinx.collections.immutable.toPersistentList
import org.junit.Test import org.junit.Test
class RoomListRoomSummaryTest { class RoomListRoomSummaryTest {
@ -91,6 +92,7 @@ internal fun createRoomListRoomSummary(
userDefinedNotificationMode: RoomNotificationMode? = null, userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false, isFavorite: Boolean = false,
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM, displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
heroes: List<AvatarData> = emptyList(),
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -110,4 +112,5 @@ internal fun createRoomListRoomSummary(
canonicalAlias = null, canonicalAlias = null,
inviteSender = null, inviteSender = null,
isDm = false, isDm = false,
heroes = heroes.toPersistentList(),
) )

View file

@ -17,14 +17,11 @@
package io.element.android.features.userprofile.shared package io.element.android.features.userprofile.shared
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -36,6 +33,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@ -55,15 +54,12 @@ fun UserProfileHeaderSection(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box(modifier = Modifier.size(70.dp)) { Avatar(
Avatar( avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader), modifier = Modifier
modifier = Modifier
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) } .clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
.fillMaxSize()
.testTag(TestTags.memberDetailAvatar) .testTag(TestTags.memberDetailAvatar)
) )
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
if (userName != null) { if (userName != null) {
Text( Text(
@ -86,3 +82,14 @@ fun UserProfileHeaderSection(
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
} }
} }
@PreviewsDayNight
@Composable
internal fun UserProfileHeaderSectionPreview() = ElementPreview {
UserProfileHeaderSection(
avatarUrl = null,
userId = UserId("@alice:example.com"),
userName = "Alice",
openAvatarPreview = {},
)
}

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_subtitle">"Verifiera den här enheten för att konfigurera säkra meddelanden."</string>
<string name="screen_identity_confirmation_title">"Bekräfta att det är du"</string>
<string name="screen_identity_confirmed_subtitle">"Nu kan du läsa eller skicka meddelanden säkert, och alla du chattar med kan också lita på den här enheten."</string>
<string name="screen_identity_confirmed_title">"Enhet verifierad"</string>
<string name="screen_identity_use_another_device">"Använd en annan enhet"</string>
<string name="screen_identity_waiting_on_other_device">"Väntar på annan enhet …"</string>
<string name="screen_session_verification_cancelled_subtitle">"Något verkar inte stämma. Antingen gick tidsgränsen för begäran ut eller så avvisades begäran."</string> <string name="screen_session_verification_cancelled_subtitle">"Något verkar inte stämma. Antingen gick tidsgränsen för begäran ut eller så avvisades begäran."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Bekräfta att emojierna nedan matchar de som visas på din andra session."</string> <string name="screen_session_verification_compare_emojis_subtitle">"Bekräfta att emojierna nedan matchar de som visas på din andra session."</string>
<string name="screen_session_verification_compare_emojis_title">"Jämför emojis"</string> <string name="screen_session_verification_compare_emojis_title">"Jämför emojis"</string>

View file

@ -34,7 +34,7 @@ coroutines = "1.8.1"
accompanist = "0.34.0" accompanist = "0.34.0"
# Test # Test
test_core = "1.5.0" test_core = "1.6.0"
#other #other
coil = "2.6.0" coil = "2.6.0"
@ -44,7 +44,7 @@ serialization_json = "1.6.3"
showkase = "1.0.3" showkase = "1.0.3"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.2" sqldelight = "2.0.2"
wysiwyg = "2.37.3" wysiwyg = "2.37.4"
telephoto = "0.11.2" telephoto = "0.11.2"
# DI # DI
@ -55,8 +55,7 @@ anvil = "2.4.9"
autoservice = "1.1.1" autoservice = "1.1.1"
# quality # quality
androidx-test-ext-junit = "1.1.5" androidx-test-ext-junit = "1.2.0"
espresso-core = "3.5.1"
kover = "0.8.0" kover = "0.8.0"
[libraries] [libraries]
@ -68,7 +67,7 @@ kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", v
kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
gms_google_services = "com.google.gms:google-services:4.4.2" gms_google_services = "com.google.gms:google-services:4.4.2"
# https://firebase.google.com/docs/android/setup#available-libraries # https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:33.1.0" google_firebase_bom = "com.google.firebase:firebase-bom:33.1.1"
firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" }
autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" }
@ -138,7 +137,7 @@ test_core = { module = "androidx.test:core", version.ref = "test_core" }
test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" }
test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_arch_core = "androidx.arch.core:core-testing:2.2.0"
test_junit = "junit:junit:4.13.2" test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.5.2" test_runner = "androidx.test:runner:1.6.0"
test_mockk = "io.mockk:mockk:1.13.11" test_mockk = "io.mockk:mockk:1.13.11"
test_konsist = "com.lemonappdev:konsist:0.15.1" test_konsist = "com.lemonappdev:konsist:0.15.1"
test_turbine = "app.cash.turbine:turbine:1.1.0" test_turbine = "app.cash.turbine:turbine:1.1.0"
@ -175,7 +174,7 @@ otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.1" statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.0.1" maplibre = "org.maplibre.gl:android-sdk:11.0.1"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0"
@ -184,7 +183,7 @@ kotlinpoet = "com.squareup:kotlinpoet:1.17.0"
zxing_cpp = "io.github.zxing-cpp:android:2.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.2.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.3.2" posthog = "com.posthog:posthog-android:3.4.0"
sentry = "io.sentry:sentry-android:7.10.0" sentry = "io.sentry:sentry-android:7.10.0"
# main branch can be tested replacing the version with main-SNAPSHOT # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1"
@ -209,8 +208,6 @@ google_autoservice_annotations = { module = "com.google.auto.service:auto-servic
# See https://github.com/renovatebot/renovate/issues/18354 # See https://github.com/renovatebot/renovate/issues/18354
android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
appcompat = "androidx.appcompat:appcompat:1.7.0"
[bundles] [bundles]

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
import io.element.android.libraries.dateformatter.test.FakeClock import io.element.android.libraries.dateformatter.test.FakeClock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.junit.Test import org.junit.Test
import java.util.Locale import java.util.Locale
@ -44,7 +45,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z" val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z" val dat = "1980-04-06T18:35:24.00Z"
val formatter = createFormatter(now) 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 @Test
@ -52,7 +53,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z" val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z" val dat = "1980-04-06T18:35:23.00Z"
val formatter = createFormatter(now) 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 @Test
@ -60,7 +61,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z" val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z" val dat = "1980-04-06T18:34:24.00Z"
val formatter = createFormatter(now) 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 @Test
@ -68,7 +69,7 @@ class DefaultLastMessageTimestampFormatterTest {
val now = "1980-04-06T18:35:24.00Z" val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z" val dat = "1980-04-06T17:35:24.00Z"
val formatter = createFormatter(now) 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 @Test
@ -96,6 +97,15 @@ class DefaultLastMessageTimestampFormatterTest {
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979") 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. * Create DefaultLastMessageFormatter and set current time to the provided date.
*/ */

View file

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

View file

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

View file

@ -0,0 +1,137 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun CompositeAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
if (avatarData.url != null || heroes.isEmpty()) {
Avatar(avatarData, modifier, contentDescription)
} else {
val limitedHeroes = heroes.take(4)
val numberOfHeroes = limitedHeroes.size
if (numberOfHeroes == 4) {
// Swap 2 and 3 so that the 4th hero is at the bottom right
Collections.swap(limitedHeroes, 2, 3)
}
when (numberOfHeroes) {
0 -> {
error("Unsupported number of heroes: 0")
}
1 -> {
Avatar(heroes[0], modifier, contentDescription)
}
else -> {
val angle = 2 * Math.PI / numberOfHeroes
val offsetRadius = when (numberOfHeroes) {
2 -> avatarData.size.dp.value / 4.2
3 -> avatarData.size.dp.value / 4.0
4 -> avatarData.size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val heroAvatarSize = when (numberOfHeroes) {
2 -> avatarData.size.dp / 2.2f
3 -> avatarData.size.dp / 2.4f
4 -> avatarData.size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val angleOffset = when (numberOfHeroes) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
Box(
modifier = modifier
.size(avatarData.size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedHeroes.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
heroAvatar,
forcedAvatarSize = heroAvatarSize,
)
}
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun CompositeAvatarPreview() = ElementThemedPreview {
val mainAvatar = anAvatarData(
id = "Zac",
name = "Zac",
size = AvatarSize.RoomListItem,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(6) { nbOfHeroes ->
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(nbOfHeroes) { aHeroAvatarData(it) }.toPersistentList(),
)
}
}
}
private fun aHeroAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)

View file

@ -0,0 +1,117 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */
private const val SIZE_RATIO = 1.6f
/**
* https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333
*/
@Composable
fun DmAvatars(
userAvatarData: AvatarData,
otherUserAvatarData: AvatarData,
openAvatarPreview: (url: String) -> Unit,
openOtherAvatarPreview: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val boxSize = userAvatarData.size.dp * SIZE_RATIO
val boxSizePx = boxSize.toPx()
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
Box(
modifier = modifier.size(boxSize),
) {
// Draw user avatar and cut top right corner
Avatar(
avatarData = userAvatarData,
modifier = Modifier
.align(Alignment.BottomStart)
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = boxSizePx - otherAvatarRadius,
y = size.height - (boxSizePx - otherAvatarRadius),
),
radius = otherAvatarRadius / 0.9f,
blendMode = BlendMode.Clear,
)
}
.clip(CircleShape)
.clickable(enabled = userAvatarData.url != null) {
userAvatarData.url?.let { openAvatarPreview(it) }
}
)
// Draw other user avatar
Avatar(
avatarData = otherUserAvatarData,
modifier = Modifier
.align(Alignment.TopEnd)
.clip(CircleShape)
.clickable(enabled = otherUserAvatarData.url != null) {
otherUserAvatarData.url?.let { openOtherAvatarPreview(it) }
}
.testTag(TestTags.memberDetailAvatar)
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun DmAvatarsPreview() = ElementThemedPreview {
val size = AvatarSize.DmCluster
DmAvatars(
userAvatarData = anAvatarData(
id = "Alice",
name = "Alice",
size = size,
),
otherUserAvatarData = anAvatarData(
id = "Bob",
name = "Bob",
size = size,
),
openAvatarPreview = {},
openOtherAvatarPreview = {},
)
}

View file

@ -3,12 +3,16 @@
<string name="state_event_avatar_changed_too">"(avatar ändrades också)"</string> <string name="state_event_avatar_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">"%1$s bytte sin avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Du bytte din 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">"%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_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">"%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_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">"%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_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">"%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_changed_by_you">"Du bytte rummets avatar"</string>
<string name="state_event_room_avatar_removed">"%1$s tog bort rummets avatar"</string> <string name="state_event_room_avatar_removed">"%1$s tog bort rummets avatar"</string>

View file

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

View file

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

View file

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

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode 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.room.message.RoomMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface RoomSummary { sealed interface RoomSummary {
data class Empty(val identifier: String) : RoomSummary data class Empty(val identifier: String) : RoomSummary
@ -52,6 +53,7 @@ data class RoomSummaryDetails(
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val currentUserMembership: CurrentUserMembership, val currentUserMembership: CurrentUserMembership,
val heroes: List<MatrixUser>,
) { ) {
val lastMessageTimestamp = lastMessage?.originServerTs val lastMessageTimestamp = lastMessage?.originServerTs
} }

View file

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

View file

@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentMap import kotlinx.collections.immutable.toPersistentMap
import org.matrix.rustcomponents.sdk.RoomHero
import org.matrix.rustcomponents.sdk.Membership as RustMembership import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
@ -55,7 +57,8 @@ class MatrixRoomInfoMapper {
notificationCount = it.notificationCount.toLong(), notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(), userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall, 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 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> { fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap() return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
} }

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.RoomInfo
/**
* Extract the heroes from the room info.
* For now we only use heroes for direct rooms with 2 members.
* Also we keep the heroes only if there is one single hero.
*/
fun RoomInfo.elementHeroes(): List<MatrixUser> {
return heroes
.takeIf { isDirect && activeMembersCount.toLong() == 2L }
?.takeIf { it.size == 1 }
?.map { it.map() }
.orEmpty()
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails 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.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.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory 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, isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
isFavorite = roomInfo.isFavourite, isFavorite = roomInfo.isFavourite,
currentUserMembership = roomInfo.membership.map(), currentUserMembership = roomInfo.membership.map(),
heroes = roomInfo.elementHeroes(),
) )
} }
} }

View file

@ -77,7 +77,13 @@ fun RustEventSendState?.map(): LocalEventSendState? {
return when (this) { return when (this) {
null -> null null -> null
RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet 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)) is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
} }
} }

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more