Merge branch 'develop' into feature/fga/draft_support
This commit is contained in:
commit
1b56d1b97a
485 changed files with 2939 additions and 1591 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue