Merge branch 'develop' into feature/fga/draft_support
This commit is contained in:
commit
1b56d1b97a
485 changed files with 2939 additions and 1591 deletions
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@12.3.2
|
||||
uses: danger/danger-js@12.3.3
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
|
|
|
|||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -245,7 +245,7 @@ jobs:
|
|||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@12.3.2
|
||||
uses: danger/danger-js@12.3.3
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -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.core.RoomId
|
||||
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.onEach
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@VisibleForTesting
|
||||
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 1500L
|
||||
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 500L
|
||||
|
||||
@SingleIn(SessionScope::class)
|
||||
class SendQueues @Inject constructor(
|
||||
|
|
@ -45,14 +45,12 @@ class SendQueues @Inject constructor(
|
|||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
matrixClient.sendQueueDisabledFlow()
|
||||
.onEach { roomId: RoomId ->
|
||||
Timber.d("Send queue disabled for room $roomId")
|
||||
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||
.onEach { _: RoomId ->
|
||||
if (networkMonitor.connectivity.value == NetworkStatus.Online) {
|
||||
delay(SEND_QUEUES_RETRY_DELAY_MILLIS)
|
||||
matrixClient.getRoom(roomId)?.use { room ->
|
||||
room.setSendQueueEnabled(enabled = true)
|
||||
}
|
||||
matrixClient.setAllSendQueuesEnabled(enabled = true)
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.appnav.di.MatrixClientsHolder
|
|||
import io.element.android.features.login.api.LoginUserStory
|
||||
import io.element.android.features.preferences.api.CacheService
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
|
@ -39,6 +40,7 @@ class RootNavStateFlowFactory @Inject constructor(
|
|||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val cacheService: CacheService,
|
||||
private val matrixClientsHolder: MatrixClientsHolder,
|
||||
private val imageLoaderHolder: ImageLoaderHolder,
|
||||
private val loginUserStory: LoginUserStory,
|
||||
) {
|
||||
private var currentCacheIndex = 0
|
||||
|
|
@ -69,6 +71,8 @@ class RootNavStateFlowFactory @Inject constructor(
|
|||
return cacheService.clearedCacheEventFlow
|
||||
.onEach { sessionId ->
|
||||
matrixClientsHolder.remove(sessionId)
|
||||
// Ensure image loader will be recreated with the new MatrixClient
|
||||
imageLoaderHolder.remove(sessionId)
|
||||
}
|
||||
.toIndexFlow(initialCacheIndex)
|
||||
.onEach { cacheIndex ->
|
||||
|
|
|
|||
|
|
@ -55,12 +55,13 @@ import org.junit.Test
|
|||
runCurrent()
|
||||
|
||||
assert(setAllSendQueuesEnabledLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(true))
|
||||
.isCalledExactly(2)
|
||||
.withSequence(
|
||||
listOf(value(true)),
|
||||
listOf(value(true)),
|
||||
)
|
||||
|
||||
assert(setRoomSendQueueEnabledLambda)
|
||||
.isCalledOnce()
|
||||
.with(value(true))
|
||||
assert(setRoomSendQueueEnabledLambda).isNeverCalled()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
1
changelog.d/2916.misc
Normal file
1
changelog.d/2916.misc
Normal 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
1
changelog.d/3051.misc
Normal 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
1
changelog.d/3081.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Let roles and permissions screens work for invited room members too.
|
||||
1
changelog.d/3082.bugfix
Normal file
1
changelog.d/3082.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Fix image rendering after clear cache
|
||||
1
changelog.d/3083.bugfix
Normal file
1
changelog.d/3083.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Improve room filters behavior
|
||||
1
changelog.d/3085.bugfix
Normal file
1
changelog.d/3085.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
|||
Make sure we replace the 'answer call' pending intent on ringing call notifications.
|
||||
1
changelog.d/3086.bugfix
Normal file
1
changelog.d/3086.bugfix
Normal 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.
|
||||
|
|
@ -35,7 +35,7 @@ internal object IntentProvider {
|
|||
context,
|
||||
DefaultElementCallEntryPoint.REQUEST_CODE,
|
||||
createIntent(context, callType),
|
||||
0,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||
false
|
||||
)!!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@
|
|||
<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_title_android">"☎️ Apel în curs"</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"Primiți un apel Element Call"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ package io.element.android.features.messages.impl
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
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.media.MediaSource
|
||||
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.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.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.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
|
|
@ -82,6 +92,9 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||
mentionSpanProviderFactory: MentionSpanProvider.Factory,
|
||||
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Messages,
|
||||
|
|
@ -137,6 +150,18 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
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 {
|
||||
return when (navTarget) {
|
||||
is NavTarget.Messages -> {
|
||||
|
|
@ -345,6 +370,13 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackWithOverlayBox(modifier)
|
||||
mentionSpanProvider.updateStyles()
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalRoomMemberProfilesCache provides roomMemberProfilesCache,
|
||||
LocalMentionSpanProvider provides mentionSpanProvider,
|
||||
) {
|
||||
BackstackWithOverlayBox(modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.MessageEventType
|
||||
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.canRedactOtherAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canRedactOwnAsState
|
||||
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -138,6 +140,9 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
val roomAvatar: AsyncData<AvatarData> by remember {
|
||||
derivedStateOf { roomInfo?.avatarData()?.let { AsyncData.Success(it) } ?: AsyncData.Uninitialized }
|
||||
}
|
||||
val heroes by remember {
|
||||
derivedStateOf { roomInfo?.heroes().orEmpty().toPersistentList() }
|
||||
}
|
||||
|
||||
var hasDismissedInviteDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
|
|
@ -204,6 +209,7 @@ class MessagesPresenter @AssistedInject constructor(
|
|||
roomId = room.roomId,
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
|
||||
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(
|
||||
action: TimelineItemAction,
|
||||
targetEvent: TimelineItem.Event,
|
||||
|
|
|
|||
|
|
@ -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.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class MessagesState(
|
||||
val roomId: RoomId,
|
||||
val roomName: AsyncData<String>,
|
||||
val roomAvatar: AsyncData<AvatarData>,
|
||||
val heroes: ImmutableList<AvatarData>,
|
||||
val userHasPermissionToSendMessage: Boolean,
|
||||
val userHasPermissionToRedactOwn: Boolean,
|
||||
val userHasPermissionToRedactOther: Boolean,
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ fun aMessagesState(
|
|||
userHasPermissionToSendReaction: Boolean = true,
|
||||
composerState: MessageComposerState = aMessageComposerState(
|
||||
textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)),
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
isFullScreen = false,
|
||||
mode = MessageComposerMode.Normal,
|
||||
),
|
||||
voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(),
|
||||
timelineState: TimelineState = aTimelineState(
|
||||
|
|
@ -121,6 +121,7 @@ fun aMessagesState(
|
|||
roomId = RoomId("!id:domain"),
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = persistentListOf(),
|
||||
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
|
||||
userHasPermissionToRedactOwn = userHasPermissionToRedactOwn,
|
||||
userHasPermissionToRedactOther = userHasPermissionToRedactOther,
|
||||
|
|
|
|||
|
|
@ -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.components.ProgressDialog
|
||||
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.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.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -187,6 +187,7 @@ fun MessagesView(
|
|||
MessagesViewTopBar(
|
||||
roomName = state.roomName.dataOrNull(),
|
||||
roomAvatar = state.roomAvatar.dataOrNull(),
|
||||
heroes = state.heroes,
|
||||
callState = state.callState,
|
||||
onBackClick = {
|
||||
// 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(
|
||||
roomName: String?,
|
||||
roomAvatar: AvatarData?,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
callState: RoomCallState,
|
||||
onRoomDetailsClick: () -> Unit,
|
||||
onJoinCallClick: () -> Unit,
|
||||
|
|
@ -457,6 +459,7 @@ private fun MessagesViewTopBar(
|
|||
RoomAvatarAndNameRow(
|
||||
roomName = roomName,
|
||||
roomAvatar = roomAvatar,
|
||||
heroes = heroes,
|
||||
modifier = titleModifier
|
||||
)
|
||||
} else {
|
||||
|
|
@ -500,13 +503,17 @@ private fun CallMenuItem(
|
|||
private fun RoomAvatarAndNameRow(
|
||||
roomName: String,
|
||||
roomAvatar: AvatarData,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Avatar(roomAvatar)
|
||||
CompositeAvatar(
|
||||
avatarData = roomAvatar,
|
||||
heroes = heroes,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = roomName,
|
||||
|
|
|
|||
|
|
@ -104,7 +104,9 @@ class ActionListPresenter @Inject constructor(
|
|||
is TimelineItemStateContent -> {
|
||||
buildList {
|
||||
add(TimelineItemAction.Copy)
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.CopyLink)
|
||||
}
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -128,7 +130,9 @@ class ActionListPresenter @Inject constructor(
|
|||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.CopyLink)
|
||||
}
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -145,8 +149,8 @@ class ActionListPresenter @Inject constructor(
|
|||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.Reply)
|
||||
add(TimelineItemAction.Forward)
|
||||
add(TimelineItemAction.CopyLink)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
@ -187,7 +191,9 @@ class ActionListPresenter @Inject constructor(
|
|||
if (timelineItem.content.canBeCopied()) {
|
||||
add(TimelineItemAction.Copy)
|
||||
}
|
||||
add(TimelineItemAction.CopyLink)
|
||||
if (timelineItem.isRemote) {
|
||||
add(TimelineItemAction.CopyLink)
|
||||
}
|
||||
if (isDeveloperModeEnabled) {
|
||||
add(TimelineItemAction.ViewSource)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ class DefaultComposerDraftService @Inject constructor(
|
|||
.onSuccess { draft ->
|
||||
room.clearComposerDraft()
|
||||
Timber.d("Loaded composer draft for room $roomId : $draft")
|
||||
}.getOrNull()
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,14 +93,14 @@ private fun RoomMemberSuggestionItemView(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
val avatarSize = AvatarSize.TimelineRoom
|
||||
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(
|
||||
memberSuggestion.roomMember.userId.value,
|
||||
memberSuggestion.roomMember.displayName,
|
||||
memberSuggestion.roomMember.avatarUrl,
|
||||
avatarSize,
|
||||
id = memberSuggestion.roomMember.userId.value,
|
||||
name = memberSuggestion.roomMember.displayName,
|
||||
url = memberSuggestion.roomMember.avatarUrl,
|
||||
size = AvatarSize.Suggestion,
|
||||
)
|
||||
}
|
||||
val title = when (memberSuggestion) {
|
||||
|
|
|
|||
|
|
@ -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.draft.ComposerDraft
|
||||
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.map
|
||||
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.PermissionsPresenter
|
||||
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.rememberMentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
|
@ -111,7 +110,6 @@ class MessageComposerPresenter @Inject constructor(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val messageComposerContext: DefaultMessageComposerContext,
|
||||
private val richTextEditorStateFactory: RichTextEditorStateFactory,
|
||||
private val currentSessionIdHolder: CurrentSessionIdHolder,
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val permalinkBuilder: PermalinkBuilder,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
|
|
@ -208,7 +206,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
val memberSuggestions = remember { mutableStateListOf<ResolvedMentionSuggestion>() }
|
||||
LaunchedEffect(isMentionsEnabled) {
|
||||
if (!isMentionsEnabled) return@LaunchedEffect
|
||||
val currentUserId = currentSessionIdHolder.current
|
||||
val currentUserId = room.sessionId
|
||||
|
||||
suspend fun canSendRoomMention(): Boolean {
|
||||
val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
|
||||
|
|
@ -258,14 +256,7 @@ class MessageComposerPresenter @Inject constructor(
|
|||
loadDraft(markdownTextEditorState, richTextEditorState)
|
||||
}
|
||||
|
||||
val mentionSpanProvider = if (isTesting) {
|
||||
null
|
||||
} else {
|
||||
rememberMentionSpanProvider(
|
||||
currentUserId = room.sessionId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
}
|
||||
val mentionSpanProvider = LocalMentionSpanProvider.current
|
||||
|
||||
fun handleEvents(event: MessageComposerEvents) {
|
||||
when (event) {
|
||||
|
|
@ -379,19 +370,17 @@ class MessageComposerPresenter @Inject constructor(
|
|||
richTextEditorState.insertAtRoomMentionAtSuggestion()
|
||||
}
|
||||
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
|
||||
richTextEditorState.insertMentionAtSuggestion(text = text, link = link)
|
||||
}
|
||||
}
|
||||
} else if (markdownTextEditorState.currentMentionSuggestion != null) {
|
||||
mentionSpanProvider?.let {
|
||||
markdownTextEditorState.insertMention(
|
||||
mention = event.mention,
|
||||
mentionSpanProvider = it,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
)
|
||||
}
|
||||
markdownTextEditorState.insertMention(
|
||||
mention = event.mention,
|
||||
mentionSpanProvider = mentionSpanProvider,
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
)
|
||||
suggestionSearchTrigger.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -413,7 +402,6 @@ class MessageComposerPresenter @Inject constructor(
|
|||
canCreatePoll = canCreatePoll.value,
|
||||
attachmentsState = attachmentsState.value,
|
||||
memberSuggestions = memberSuggestions.toPersistentList(),
|
||||
currentUserId = currentSessionIdHolder.current,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.messagecomposer
|
|||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
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.textcomposer.mentions.ResolvedMentionSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
|
|
@ -38,7 +37,6 @@ data class MessageComposerState(
|
|||
val canCreatePoll: Boolean,
|
||||
val attachmentsState: AttachmentsState,
|
||||
val memberSuggestions: ImmutableList<ResolvedMentionSuggestion>,
|
||||
val currentUserId: UserId,
|
||||
val eventSink: (MessageComposerEvents) -> Unit,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.features.messages.impl.messagecomposer
|
||||
|
||||
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.PermalinkParser
|
||||
import io.element.android.libraries.textcomposer.aRichTextEditorState
|
||||
|
|
@ -57,6 +56,5 @@ fun aMessageComposerState(
|
|||
canCreatePoll = canCreatePoll,
|
||||
attachmentsState = attachmentsState,
|
||||
memberSuggestions = memberSuggestions,
|
||||
currentUserId = UserId("@alice:localhost"),
|
||||
eventSink = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ internal fun MessageComposerView(
|
|||
onReceiveSuggestion = ::onSuggestionReceived,
|
||||
onError = ::onError,
|
||||
onTyping = ::onTyping,
|
||||
currentUserId = state.currentUserId,
|
||||
onSelectRichContent = ::sendUri,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.SingleIn
|
||||
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.mentions.rememberMentionSpanProvider
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanProvider
|
||||
import io.element.android.wysiwyg.compose.StyledHtmlConverter
|
||||
import io.element.android.wysiwyg.display.MentionDisplayHandler
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
|
|
@ -40,9 +39,7 @@ import javax.inject.Inject
|
|||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultHtmlConverterProvider @Inject constructor(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
) : HtmlConverterProvider {
|
||||
class DefaultHtmlConverterProvider @Inject constructor() : HtmlConverterProvider {
|
||||
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
|
||||
|
||||
@Composable
|
||||
|
|
@ -53,10 +50,7 @@ class DefaultHtmlConverterProvider @Inject constructor(
|
|||
}
|
||||
|
||||
val editorStyle = ElementRichTextEditorStyle.textStyle()
|
||||
val mentionSpanProvider = rememberMentionSpanProvider(
|
||||
currentUserId = currentUserId,
|
||||
permalinkParser = permalinkParser,
|
||||
)
|
||||
val mentionSpanProvider = LocalMentionSpanProvider.current
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
|||
isMine = false,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||
sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = false,
|
||||
|
|
@ -104,7 +104,7 @@ internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList
|
|||
isMine = true,
|
||||
content = content,
|
||||
groupPosition = TimelineItemGroupPosition.Middle,
|
||||
sendState = LocalEventSendState.SendingFailed("Message failed to send"),
|
||||
sendState = LocalEventSendState.SendingFailed.Unrecoverable("Message failed to send"),
|
||||
),
|
||||
aTimelineItemEvent(
|
||||
isMine = true,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@ import androidx.compose.runtime.remember
|
|||
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.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.BubbleStateProvider
|
||||
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.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.Text
|
||||
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
|
||||
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.
|
||||
private const val BUBBLE_WIDTH_RATIO = 0.85f
|
||||
|
|
@ -66,11 +75,12 @@ fun MessageEventBubble(
|
|||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
fun bubbleShape(): Shape {
|
||||
val topLeftCorner = if (state.cutTopStart) 0.dp else BUBBLE_RADIUS
|
||||
return when (state.groupPosition) {
|
||||
TimelineItemGroupPosition.First -> if (state.isMine) {
|
||||
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp, BUBBLE_RADIUS)
|
||||
} else {
|
||||
RoundedCornerShape(BUBBLE_RADIUS, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
|
||||
RoundedCornerShape(topLeftCorner, BUBBLE_RADIUS, BUBBLE_RADIUS, 0.dp)
|
||||
}
|
||||
TimelineItemGroupPosition.Middle -> if (state.isMine) {
|
||||
RoundedCornerShape(BUBBLE_RADIUS, 0.dp, 0.dp, BUBBLE_RADIUS)
|
||||
|
|
@ -84,7 +94,7 @@ fun MessageEventBubble(
|
|||
}
|
||||
TimelineItemGroupPosition.None ->
|
||||
RoundedCornerShape(
|
||||
BUBBLE_RADIUS,
|
||||
topLeftCorner,
|
||||
BUBBLE_RADIUS,
|
||||
BUBBLE_RADIUS,
|
||||
BUBBLE_RADIUS
|
||||
|
|
@ -106,11 +116,30 @@ fun MessageEventBubble(
|
|||
else -> ElementTheme.colors.messageFromOtherBackground
|
||||
}
|
||||
val bubbleShape = bubbleShape()
|
||||
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
|
||||
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(BUBBLE_WIDTH_RATIO)
|
||||
.padding(horizontal = 16.dp)
|
||||
.offsetForItem(),
|
||||
.padding(start = avatarRadius, end = 16.dp)
|
||||
.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
|
||||
// when content width is low.
|
||||
contentAlignment = if (state.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.unit.dp
|
||||
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.event.isEdited
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun TimelineEventTimestampView(
|
||||
formattedTime: String,
|
||||
isMessageEdited: Boolean,
|
||||
event: TimelineItem.Event,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isMessageEdited) {
|
||||
Text(
|
||||
stringResource(CommonStrings.common_edited_suffix),
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
color = tint,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
Text(
|
||||
formattedTime,
|
||||
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
|
||||
@Composable
|
||||
internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEventForTimestampViewProvider::class) event: TimelineItem.Event) = ElementPreview {
|
||||
TimelineEventTimestampView(formattedTime = event.sentTime, isMessageEdited = event.content.isEdited())
|
||||
TimelineEventTimestampView(event = event)
|
||||
}
|
||||
|
||||
object TimelineEventTimestampViewDefaults {
|
||||
|
|
|
|||
|
|
@ -26,13 +26,15 @@ class TimelineItemEventForTimestampViewProvider : PreviewParameterProvider<Timel
|
|||
override val values: Sequence<TimelineItem.Event>
|
||||
get() = sequenceOf(
|
||||
aTimelineItemEvent(),
|
||||
// Sending failed
|
||||
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed("AN_ERROR")),
|
||||
// Sending failed recoverable
|
||||
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Recoverable("AN_ERROR")),
|
||||
// Sending failed unrecoverable
|
||||
aTimelineItemEvent().copy(localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR")),
|
||||
// Edited
|
||||
aTimelineItemEvent().copy(content = aTimelineItemTextContent().copy(isEdited = true)),
|
||||
// Sending failed + Edited (not sure this is possible IRL, but should be covered by test)
|
||||
aTimelineItemEvent().copy(
|
||||
localSendState = LocalEventSendState.SendingFailed("AN_ERROR"),
|
||||
localSendState = LocalEventSendState.SendingFailed.Unrecoverable("AN_ERROR"),
|
||||
content = aTimelineItemTextContent().copy(isEdited = true),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
package io.element.android.features.messages.impl.timeline.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -45,8 +42,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
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.ViewConfiguration
|
||||
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.semantics
|
||||
import androidx.compose.ui.semantics.testTag
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
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.aTimelineItemTextContent
|
||||
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.components.EqualWidthColumn
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
|
|
@ -110,6 +103,13 @@ import kotlinx.coroutines.launch
|
|||
import kotlin.math.abs
|
||||
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
|
||||
fun TimelineItemEventRow(
|
||||
event: TimelineItem.Event,
|
||||
|
|
@ -281,13 +281,11 @@ private fun TimelineItemEventRowContent(
|
|||
) = createRefs()
|
||||
|
||||
// Sender
|
||||
val avatarStrokeSize = 3.dp
|
||||
if (event.showSenderInformation && !timelineRoomInfo.isDm) {
|
||||
MessageSenderInformation(
|
||||
event.senderId,
|
||||
event.senderProfile,
|
||||
event.senderAvatar,
|
||||
avatarStrokeSize,
|
||||
Modifier
|
||||
.constrainAs(sender) {
|
||||
top.linkTo(parent.top)
|
||||
|
|
@ -313,7 +311,7 @@ private fun TimelineItemEventRowContent(
|
|||
MessageEventBubble(
|
||||
modifier = Modifier
|
||||
.constrainAs(message) {
|
||||
top.linkTo(sender.bottom, margin = -avatarStrokeSize - 8.dp)
|
||||
top.linkTo(sender.bottom, margin = NEGATIVE_MARGIN_FOR_BUBBLE)
|
||||
this.linkStartOrEnd(event)
|
||||
},
|
||||
state = bubbleState,
|
||||
|
|
@ -365,37 +363,17 @@ private fun MessageSenderInformation(
|
|||
senderId: UserId,
|
||||
senderProfile: ProfileTimelineDetails,
|
||||
senderAvatar: AvatarData,
|
||||
avatarStrokeSize: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val avatarStrokeColor = MaterialTheme.colorScheme.background
|
||||
val avatarSize = senderAvatar.size.dp
|
||||
val avatarColors = AvatarColorsProvider.provide(senderAvatar.id, ElementTheme.isLightTheme)
|
||||
Box(
|
||||
modifier = modifier
|
||||
) {
|
||||
// Background of Avatar, to erase the corner of the message content
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size = avatarSize + avatarStrokeSize)
|
||||
.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),
|
||||
)
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
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) {
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
formattedTime = event.sentTime,
|
||||
isMessageEdited = event.content.isEdited(),
|
||||
event = event,
|
||||
modifier = Modifier
|
||||
// Outer padding
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
|
|
@ -472,8 +449,7 @@ private fun MessageEventBubbleContent(
|
|||
content = { content(this::onContentLayoutChange) },
|
||||
overlay = {
|
||||
TimelineEventTimestampView(
|
||||
formattedTime = event.sentTime,
|
||||
isMessageEdited = event.content.isEdited(),
|
||||
event = event,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
|
|
@ -483,8 +459,7 @@ private fun MessageEventBubbleContent(
|
|||
Column(modifier) {
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
formattedTime = event.sentTime,
|
||||
isMessageEdited = event.content.isEdited(),
|
||||
event = event,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,15 @@
|
|||
package io.element.android.features.messages.impl.timeline.components.event
|
||||
|
||||
import android.text.SpannableString
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
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.semantics.contentDescription
|
||||
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.libraries.designsystem.preview.ElementPreview
|
||||
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.mentions.MentionSpan
|
||||
import io.element.android.libraries.textcomposer.mentions.getMentionSpans
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
|
||||
@Composable
|
||||
|
|
@ -47,10 +56,8 @@ fun TimelineItemTextView(
|
|||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
|
||||
) {
|
||||
val formattedBody = content.formattedBody
|
||||
val body = SpannableString(formattedBody ?: content.body)
|
||||
|
||||
Box(modifier.semantics { contentDescription = body.toString() }) {
|
||||
val body = getTextWithResolvedMentions(content)
|
||||
Box(modifier.semantics { contentDescription = content.plainText }) {
|
||||
EditorStyledText(
|
||||
text = body,
|
||||
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
|
||||
@Composable
|
||||
internal fun TimelineItemTextViewPreview(
|
||||
|
|
|
|||
|
|
@ -81,8 +81,8 @@ fun TimelineItemReadReceiptView(
|
|||
}
|
||||
} else {
|
||||
when (state.sendState) {
|
||||
is LocalEventSendState.SendingFailed,
|
||||
is LocalEventSendState.NotSentYet -> {
|
||||
LocalEventSendState.NotSentYet,
|
||||
is LocalEventSendState.SendingFailed.Recoverable -> {
|
||||
ReadReceiptsRow(modifier) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(2.dp),
|
||||
|
|
@ -92,6 +92,9 @@ fun TimelineItemReadReceiptView(
|
|||
)
|
||||
}
|
||||
}
|
||||
is LocalEventSendState.SendingFailed.Unrecoverable -> {
|
||||
// Error? The timestamp is already displayed in red
|
||||
}
|
||||
null,
|
||||
is LocalEventSendState.Sent -> {
|
||||
if (state.isLastOutgoingMessage) {
|
||||
|
|
|
|||
|
|
@ -24,4 +24,7 @@ data class BubbleState(
|
|||
val isMine: Boolean,
|
||||
val isHighlighted: Boolean,
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.MessageEventType
|
||||
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_EVENT_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_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.permalink.FakePermalinkBuilder
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
|
|
@ -780,10 +778,9 @@ class MessagesPresenterTest {
|
|||
messageComposerContext = DefaultMessageComposerContext(),
|
||||
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
|
||||
permissionsPresenterFactory = permissionsPresenterFactory,
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = FakePermalinkBuilder(),
|
||||
timelineController = TimelineController(matrixRoom),
|
||||
permalinkParser = permalinkParser,
|
||||
draftService = FakeComposerDraftService(),
|
||||
).apply {
|
||||
showTextFormatting = true
|
||||
|
|
|
|||
|
|
@ -470,7 +470,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
|
|||
) {
|
||||
setContent {
|
||||
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode
|
||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||
CompositionLocalProvider(
|
||||
LocalInspectionMode provides true
|
||||
) {
|
||||
MessagesView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
|
|
|
|||
|
|
@ -615,7 +615,6 @@ class ActionListPresenterTest {
|
|||
actions = persistentListOf(
|
||||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.Copy,
|
||||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.ComposerDraftType
|
||||
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.AN_EVENT_ID
|
||||
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_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_USER_ID
|
||||
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_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.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
|
|
@ -1290,7 +1287,6 @@ class MessageComposerPresenterTest {
|
|||
analyticsService,
|
||||
DefaultMessageComposerContext(),
|
||||
TestRichTextEditorStateFactory(),
|
||||
currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
permalinkBuilder = permalinkBuilder,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
|||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
|
@ -33,9 +32,7 @@ class DefaultHtmlConverterProviderTest {
|
|||
|
||||
@Test
|
||||
fun `calling provide without calling Update first should throw an exception`() {
|
||||
val provider = DefaultHtmlConverterProvider(
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
)
|
||||
val provider = DefaultHtmlConverterProvider()
|
||||
|
||||
val exception = runCatching { provider.provide() }.exceptionOrNull()
|
||||
|
||||
|
|
@ -44,9 +41,7 @@ class DefaultHtmlConverterProviderTest {
|
|||
|
||||
@Test
|
||||
fun `calling provide after calling Update first should return an HtmlConverter`() {
|
||||
val provider = DefaultHtmlConverterProvider(
|
||||
permalinkParser = FakePermalinkParser(),
|
||||
)
|
||||
val provider = DefaultHtmlConverterProvider()
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalInspectionMode provides true) {
|
||||
provider.Update(currentUserId = A_USER_ID)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -25,9 +25,8 @@ import androidx.compose.ui.text.font.FontStyle
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.features.preferences.impl.R
|
||||
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.CompositeAvatar
|
||||
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.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.matrix.api.core.RoomId
|
||||
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 kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
/**
|
||||
* 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)
|
||||
null -> ""
|
||||
}
|
||||
val avatarData = AvatarData(
|
||||
id = summary.identifier(),
|
||||
name = summary.details.name,
|
||||
url = summary.details.avatarUrl,
|
||||
size = AvatarSize.CustomRoomNotificationSetting,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
val roomName = summary.details.name
|
||||
|
|
@ -114,7 +109,12 @@ fun EditDefaultNotificationSettingView(
|
|||
Text(text = subtitle)
|
||||
},
|
||||
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 = {
|
||||
openRoomNotificationSettings(summary.details.roomId)
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ fun EditUserProfileView(
|
|||
matrixId = state.userId.value,
|
||||
displayName = state.displayName,
|
||||
avatarUrl = state.userAvatarUrl,
|
||||
avatarSize = AvatarSize.RoomHeader,
|
||||
avatarSize = AvatarSize.EditProfileDetails,
|
||||
onAvatarClick = { onAvatarClick() },
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_developer_mode">"Рэжым распрацоўшчыка"</string>
|
||||
<string name="screen_advanced_settings_developer_mode_description">"Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям."</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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 d’appel"</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_description">"Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs."</string>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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_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_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_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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_developer_mode">"Режим разработчика"</string>
|
||||
<string name="screen_advanced_settings_developer_mode_description">"Предоставьте разработчикам доступ к функциям и функциональным возможностям."</string>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
|
|
|
|||
|
|
@ -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_turned_off">"Systemaviseringar avstängda"</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>
|
||||
|
|
|
|||
|
|
@ -282,11 +282,13 @@ class DefaultBugReporter @Inject constructor(
|
|||
listener.onUploadFailed(serverError)
|
||||
}
|
||||
} finally {
|
||||
// delete the generated files when the bug report process has finished
|
||||
for (file in bugReportFiles) {
|
||||
file.safeDelete()
|
||||
withContext(coroutineDispatchers.io) {
|
||||
// delete the generated files when the bug report process has finished
|
||||
for (file in bugReportFiles) {
|
||||
file.safeDelete()
|
||||
}
|
||||
response?.close()
|
||||
}
|
||||
response?.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.roomNotificationSettings
|
||||
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.isOwnUserAdmin
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
|
@ -97,8 +99,9 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC)
|
||||
val canJoinCall by room.canCall(updateKey = syncUpdateTimestamp)
|
||||
val dmMember by room.getDirectRoomMember(membersState)
|
||||
val currentMember by room.getCurrentRoomMember(membersState)
|
||||
val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember)
|
||||
val roomType by getRoomType(dmMember)
|
||||
val roomType by getRoomType(dmMember, currentMember)
|
||||
|
||||
val topicState = remember(canEditTopic, roomTopic, roomType) {
|
||||
val topic = roomTopic
|
||||
|
|
@ -151,6 +154,7 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
isFavorite = isFavorite,
|
||||
displayRolesAndPermissionsSettings = !room.isDm && isUserAdmin,
|
||||
isPublic = isPublic,
|
||||
heroes = roomInfo?.heroes.orEmpty().toPersistentList(),
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
|
@ -163,10 +167,16 @@ class RoomDetailsPresenter @Inject constructor(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun getRoomType(dmMember: RoomMember?): State<RoomDetailsType> = remember(dmMember) {
|
||||
private fun getRoomType(
|
||||
dmMember: RoomMember?,
|
||||
currentMember: RoomMember?,
|
||||
): State<RoomDetailsType> = remember(dmMember, currentMember) {
|
||||
derivedStateOf {
|
||||
if (dmMember != null) {
|
||||
RoomDetailsType.Dm(dmMember)
|
||||
if (dmMember != null && currentMember != null) {
|
||||
RoomDetailsType.Dm(
|
||||
me = currentMember,
|
||||
otherMember = dmMember,
|
||||
)
|
||||
} else {
|
||||
RoomDetailsType.Room
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.room.RoomMember
|
||||
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(
|
||||
val roomId: RoomId,
|
||||
|
|
@ -43,13 +45,17 @@ data class RoomDetailsState(
|
|||
val isFavorite: Boolean,
|
||||
val displayRolesAndPermissionsSettings: Boolean,
|
||||
val isPublic: Boolean,
|
||||
val heroes: ImmutableList<MatrixUser>,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface RoomDetailsType {
|
||||
data object Room : RoomDetailsType
|
||||
data class Dm(val roomMember: RoomMember) : RoomDetailsType
|
||||
data class Dm(
|
||||
val me: RoomMember,
|
||||
val otherMember: RoomMember,
|
||||
) : RoomDetailsType
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
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.aUserProfileState
|
||||
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.RoomNotificationMode
|
||||
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> {
|
||||
override val values: Sequence<RoomDetailsState>
|
||||
|
|
@ -48,6 +52,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
|
|||
),
|
||||
aRoomDetailsState(canCall = false, canInvite = false),
|
||||
aRoomDetailsState(isPublic = false),
|
||||
aRoomDetailsState(heroes = aMatrixUserList()),
|
||||
// Add other state here
|
||||
)
|
||||
}
|
||||
|
|
@ -99,6 +104,7 @@ fun aRoomDetailsState(
|
|||
isFavorite: Boolean = false,
|
||||
displayAdminSettings: Boolean = false,
|
||||
isPublic: Boolean = true,
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
eventSink: (RoomDetailsEvent) -> Unit = {},
|
||||
) = RoomDetailsState(
|
||||
roomId = roomId,
|
||||
|
|
@ -119,6 +125,7 @@ fun aRoomDetailsState(
|
|||
isFavorite = isFavorite,
|
||||
displayRolesAndPermissionsSettings = displayAdminSettings,
|
||||
isPublic = isPublic,
|
||||
heroes = heroes.toPersistentList(),
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
|
|
@ -135,6 +142,10 @@ fun aDmRoomDetailsState(
|
|||
roomName: String = "Daniel",
|
||||
) = aRoomDetailsState(
|
||||
roomName = roomName,
|
||||
roomType = RoomDetailsType.Dm(aDmRoomMember(isIgnored = isDmMemberIgnored)),
|
||||
isPublic = false,
|
||||
roomType = RoomDetailsType.Dm(
|
||||
aRoomMember(),
|
||||
aDmRoomMember(isIgnored = isDmMemberIgnored),
|
||||
),
|
||||
roomMemberDetailsState = aUserProfileState()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.features.leaveroom.api.LeaveRoomView
|
||||
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.BlockUserSection
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
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.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.MainActionButton
|
||||
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.matrix.api.core.RoomAlias
|
||||
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.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.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
fun RoomDetailsView(
|
||||
|
|
@ -125,38 +130,35 @@ fun RoomDetailsView(
|
|||
roomId = state.roomId,
|
||||
roomName = state.roomName,
|
||||
roomAlias = state.roomAlias,
|
||||
isEncrypted = state.isEncrypted,
|
||||
isPublic = state.isPublic,
|
||||
heroes = state.heroes,
|
||||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(state.roomName, avatarUrl)
|
||||
},
|
||||
)
|
||||
MainActionsSection(
|
||||
state = state,
|
||||
onShareRoom = onShareRoom,
|
||||
onInvitePeople = invitePeople,
|
||||
onCall = onJoinCallClick,
|
||||
)
|
||||
}
|
||||
|
||||
is RoomDetailsType.Dm -> {
|
||||
val member = state.roomType.roomMember
|
||||
UserProfileHeaderSection(
|
||||
avatarUrl = state.roomAvatarUrl ?: member.avatarUrl,
|
||||
userId = member.userId,
|
||||
userName = state.roomName,
|
||||
openAvatarPreview = { avatarUrl ->
|
||||
openAvatarPreview(member.getBestName(), avatarUrl)
|
||||
DmHeaderSection(
|
||||
me = state.roomType.me,
|
||||
otherMember = state.roomType.otherMember,
|
||||
roomName = state.roomName,
|
||||
openAvatarPreview = { name, avatarUrl ->
|
||||
openAvatarPreview(name, 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))
|
||||
|
||||
if (state.roomTopic !is RoomTopicState.Hidden) {
|
||||
|
|
@ -322,8 +324,7 @@ private fun RoomHeaderSection(
|
|||
roomId: RoomId,
|
||||
roomName: String,
|
||||
roomAlias: RoomAlias?,
|
||||
isEncrypted: Boolean,
|
||||
isPublic: Boolean,
|
||||
heroes: ImmutableList<MatrixUser>,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -332,30 +333,65 @@ private fun RoomHeaderSection(
|
|||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
CompositeAvatar(
|
||||
avatarData = AvatarData(roomId.value, roomName, avatarUrl, AvatarSize.RoomHeader),
|
||||
heroes = heroes.map { user ->
|
||||
user.getAvatarData(size = AvatarSize.RoomHeader)
|
||||
}.toPersistentList(),
|
||||
modifier = Modifier
|
||||
.size(70.dp)
|
||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.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 = roomName,
|
||||
style = ElementTheme.typography.fontHeadingLgBold,
|
||||
text = subtitle,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
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(
|
||||
isEncrypted: Boolean,
|
||||
isPublic: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (isEncrypted || isPublic) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
modifier = modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (isEncrypted) {
|
||||
|
|
|
|||
|
|
@ -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.TopAppBar
|
||||
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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -276,11 +276,7 @@ private fun RoomMemberListItem(
|
|||
}
|
||||
MatrixUserRow(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
matrixUser = MatrixUser(
|
||||
userId = roomMember.userId,
|
||||
displayName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl,
|
||||
),
|
||||
matrixUser = roomMember.toMatrixUser(),
|
||||
avatarSize = AvatarSize.UserListItem,
|
||||
trailingContent = roleText?.let {
|
||||
@Composable {
|
||||
|
|
|
|||
|
|
@ -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.rememberAsyncIndicatorState
|
||||
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.dialogs.ConfirmationDialog
|
||||
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.room.RoomMember
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -217,12 +217,7 @@ private fun RoomMemberActionsBottomSheet(
|
|||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = roomMember.userId.value,
|
||||
name = roomMember.displayName,
|
||||
url = roomMember.avatarUrl,
|
||||
size = AvatarSize.RoomListManageUser,
|
||||
),
|
||||
avatarData = roomMember.getAvatarData(size = AvatarSize.RoomListManageUser),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 28.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
|
|
|
|||
|
|
@ -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.MatrixRoomInfo
|
||||
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.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -50,21 +50,21 @@ class RolesAndPermissionsPresenter @Inject constructor(
|
|||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
val roomMembers by room.membersStateFlow.collectAsState()
|
||||
// Get the list of joined room members, in order to filter members present in the power
|
||||
// level state Event, but not member of the room anymore.
|
||||
val joinedRoomMemberIds by remember {
|
||||
// Get the list of active room members (joined or invited), in order to filter members present in the power
|
||||
// level state Event.
|
||||
val activeRoomMemberIds by remember {
|
||||
derivedStateOf {
|
||||
roomMembers.joinedRoomMembers().map { it.userId }
|
||||
roomMembers.activeRoomMembers().map { it.userId }
|
||||
}
|
||||
}
|
||||
val moderatorCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.MODERATOR)
|
||||
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.MODERATOR)
|
||||
}
|
||||
}
|
||||
val adminCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(joinedRoomMemberIds, RoomMember.Role.ADMIN)
|
||||
roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.ADMIN)
|
||||
}
|
||||
}
|
||||
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) ->
|
||||
RoomMember.Role.forPowerLevel(level) == role && userId in joinedRoomMemberIds
|
||||
RoomMember.Role.forPowerLevel(level) == role && userId in userIds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.user.MatrixUser
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -327,7 +328,7 @@ private fun ListMemberItem(
|
|||
}
|
||||
MemberRow(
|
||||
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(),
|
||||
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
|
||||
isPending = roomMember.membership == RoomMembershipState.INVITE,
|
||||
|
|
|
|||
|
|
@ -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_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_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_section_administrators">"Administratörer"</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_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_already_a_member">"Redan medlem"</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_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_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>
|
||||
<plurals name="screen_room_member_list_header_title">
|
||||
<item quantity="one">"%1$d person"</item>
|
||||
<item quantity="other">"%1$d personer"</item>
|
||||
</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_confirmation_ban">"Ta bort och banna 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_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_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_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_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_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_room_details">"Rumsdetaljer"</string>
|
||||
<string name="screen_room_roles_and_permissions_title">"Roller och behörigheter"</string>
|
||||
|
|
|
|||
|
|
@ -169,8 +169,12 @@ class RoomDetailsPresenterTest {
|
|||
val presenter = createRoomDetailsPresenter(room)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
|
||||
|
||||
assertThat(initialState.roomType).isEqualTo(
|
||||
RoomDetailsType.Dm(
|
||||
me = myRoomMember,
|
||||
otherMember = otherRoomMember,
|
||||
)
|
||||
)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
|||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.testtags.TestTags
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
|
@ -126,6 +127,7 @@ class RoomDetailsViewTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Config(qualifiers = "h1024dp")
|
||||
@Test
|
||||
fun `click on add topic emit expected event`() {
|
||||
ensureCalledOnceWithParam<RoomDetailsAction>(RoomDetailsAction.AddTopic) { callback ->
|
||||
|
|
@ -176,7 +178,11 @@ class RoomDetailsViewTest {
|
|||
fun `click on avatar test on DM`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEvent>(expectEvents = false)
|
||||
val state = aRoomDetailsState(
|
||||
roomType = RoomDetailsType.Dm(aDmRoomMember(avatarUrl = "an_avatar_url")),
|
||||
roomType = RoomDetailsType.Dm(
|
||||
aRoomMember(),
|
||||
aDmRoomMember(avatarUrl = "an_avatar_url"),
|
||||
),
|
||||
roomName = "Daniel",
|
||||
eventSink = eventsRecorder,
|
||||
)
|
||||
val callback = EnsureCalledOnceWithTwoParams("Daniel", "an_avatar_url")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.libraries.core.extensions.orEmpty
|
||||
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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
|
|
@ -158,7 +158,10 @@ private fun RoomSummaryScaffoldRow(
|
|||
.padding(horizontal = 16.dp, vertical = 11.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
Avatar(room.avatarData)
|
||||
CompositeAvatar(
|
||||
avatarData = room.avatarData,
|
||||
heroes = room.heroes,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -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.room.CurrentUserMembership
|
||||
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 kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomListRoomSummaryFactory @Inject constructor(
|
||||
|
|
@ -54,43 +58,45 @@ class RoomListRoomSummaryFactory @Inject constructor(
|
|||
inviteSender = null,
|
||||
isDm = false,
|
||||
canonicalAlias = null,
|
||||
heroes = persistentListOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(roomSummary: RoomSummary.Filled): RoomListRoomSummary {
|
||||
val roomIdentifier = roomSummary.identifier()
|
||||
val avatarData = AvatarData(
|
||||
id = roomIdentifier,
|
||||
name = roomSummary.details.name,
|
||||
url = roomSummary.details.avatarUrl,
|
||||
size = AvatarSize.RoomListItem,
|
||||
)
|
||||
return create(roomSummary.details)
|
||||
}
|
||||
|
||||
private fun create(details: RoomSummaryDetails): RoomListRoomSummary {
|
||||
val avatarData = details.getAvatarData(size = AvatarSize.RoomListItem)
|
||||
return RoomListRoomSummary(
|
||||
id = roomIdentifier,
|
||||
roomId = RoomId(roomIdentifier),
|
||||
name = roomSummary.details.name,
|
||||
numberOfUnreadMessages = roomSummary.details.numUnreadMessages,
|
||||
numberOfUnreadMentions = roomSummary.details.numUnreadMentions,
|
||||
numberOfUnreadNotifications = roomSummary.details.numUnreadNotifications,
|
||||
isMarkedUnread = roomSummary.details.isMarkedUnread,
|
||||
timestamp = lastMessageTimestampFormatter.format(roomSummary.details.lastMessageTimestamp),
|
||||
lastMessage = roomSummary.details.lastMessage?.let { message ->
|
||||
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
|
||||
id = details.roomId.value,
|
||||
roomId = details.roomId,
|
||||
name = details.name,
|
||||
numberOfUnreadMessages = details.numUnreadMessages,
|
||||
numberOfUnreadMentions = details.numUnreadMentions,
|
||||
numberOfUnreadNotifications = details.numUnreadNotifications,
|
||||
isMarkedUnread = details.isMarkedUnread,
|
||||
timestamp = lastMessageTimestampFormatter.format(details.lastMessageTimestamp),
|
||||
lastMessage = details.lastMessage?.let { message ->
|
||||
roomLastMessageFormatter.format(message.event, details.isDirect)
|
||||
}.orEmpty(),
|
||||
avatarData = avatarData,
|
||||
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
|
||||
hasRoomCall = roomSummary.details.hasRoomCall,
|
||||
isDirect = roomSummary.details.isDirect,
|
||||
isFavorite = roomSummary.details.isFavorite,
|
||||
inviteSender = roomSummary.details.inviter?.toInviteSender(),
|
||||
isDm = roomSummary.details.isDm,
|
||||
canonicalAlias = roomSummary.details.canonicalAlias,
|
||||
displayType = if (roomSummary.details.currentUserMembership == CurrentUserMembership.INVITED) {
|
||||
userDefinedNotificationMode = details.userDefinedNotificationMode,
|
||||
hasRoomCall = details.hasRoomCall,
|
||||
isDirect = details.isDirect,
|
||||
isFavorite = details.isFavorite,
|
||||
inviteSender = details.inviter?.toInviteSender(),
|
||||
isDm = details.isDm,
|
||||
canonicalAlias = details.canonicalAlias,
|
||||
displayType = if (details.currentUserMembership == CurrentUserMembership.INVITED) {
|
||||
RoomSummaryDisplayType.INVITE
|
||||
} else {
|
||||
RoomSummaryDisplayType.ROOM
|
||||
}
|
||||
},
|
||||
heroes = details.heroes.map { user ->
|
||||
user.getAvatarData(size = AvatarSize.RoomListItem)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.animation.core.spring
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.FilterChipDefaults
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -66,7 +71,31 @@ fun RoomListFiltersView(
|
|||
state.eventSink(RoomListFiltersEvents.ToggleFilter(filter))
|
||||
}
|
||||
|
||||
var scrollToStart by remember { mutableIntStateOf(0) }
|
||||
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>()) }
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(start = 8.dp, end = 16.dp),
|
||||
|
|
@ -84,6 +113,9 @@ fun RoomListFiltersView(
|
|||
onClick = {
|
||||
previousFilters.value = state.selectedFilters()
|
||||
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 = {
|
||||
previousFilters.value = state.selectedFilters()
|
||||
onToggleFilter(it)
|
||||
// When selecting a filter, we want to scroll to the start of the list
|
||||
if (filterWithSelection.isSelected.not()) {
|
||||
scrollToStart++
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
data class RoomListRoomSummary(
|
||||
|
|
@ -43,6 +44,7 @@ data class RoomListRoomSummary(
|
|||
val isDm: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
val inviteSender: InviteSender?,
|
||||
val heroes: ImmutableList<AvatarData>,
|
||||
) {
|
||||
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
|
||||
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
|
||||
|
|
|
|||
|
|
@ -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.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.ui.model.InviteSender
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
|
||||
override val values: Sequence<RoomListRoomSummary>
|
||||
|
|
@ -142,6 +143,7 @@ internal fun aRoomListRoomSummary(
|
|||
inviteSender: InviteSender? = null,
|
||||
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
|
||||
canonicalAlias: RoomAlias? = null,
|
||||
heroes: List<AvatarData> = emptyList(),
|
||||
) = RoomListRoomSummary(
|
||||
id = id,
|
||||
roomId = RoomId(id),
|
||||
|
|
@ -161,4 +163,5 @@ internal fun aRoomListRoomSummary(
|
|||
inviteSender = inviteSender,
|
||||
displayType = displayType,
|
||||
canonicalAlias = canonicalAlias,
|
||||
heroes = heroes.toImmutableList(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<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_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_title">"Адхіліць запрашэнне"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?"</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<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_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_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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<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_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 d’appel"</string>
|
||||
<string name="screen_invites_decline_chat_message">"Êtes-vous sûr de vouloir décliner l’invitation à rejoindre %1$s ?"</string>
|
||||
<string name="screen_invites_decline_chat_title">"Refuser l’invitation"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Êtes-vous sûr de vouloir refuser cette discussion privée avec %1$s ?"</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<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_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_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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
"Введите "
|
||||
<b>"ключ восстановления"</b>
|
||||
</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_title">"Отклонить приглашение"</string>
|
||||
<string name="screen_invites_decline_direct_chat_message">"Вы уверены, что хотите отказаться от личного общения с %1$s?"</string>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<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_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_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>
|
||||
|
|
|
|||
|
|
@ -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_title">"Inga chattar än."</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_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_people">"Personer"</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_mark_as_read">"Markera som lä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_title">"Verifiera att det är du"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import org.junit.Test
|
||||
|
||||
class RoomListRoomSummaryTest {
|
||||
|
|
@ -91,6 +92,7 @@ internal fun createRoomListRoomSummary(
|
|||
userDefinedNotificationMode: RoomNotificationMode? = null,
|
||||
isFavorite: Boolean = false,
|
||||
displayType: RoomSummaryDisplayType = RoomSummaryDisplayType.ROOM,
|
||||
heroes: List<AvatarData> = emptyList(),
|
||||
) = RoomListRoomSummary(
|
||||
id = A_ROOM_ID.value,
|
||||
roomId = A_ROOM_ID,
|
||||
|
|
@ -110,4 +112,5 @@ internal fun createRoomListRoomSummary(
|
|||
canonicalAlias = null,
|
||||
inviteSender = null,
|
||||
isDm = false,
|
||||
heroes = heroes.toPersistentList(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,14 +17,11 @@
|
|||
package io.element.android.features.userprofile.shared
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
|
|
@ -55,15 +54,12 @@ fun UserProfileHeaderSection(
|
|||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(modifier = Modifier.size(70.dp)) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
|
||||
modifier = Modifier
|
||||
Avatar(
|
||||
avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader),
|
||||
modifier = Modifier
|
||||
.clickable(enabled = avatarUrl != null) { openAvatarPreview(avatarUrl!!) }
|
||||
.fillMaxSize()
|
||||
.testTag(TestTags.memberDetailAvatar)
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
if (userName != null) {
|
||||
Text(
|
||||
|
|
@ -86,3 +82,14 @@ fun UserProfileHeaderSection(
|
|||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserProfileHeaderSectionPreview() = ElementPreview {
|
||||
UserProfileHeaderSection(
|
||||
avatarUrl = null,
|
||||
userId = UserId("@alice:example.com"),
|
||||
userName = "Alice",
|
||||
openAvatarPreview = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ coroutines = "1.8.1"
|
|||
accompanist = "0.34.0"
|
||||
|
||||
# Test
|
||||
test_core = "1.5.0"
|
||||
test_core = "1.6.0"
|
||||
|
||||
#other
|
||||
coil = "2.6.0"
|
||||
|
|
@ -44,7 +44,7 @@ serialization_json = "1.6.3"
|
|||
showkase = "1.0.3"
|
||||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.2"
|
||||
wysiwyg = "2.37.3"
|
||||
wysiwyg = "2.37.4"
|
||||
telephoto = "0.11.2"
|
||||
|
||||
# DI
|
||||
|
|
@ -55,8 +55,7 @@ anvil = "2.4.9"
|
|||
autoservice = "1.1.1"
|
||||
|
||||
# quality
|
||||
androidx-test-ext-junit = "1.1.5"
|
||||
espresso-core = "3.5.1"
|
||||
androidx-test-ext-junit = "1.2.0"
|
||||
kover = "0.8.0"
|
||||
|
||||
[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" }
|
||||
gms_google_services = "com.google.gms:google-services:4.4.2"
|
||||
# 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" }
|
||||
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_arch_core = "androidx.arch.core:core-testing:2.2.0"
|
||||
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_konsist = "com.lemonappdev:konsist:0.15.1"
|
||||
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"
|
||||
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", 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_ktx = "org.maplibre.gl:android-sdk-ktx-v7: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"
|
||||
|
||||
# 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"
|
||||
# main branch can be tested replacing the version with main-SNAPSHOT
|
||||
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
|
||||
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" }
|
||||
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" }
|
||||
appcompat = "androidx.appcompat:appcompat:1.7.0"
|
||||
|
||||
[bundles]
|
||||
|
||||
|
|
|
|||
|
|
@ -26,19 +26,18 @@ import kotlinx.datetime.toJavaLocalDate
|
|||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import java.time.Period
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
// TODO rework this date formatting
|
||||
class DateFormatters @Inject constructor(
|
||||
private val locale: Locale,
|
||||
private val clock: Clock,
|
||||
private val timeZone: TimeZone,
|
||||
) {
|
||||
private val onlyTimeFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm"
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
}
|
||||
|
||||
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
|
||||
|
|
@ -51,6 +50,10 @@ class DateFormatters @Inject constructor(
|
|||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
private val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
}
|
||||
|
||||
internal fun formatTime(localDateTime: LocalDateTime): String {
|
||||
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
|
@ -63,6 +66,10 @@ class DateFormatters @Inject constructor(
|
|||
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String {
|
||||
return dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDate(
|
||||
dateToFormat: LocalDateTime,
|
||||
currentDate: LocalDateTime,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class DefaultDaySeparatorFormatter @Inject constructor(
|
|||
) : DaySeparatorFormatter {
|
||||
override fun format(timestamp: Long): String {
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
return dateFormatters.formatDateWithYear(dateToFormat)
|
||||
// TODO use relative formatting once iOS uses it too
|
||||
return dateFormatters.formatDateWithFullFormat(dateToFormat)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormat
|
|||
import io.element.android.libraries.dateformatter.test.FakeClock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ class DefaultLastMessageTimestampFormatterTest {
|
|||
val now = "1980-04-06T18:35:24.00Z"
|
||||
val dat = "1980-04-06T18:35:24.00Z"
|
||||
val formatter = createFormatter(now)
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35")
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -52,7 +53,7 @@ class DefaultLastMessageTimestampFormatterTest {
|
|||
val now = "1980-04-06T18:35:24.00Z"
|
||||
val dat = "1980-04-06T18:35:23.00Z"
|
||||
val formatter = createFormatter(now)
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35")
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -60,7 +61,7 @@ class DefaultLastMessageTimestampFormatterTest {
|
|||
val now = "1980-04-06T18:35:24.00Z"
|
||||
val dat = "1980-04-06T18:34:24.00Z"
|
||||
val formatter = createFormatter(now)
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:34")
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:34 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -68,7 +69,7 @@ class DefaultLastMessageTimestampFormatterTest {
|
|||
val now = "1980-04-06T18:35:24.00Z"
|
||||
val dat = "1980-04-06T17:35:24.00Z"
|
||||
val formatter = createFormatter(now)
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("17:35")
|
||||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("5:35 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -96,6 +97,15 @@ class DefaultLastMessageTimestampFormatterTest {
|
|||
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test full format`() {
|
||||
val now = "1980-04-06T18:35:24.00Z"
|
||||
val dat = "1979-04-06T18:35:24.00Z"
|
||||
val clock = FakeClock().apply { givenInstant(Instant.parse(now)) }
|
||||
val dateFormatters = DateFormatters(Locale.US, clock, TimeZone.UTC)
|
||||
assertThat(dateFormatters.formatDateWithFullFormat(Instant.parse(dat).toLocalDateTime(TimeZone.UTC))).isEqualTo("Friday, April 6, 1979")
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DefaultLastMessageFormatter and set current time to the provided date.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
|
|||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
|
|
@ -52,18 +53,22 @@ fun Avatar(
|
|||
avatarData: AvatarData,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
// If not null, will be used instead of the size from avatarData
|
||||
forcedAvatarSize: Dp? = null,
|
||||
) {
|
||||
val commonModifier = modifier
|
||||
.size(avatarData.size.dp)
|
||||
.size(forcedAvatarSize ?: avatarData.size.dp)
|
||||
.clip(CircleShape)
|
||||
if (avatarData.url.isNullOrBlank()) {
|
||||
InitialsAvatar(
|
||||
avatarData = avatarData,
|
||||
forcedAvatarSize = forcedAvatarSize,
|
||||
modifier = commonModifier,
|
||||
)
|
||||
} else {
|
||||
ImageAvatar(
|
||||
avatarData = avatarData,
|
||||
forcedAvatarSize = forcedAvatarSize,
|
||||
modifier = commonModifier,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
|
|
@ -73,6 +78,7 @@ fun Avatar(
|
|||
@Composable
|
||||
private fun ImageAvatar(
|
||||
avatarData: AvatarData,
|
||||
forcedAvatarSize: Dp?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
|
|
@ -98,9 +104,15 @@ private fun ImageAvatar(
|
|||
SideEffect {
|
||||
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
|
||||
}
|
||||
InitialsAvatar(avatarData = avatarData)
|
||||
InitialsAvatar(
|
||||
avatarData = avatarData,
|
||||
forcedAvatarSize = forcedAvatarSize,
|
||||
)
|
||||
}
|
||||
else -> InitialsAvatar(avatarData = avatarData)
|
||||
else -> InitialsAvatar(
|
||||
avatarData = avatarData,
|
||||
forcedAvatarSize = forcedAvatarSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,13 +121,14 @@ private fun ImageAvatar(
|
|||
@Composable
|
||||
private fun InitialsAvatar(
|
||||
avatarData: AvatarData,
|
||||
forcedAvatarSize: Dp?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
|
||||
Box(
|
||||
modifier.background(color = avatarColors.background)
|
||||
) {
|
||||
val fontSize = avatarData.size.dp.toSp() / 2
|
||||
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).toSp() / 2
|
||||
val originalFont = ElementTheme.typography.fontHeadingMdBold
|
||||
val ratio = fontSize.value / originalFont.fontSize.value
|
||||
val lineHeight = originalFont.lineHeight * ratio
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ enum class AvatarSize(val dp: Dp) {
|
|||
SelectedUser(56.dp),
|
||||
SelectedRoom(56.dp),
|
||||
|
||||
DmCluster(75.dp),
|
||||
|
||||
TimelineRoom(32.dp),
|
||||
TimelineSender(32.dp),
|
||||
TimelineReadReceipt(16.dp),
|
||||
|
|
@ -55,4 +57,8 @@ enum class AvatarSize(val dp: Dp) {
|
|||
CustomRoomNotificationSetting(36.dp),
|
||||
|
||||
RoomDirectoryItem(36.dp),
|
||||
|
||||
EditProfileDetails(96.dp),
|
||||
|
||||
Suggestion(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.avatar
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.util.Collections
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
fun CompositeAvatar(
|
||||
avatarData: AvatarData,
|
||||
heroes: ImmutableList<AvatarData>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
if (avatarData.url != null || heroes.isEmpty()) {
|
||||
Avatar(avatarData, modifier, contentDescription)
|
||||
} else {
|
||||
val limitedHeroes = heroes.take(4)
|
||||
val numberOfHeroes = limitedHeroes.size
|
||||
if (numberOfHeroes == 4) {
|
||||
// Swap 2 and 3 so that the 4th hero is at the bottom right
|
||||
Collections.swap(limitedHeroes, 2, 3)
|
||||
}
|
||||
when (numberOfHeroes) {
|
||||
0 -> {
|
||||
error("Unsupported number of heroes: 0")
|
||||
}
|
||||
1 -> {
|
||||
Avatar(heroes[0], modifier, contentDescription)
|
||||
}
|
||||
else -> {
|
||||
val angle = 2 * Math.PI / numberOfHeroes
|
||||
val offsetRadius = when (numberOfHeroes) {
|
||||
2 -> avatarData.size.dp.value / 4.2
|
||||
3 -> avatarData.size.dp.value / 4.0
|
||||
4 -> avatarData.size.dp.value / 3.1
|
||||
else -> error("Unsupported number of heroes: $numberOfHeroes")
|
||||
}
|
||||
val heroAvatarSize = when (numberOfHeroes) {
|
||||
2 -> avatarData.size.dp / 2.2f
|
||||
3 -> avatarData.size.dp / 2.4f
|
||||
4 -> avatarData.size.dp / 2.2f
|
||||
else -> error("Unsupported number of heroes: $numberOfHeroes")
|
||||
}
|
||||
val angleOffset = when (numberOfHeroes) {
|
||||
2 -> PI
|
||||
3 -> 7 * PI / 6
|
||||
4 -> 13 * PI / 4
|
||||
else -> error("Unsupported number of heroes: $numberOfHeroes")
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(avatarData.size.dp)
|
||||
.semantics {
|
||||
this.contentDescription = contentDescription.orEmpty()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
limitedHeroes.forEachIndexed { index, heroAvatar ->
|
||||
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
|
||||
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(heroAvatarSize)
|
||||
.offset(
|
||||
x = xOffset,
|
||||
y = yOffset,
|
||||
)
|
||||
) {
|
||||
Avatar(
|
||||
heroAvatar,
|
||||
forcedAvatarSize = heroAvatarSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Avatars)
|
||||
@Composable
|
||||
internal fun CompositeAvatarPreview() = ElementThemedPreview {
|
||||
val mainAvatar = anAvatarData(
|
||||
id = "Zac",
|
||||
name = "Zac",
|
||||
size = AvatarSize.RoomListItem,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
repeat(6) { nbOfHeroes ->
|
||||
CompositeAvatar(
|
||||
avatarData = mainAvatar,
|
||||
heroes = List(nbOfHeroes) { aHeroAvatarData(it) }.toPersistentList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aHeroAvatarData(i: Int) = anAvatarData(
|
||||
id = ('A' + i).toString(),
|
||||
name = ('A' + i).toString()
|
||||
)
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.avatar
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
import io.element.android.libraries.designsystem.text.toPx
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
|
||||
/** Ratio between the box size (120 on Figma) and the avatar size (75 on Figma). */
|
||||
private const val SIZE_RATIO = 1.6f
|
||||
|
||||
/**
|
||||
* https://www.figma.com/design/A2pAEvTEpJZBiOPUlcMnKi/Settings-%2B-Room-Details-(new)?node-id=1787-56333
|
||||
*/
|
||||
@Composable
|
||||
fun DmAvatars(
|
||||
userAvatarData: AvatarData,
|
||||
otherUserAvatarData: AvatarData,
|
||||
openAvatarPreview: (url: String) -> Unit,
|
||||
openOtherAvatarPreview: (url: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val boxSize = userAvatarData.size.dp * SIZE_RATIO
|
||||
val boxSizePx = boxSize.toPx()
|
||||
val otherAvatarRadius = otherUserAvatarData.size.dp.toPx() / 2
|
||||
Box(
|
||||
modifier = modifier.size(boxSize),
|
||||
) {
|
||||
// Draw user avatar and cut top right corner
|
||||
Avatar(
|
||||
avatarData = userAvatarData,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.graphicsLayer {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawCircle(
|
||||
color = Color.Black,
|
||||
center = Offset(
|
||||
x = boxSizePx - otherAvatarRadius,
|
||||
y = size.height - (boxSizePx - otherAvatarRadius),
|
||||
),
|
||||
radius = otherAvatarRadius / 0.9f,
|
||||
blendMode = BlendMode.Clear,
|
||||
)
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = userAvatarData.url != null) {
|
||||
userAvatarData.url?.let { openAvatarPreview(it) }
|
||||
}
|
||||
)
|
||||
// Draw other user avatar
|
||||
Avatar(
|
||||
avatarData = otherUserAvatarData,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.clip(CircleShape)
|
||||
.clickable(enabled = otherUserAvatarData.url != null) {
|
||||
otherUserAvatarData.url?.let { openOtherAvatarPreview(it) }
|
||||
}
|
||||
.testTag(TestTags.memberDetailAvatar)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(group = PreviewGroup.Avatars)
|
||||
@Composable
|
||||
internal fun DmAvatarsPreview() = ElementThemedPreview {
|
||||
val size = AvatarSize.DmCluster
|
||||
DmAvatars(
|
||||
userAvatarData = anAvatarData(
|
||||
id = "Alice",
|
||||
name = "Alice",
|
||||
size = size,
|
||||
),
|
||||
otherUserAvatarData = anAvatarData(
|
||||
id = "Bob",
|
||||
name = "Bob",
|
||||
size = size,
|
||||
),
|
||||
openAvatarPreview = {},
|
||||
openOtherAvatarPreview = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -3,12 +3,16 @@
|
|||
<string name="state_event_avatar_changed_too">"(avatar ändrades också)"</string>
|
||||
<string name="state_event_avatar_url_changed">"%1$s bytte sin avatar"</string>
|
||||
<string name="state_event_avatar_url_changed_by_you">"Du bytte din avatar"</string>
|
||||
<string name="state_event_demoted_to_member">"%1$s degraderades till medlem"</string>
|
||||
<string name="state_event_demoted_to_moderator">"%1$s degraderades till moderator"</string>
|
||||
<string name="state_event_display_name_changed_from">"%1$s bytte sitt visningsnamn från %2$s till %3$s"</string>
|
||||
<string name="state_event_display_name_changed_from_by_you">"Du bytte ditt visningsnamn från %1$s till %2$s"</string>
|
||||
<string name="state_event_display_name_removed">"%1$s tog bort sitt visningsnamn (det var %2$s)"</string>
|
||||
<string name="state_event_display_name_removed_by_you">"Du tog bort ditt visningsnamn (det var %1$s)"</string>
|
||||
<string name="state_event_display_name_set">"%1$s satte sitt visningsnamn till %2$s"</string>
|
||||
<string name="state_event_display_name_set_by_you">"Du satte ditt visningsnamn till %1$s"</string>
|
||||
<string name="state_event_promoted_to_administrator">"%1$s befordrades till admin"</string>
|
||||
<string name="state_event_promoted_to_moderator">"%1$s befordrades till moderator"</string>
|
||||
<string name="state_event_room_avatar_changed">"%1$s bytte rummets avatar"</string>
|
||||
<string name="state_event_room_avatar_changed_by_you">"Du bytte rummets avatar"</string>
|
||||
<string name="state_event_room_avatar_removed">"%1$s tog bort rummets avatar"</string>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
|
|||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
|
|
@ -49,5 +50,6 @@ data class MatrixRoomInfo(
|
|||
val notificationCount: Long,
|
||||
val userDefinedNotificationMode: RoomNotificationMode?,
|
||||
val hasRoomCall: Boolean,
|
||||
val activeRoomCallParticipants: ImmutableList<String>
|
||||
val activeRoomCallParticipants: ImmutableList<String>,
|
||||
val heroes: ImmutableList<MatrixUser>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,3 +39,7 @@ fun MatrixRoomMembersState.roomMembers(): List<RoomMember>? {
|
|||
fun MatrixRoomMembersState.joinedRoomMembers(): List<RoomMember> {
|
||||
return roomMembers().orEmpty().filter { it.membership == RoomMembershipState.JOIN }
|
||||
}
|
||||
|
||||
fun MatrixRoomMembersState.activeRoomMembers(): List<RoomMember> {
|
||||
return roomMembers().orEmpty().filter { it.membership.isActive() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ package io.element.android.libraries.matrix.api.room.powerlevels
|
|||
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.matrix.api.room.activeRoomMembers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -27,14 +27,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Return a flow of the list of room members who are still in the room (with membership == RoomMembershipState.JOIN)
|
||||
* and who have the given role.
|
||||
* Return a flow of the list of active room members who have the given role.
|
||||
*/
|
||||
fun MatrixRoom.usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
|
||||
return roomInfoFlow
|
||||
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
|
||||
.combine(membersStateFlow) { powerLevels, membersState ->
|
||||
membersState.joinedRoomMembers()
|
||||
membersState.activeRoomMembers()
|
||||
.filter { powerLevels.containsKey(it.userId) }
|
||||
.toPersistentList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
|||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.message.RoomMessage
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface RoomSummary {
|
||||
data class Empty(val identifier: String) : RoomSummary
|
||||
|
|
@ -52,6 +53,7 @@ data class RoomSummaryDetails(
|
|||
val isDm: Boolean,
|
||||
val isFavorite: Boolean,
|
||||
val currentUserMembership: CurrentUserMembership,
|
||||
val heroes: List<MatrixUser>,
|
||||
) {
|
||||
val lastMessageTimestamp = lastMessage?.originServerTs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,10 @@ import io.element.android.libraries.matrix.api.core.EventId
|
|||
@Immutable
|
||||
sealed interface LocalEventSendState {
|
||||
data object NotSentYet : LocalEventSendState
|
||||
|
||||
data class SendingFailed(
|
||||
val error: String
|
||||
) : LocalEventSendState
|
||||
|
||||
sealed class SendingFailed(open val error: String) : LocalEventSendState {
|
||||
data class Recoverable(override val error: String) : SendingFailed(error)
|
||||
data class Unrecoverable(override val error: String) : SendingFailed(error)
|
||||
}
|
||||
data class Sent(
|
||||
val eventId: EventId
|
||||
) : LocalEventSendState
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import org.matrix.rustcomponents.sdk.RoomHero
|
||||
import org.matrix.rustcomponents.sdk.Membership as RustMembership
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
|
||||
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
|
||||
|
|
@ -55,7 +57,8 @@ class MatrixRoomInfoMapper {
|
|||
notificationCount = it.notificationCount.toLong(),
|
||||
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
|
||||
hasRoomCall = it.hasRoomCall,
|
||||
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList()
|
||||
activeRoomCallParticipants = it.activeRoomCallParticipants.toImmutableList(),
|
||||
heroes = it.elementHeroes().toImmutableList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +75,15 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
|
|||
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a RoomHero to a MatrixUser. There is not need to create a RoomHero type on the application side.
|
||||
*/
|
||||
fun RoomHero.map(): MatrixUser = MatrixUser(
|
||||
userId = UserId(userId),
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl
|
||||
)
|
||||
|
||||
fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
|
||||
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo
|
||||
|
||||
/**
|
||||
* Extract the heroes from the room info.
|
||||
* For now we only use heroes for direct rooms with 2 members.
|
||||
* Also we keep the heroes only if there is one single hero.
|
||||
*/
|
||||
fun RoomInfo.elementHeroes(): List<MatrixUser> {
|
||||
return heroes
|
||||
.takeIf { isDirect && activeMembersCount.toLong() == 2L }
|
||||
?.takeIf { it.size == 1 }
|
||||
?.map { it.map() }
|
||||
.orEmpty()
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias
|
|||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
|
||||
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
|
||||
import io.element.android.libraries.matrix.impl.room.elementHeroes
|
||||
import io.element.android.libraries.matrix.impl.room.map
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
|
||||
|
|
@ -49,6 +50,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
|
|||
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
|
||||
isFavorite = roomInfo.isFavourite,
|
||||
currentUserMembership = roomInfo.membership.map(),
|
||||
heroes = roomInfo.elementHeroes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,13 @@ fun RustEventSendState?.map(): LocalEventSendState? {
|
|||
return when (this) {
|
||||
null -> null
|
||||
RustEventSendState.NotSentYet -> LocalEventSendState.NotSentYet
|
||||
is RustEventSendState.SendingFailed -> LocalEventSendState.SendingFailed(error)
|
||||
is RustEventSendState.SendingFailed -> {
|
||||
if (this.isRecoverable) {
|
||||
LocalEventSendState.SendingFailed.Recoverable(this.error)
|
||||
} else {
|
||||
LocalEventSendState.SendingFailed.Unrecoverable(this.error)
|
||||
}
|
||||
}
|
||||
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.MatrixRoomPowerL
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
|
|
@ -764,7 +765,8 @@ fun aRoomInfo(
|
|||
userDefinedNotificationMode: RoomNotificationMode? = null,
|
||||
hasRoomCall: Boolean = false,
|
||||
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
|
||||
activeRoomCallParticipants: List<String> = emptyList()
|
||||
activeRoomCallParticipants: List<String> = emptyList(),
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
) = MatrixRoomInfo(
|
||||
id = id,
|
||||
name = name,
|
||||
|
|
@ -789,6 +791,7 @@ fun aRoomInfo(
|
|||
hasRoomCall = hasRoomCall,
|
||||
userPowerLevels = userPowerLevels,
|
||||
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
|
||||
heroes = heroes.toImmutableList(),
|
||||
)
|
||||
|
||||
fun defaultRoomPowerLevels() = MatrixRoomPowerLevels(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.message.RoomMessage
|
|||
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
|
|
@ -78,6 +79,7 @@ fun aRoomSummaryDetails(
|
|||
isDm: Boolean = false,
|
||||
isFavorite: Boolean = false,
|
||||
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
|
||||
heroes: List<MatrixUser> = emptyList(),
|
||||
) = RoomSummaryDetails(
|
||||
roomId = roomId,
|
||||
name = name,
|
||||
|
|
@ -95,6 +97,7 @@ fun aRoomSummaryDetails(
|
|||
isDm = isDm,
|
||||
isFavorite = isFavorite,
|
||||
currentUserMembership = currentUserMembership,
|
||||
heroes = heroes,
|
||||
)
|
||||
|
||||
fun aRoomMessage(
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ dependencies {
|
|||
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
|
|
@ -62,4 +63,5 @@ dependencies {
|
|||
testImplementation(libs.test.mockk)
|
||||
testImplementation(libs.test.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue