Merge branch 'develop' into fix/1612-redaction-reason

This commit is contained in:
hayaksi1
2026-09-03 03:02:35 +03:00
1121 changed files with 7527 additions and 3389 deletions
@@ -51,6 +51,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.captionOrNull
import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter
import io.element.android.features.roomcall.api.RoomCallState
@@ -396,7 +397,7 @@ class MessagesPresenter(
TimelineItemAction.Edit,
TimelineItemAction.EditPoll -> handleActionEdit(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.AddCaption -> handleActionAddCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState)
TimelineItemAction.EditCaption -> handleActionEditCaption(targetEvent, composerState, enableTextFormatting)
TimelineItemAction.RemoveCaption -> handleRemoveCaption(targetEvent)
TimelineItemAction.Reply -> handleActionReply(targetEvent, composerState, timelineProtectionState)
TimelineItemAction.ReplyInThread -> {
@@ -575,10 +576,15 @@ class MessagesPresenter(
private suspend fun handleActionEditCaption(
targetEvent: TimelineItem.Event,
composerState: MessageComposerState,
enableTextFormatting: Boolean,
) {
val composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = targetEvent.eventOrTransactionId,
content = targetEvent.content.captionOrNull().orEmpty(),
content = if (enableTextFormatting) {
targetEvent.content.htmlCaptionOrNull() ?: targetEvent.content.captionOrNull()
} else {
targetEvent.content.captionOrNull()
}.orEmpty(),
)
composerState.eventSink(
MessageComposerEvent.SetMode(composerMode)
@@ -115,9 +115,10 @@ class SuggestionsProcessor(
.filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
}
.sortedBy { member -> matchRank(member, query) }
.map(ResolvedSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) {
if ("room".startsWith(query, ignoreCase = true) && canSendRoomMention) {
listOf(ResolvedSuggestion.AtRoom) + matchingMembers
} else {
matchingMembers
@@ -125,6 +126,18 @@ class SuggestionsProcessor(
}
}
/**
* Members whose display name starts with the query come first, then members whose user id
* localpart starts with it, then the remaining matches on any other part of the name or id.
*/
private fun matchRank(member: RoomMember, query: String): Int {
return when {
member.displayName?.startsWith(query, ignoreCase = true) == true -> 0
member.userId.extractedDisplayName.startsWith(query, ignoreCase = true) -> 1
else -> 2
}
}
companion object {
// We don't want to retrieve thousands of members
private const val MAX_BATCH_ITEMS = 100
@@ -52,9 +52,9 @@ sealed interface TimelineEvent {
*/
sealed interface TimelineItemPollEvent : TimelineItemEvent
data class SelectPollAnswer(
data class SendPollResponse(
val pollStartId: EventId,
val answerId: String
val answerIds: List<String>
) : TimelineItemPollEvent
data class EndPoll(
@@ -201,13 +201,15 @@ class TimelinePresenter(
newEventState.value = NewEventState.None
}
}
is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch {
is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch {
timelineController.invokeOnCurrentTimeline {
sendPollResponseAction.execute(
timeline = this,
pollStartId = event.pollStartId,
answerId = event.answerId
)
answerIds = event.answerIds,
).onFailure { error ->
Timber.tag(tag).e(error, "Failed to send poll response")
}
}
}
is TimelineEvent.EndPoll -> sessionCoroutineScope.launch {
@@ -49,7 +49,9 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
@@ -217,13 +219,23 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
Column(modifier = modifier.fillMaxWidth()) {
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
val accessibilityActions = rememberTimelineItemAccessibilityActions(
canReply = canReply,
onLongClick = onLongClick,
onSwipeToReply = onSwipeToReply,
)
Column(
modifier = modifier
.fillMaxWidth()
.semantics { customActions = accessibilityActions }
) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
} else {
Spacer(modifier = Modifier.height(2.dp))
}
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.floatValue
@@ -319,6 +331,40 @@ fun TimelineItemEventRow(
}
}
/**
* Exposes the gestures of a timeline item — long press for the action list, swipe for a reply — as TalkBack actions, since neither gesture is reachable
* with a screen reader enabled.
*/
@Composable
private fun rememberTimelineItemAccessibilityActions(
canReply: Boolean,
onLongClick: () -> Unit,
onSwipeToReply: () -> Unit,
): List<CustomAccessibilityAction> {
val messageActionsLabel = stringResource(CommonStrings.common_message_actions)
val replyLabel = stringResource(CommonStrings.action_reply)
val latestOnLongClick by rememberUpdatedState(onLongClick)
val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply)
return remember(canReply, messageActionsLabel, replyLabel) {
buildList {
add(
CustomAccessibilityAction(messageActionsLabel) {
latestOnLongClick()
true
}
)
if (canReply) {
add(
CustomAccessibilityAction(replyLabel) {
latestOnSwipeToReply()
true
}
)
}
}
}
}
@Composable
private fun ThreadSummaryView(
threadSummary: ThreadSummary,
@@ -26,8 +26,8 @@ fun TimelineItemPollView(
eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit,
modifier: Modifier = Modifier,
) {
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId))
fun onSendPollResponse(pollStartId: EventId, answerIds: List<String>) {
eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds))
}
fun onEndPoll(pollStartId: EventId) {
@@ -43,10 +43,12 @@ fun TimelineItemPollView(
question = content.question,
answerItems = content.answerItems.toImmutableList(),
pollKind = content.pollKind,
isMultipleSelection = content.maxSelections > 1u,
maxSelections = content.maxSelections,
isPollEnded = content.isEnded,
isPollEditable = content.isEditable,
isMine = content.isMine,
onSelectAnswer = ::onSelectAnswer,
onSendPollResponse = ::onSendPollResponse,
onEditPoll = ::onEditPoll,
onEndPoll = ::onEndPoll,
modifier = modifier,
@@ -15,7 +15,9 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
@@ -23,6 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentPreviewParam
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
@@ -47,8 +50,13 @@ fun TimelineItemTextView(
// The View <-> Compose interop is not working well with Compose UI tests (it loops indefinitely), so we skip it in the UI test mode.
if (LocalUiTestMode.current) return
val emojiOnly = content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").containsOnlyEmojis()
val isInPreview = LocalInspectionMode.current
val emojiOnly = remember(content.body, content.formattedBody, isInPreview) {
content.formattedBody.toString() == content.body &&
content.body.replace(" ", "").let { body ->
if (isInPreview) body == AN_EMOJI_ONLY_TEXT else body.containsOnlyEmojis()
}
}
val textStyle = when {
emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
else -> ElementTheme.typography.fontBodyLgRegular
@@ -108,6 +108,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -132,6 +133,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -174,6 +176,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -198,6 +201,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -216,6 +220,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -235,6 +240,7 @@ class TimelineItemContentMessageFactory(
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
formattedCaption = formattedCaption,
htmlCaption = messageType.formattedCaption?.body,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -345,6 +351,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
items = galleryItems.toImmutableList(),
)
@@ -402,6 +409,7 @@ class TimelineItemContentMessageFactory(
body = messageType.body,
caption = messageType.body.trimEnd().takeIf { it.isNotEmpty() },
formattedCaption = formattedCaption,
htmlCaption = messageType.formatted?.body,
isEdited = content.isEdited,
attachments = attachments.toImmutableList(),
)
@@ -33,6 +33,7 @@ class TimelineItemContentPollFactory(
question = pollContentState.question,
answerItems = pollContentState.answerItems,
pollKind = pollContentState.pollKind,
maxSelections = pollContentState.maxSelections,
isEnded = pollContentState.isPollEnded,
isEdited = content.isEdited
)
@@ -28,10 +28,12 @@ import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.messages.reply.map
@@ -64,7 +66,7 @@ class TimelineItemEventFactory(
val currentSender = currentTimelineItem.event.sender
val groupPosition =
computeGroupPosition(currentTimelineItem, timelineItems, index)
val senderProfile = currentTimelineItem.event.senderProfile
val senderProfile = currentTimelineItem.event.senderProfile.orFallbackTo(roomMembers, currentSender)
val sentTime = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.TimeOnly,
@@ -138,11 +140,32 @@ class TimelineItemEventFactory(
roomMembers: List<RoomMember>,
renderReadReceipts: Boolean,
): TimelineItem.Event {
val senderProfile = receivedMatrixTimelineItem.event.senderProfile.orFallbackTo(roomMembers, timelineItem.senderId)
return timelineItem.copy(
senderProfile = senderProfile,
senderAvatar = timelineItem.senderAvatar.copy(
name = senderProfile.getDisambiguatedDisplayName(timelineItem.senderId),
url = senderProfile.getAvatarUrl(),
),
readReceiptState = receivedMatrixTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts)
)
}
/**
* The timeline only knows the profiles the SDK has resolved, so a sender it has nothing for is rendered as a raw
* user ID. The room member list usually knows that user, so use it rather than showing the ID.
*/
private fun ProfileDetails.orFallbackTo(roomMembers: List<RoomMember>, senderId: UserId): ProfileDetails {
if (this is ProfileDetails.Ready) return this
val member = roomMembers.find { it.userId == senderId } ?: return this
return ProfileDetails.Ready(
displayName = member.displayName,
displayNameAmbiguous = member.isNameAmbiguous,
avatarUrl = member.avatarUrl,
displayedStatus = member.displayedStatus,
)
}
private fun MatrixTimelineItem.Event.computeReactionsState(): TimelineItemReactions {
if (!config.computeReactions) {
return TimelineItemReactions(reactions = persistentListOf())
@@ -91,7 +91,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
// Can be grouped
is FailedToParseMessageLikeContent,
is MessageContent,
RedactedContent,
is RedactedContent,
is StickerContent,
is PollContent,
is UnableToDecryptContent,
@@ -12,6 +12,7 @@ import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UniqueId
@@ -99,7 +100,8 @@ internal const val MIN_REDACTED_RUN_SIZE = 3
* Fold runs of [MIN_REDACTED_RUN_SIZE] or more consecutive deleted (redacted) messages into a single
* [TimelineItem.GroupedEvents], so a long stretch of "Message removed" tiles shows as one expandable
* "N removed messages" line, like element-web. Shorter runs, and anything that is not a redacted event
* (day dividers included), are left untouched.
* (day dividers included), are left untouched. A deleted message that still heads a thread is left
* untouched too, so that its thread summary stays reachable.
*
* This runs as the final step of [TimelineItemGrouper.group], after the state and membership events
* have been grouped. It is deliberately kept out of the normal grouping: a run needs a higher minimum
@@ -134,7 +136,7 @@ internal fun List<TimelineItem>.collapseRedactedRuns(groupIds: MutableMap<String
}
for (item in this) {
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent) {
if (item is TimelineItem.Event && item.content is TimelineItemRedactedContent && item.threadInfo !is TimelineItemThreadInfo.ThreadRoot) {
run.add(item)
} else {
flushRun()
@@ -14,6 +14,7 @@ data class TimelineItemAttachmentsContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val attachments: ImmutableList<AttachmentItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -17,6 +17,7 @@ data class TimelineItemAudioContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -33,6 +33,9 @@ sealed interface TimelineItemEventContentWithAttachment :
val fileSize: Long?
val caption: String?
val formattedCaption: CharSequence?
/** The caption as it was written, in HTML, so that editing it can start from the formatted text. */
val htmlCaption: String?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
@@ -127,6 +130,13 @@ fun TimelineItemEventContent.formattedCaptionOrNull(): CharSequence? = when (thi
else -> null
}
fun TimelineItemEventContent.htmlCaptionOrNull(): String? = when (this) {
is TimelineItemEventContentWithAttachment -> htmlCaption
is TimelineItemGalleryContent -> htmlCaption
is TimelineItemAttachmentsContent -> htmlCaption
else -> null
}
fun TimelineItemEventContentWithAttachment.duration(): Duration? {
return when (this) {
is TimelineItemAudioContent -> duration
@@ -16,6 +16,7 @@ data class TimelineItemFileContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemGalleryContent(
val body: String,
val caption: String?,
val formattedCaption: CharSequence?,
val htmlCaption: String? = null,
override val isEdited: Boolean,
val items: ImmutableList<GalleryItem>,
) : TimelineItemEventContent, TimelineItemEventMutableContent {
@@ -19,6 +19,7 @@ data class TimelineItemImageContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -28,6 +28,7 @@ fun aTimelineItemImageContent(
blurhash: String? = A_BLUR_HASH,
filename: String = "A picture.jpg",
caption: String? = null,
htmlCaption: String? = null,
width: Int? = null,
height: Int? = 300,
) = TimelineItemImageContent(
@@ -35,6 +36,7 @@ fun aTimelineItemImageContent(
fileSize = 4 * 1024 * 1024L,
caption = caption,
formattedCaption = null,
htmlCaption = htmlCaption,
isEdited = false,
mediaSource = MediaSource(""),
thumbnailSource = null,
@@ -19,6 +19,7 @@ data class TimelineItemPollContent(
val question: String,
val answerItems: List<PollAnswerItem>,
val pollKind: PollKind,
val maxSelections: ULong,
val isEnded: Boolean,
override val isEdited: Boolean,
) : TimelineItemEventContent,
@@ -32,12 +32,14 @@ fun aTimelineItemPollContent(
isEditable: Boolean = false,
isEnded: Boolean = false,
isEdited: Boolean = false,
maxSelections: ULong = 1u,
): TimelineItemPollContent {
return TimelineItemPollContent(
eventId = EventId("\$anEventId"),
pollKind = PollKind.Disclosed,
question = question,
answerItems = answerItems,
maxSelections = maxSelections,
isMine = isMine,
isEditable = isEditable,
isEnded = isEnded,
@@ -15,6 +15,7 @@ data class TimelineItemStickerContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
@@ -16,6 +16,7 @@ data class TimelineItemVideoContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -19,6 +19,7 @@ data class TimelineItemVoiceContent(
override val fileSize: Long?,
override val caption: String?,
override val formattedCaption: CharSequence?,
override val htmlCaption: String? = null,
override val isEdited: Boolean,
val duration: Duration,
override val mediaSource: MediaSource,
@@ -8,25 +8,15 @@
package io.element.android.features.messages.impl.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalInspectionMode
import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
import com.sigpwned.emoji4j.core.GraphemeMatchResult
import com.sigpwned.emoji4j.core.GraphemeMatcher
import io.element.android.features.messages.impl.timeline.model.event.AN_EMOJI_ONLY_TEXT
/**
* Returns true if the string consists exclusively of "emoji or pictographic graphemes".
*/
@Composable
fun String.containsOnlyEmojis(): Boolean {
if (LocalInspectionMode.current) return this == AN_EMOJI_ONLY_TEXT
if (isEmpty()) return false
return containsOnlyEmojisInternal()
}
internal fun String.containsOnlyEmojisInternal(): Boolean {
internal fun String.containsOnlyEmojis(): Boolean {
val matcher = GraphemeMatcher(this)
var m: GraphemeMatchResult? = null
var contiguous = true
@@ -7,19 +7,37 @@
<string name="crypto_event_authenticity_unknown_device">"Verschlüsselt von einem unbekannten oder gelöschten Gerät."</string>
<string name="crypto_event_authenticity_unsigned_device">"Verschlüsselt durch ein Gerät, das nicht von seinem Besitzer verifiziert wurde."</string>
<string name="crypto_event_authenticity_unverified_identity">"Verschlüsselt durch einen nicht verifizierten Nutzer."</string>
<string name="error_sending_failure_invalid_mime_type">"Ungültiger MIME-Typ: %s"</string>
<string name="error_sending_failure_missing_media_content">"Fehlende Medieninhalte"</string>
<string name="error_sending_failure_other">"Fehlerdetails: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Senden von einem nicht verifizierten Gerät"</string>
<string name="error_sending_failure_unknown">"Unbekannter Fehler"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Bild horizontal spiegeln"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Horizontal gespiegelt"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Bild vertikal spiegeln"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Vertikal gespiegelt"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Drehe das Bild nach links"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d Grad"</item>
<item quantity="other">"%1$d Grad"</item>
</plurals>
<string name="screen_image_edition_title">"Foto bearbeiten"</string>
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tippe, um die Qualität des Video-Uploads zu ändern"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Die Datei konnte nicht hochgeladen werden."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Das Hochladen der Medien ist fehlgeschlagen. Bitte versuche es erneut."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Die maximal zulässige Dateigröße beträgt %1$s."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Die Datei ist zu groß zum Hochladen."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Die Datei ist zu groß zum Hochladen"</string>
<string name="screen_media_upload_preview_item_count">"%1$d von %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiere die Bildqualität"</string>
<string name="screen_media_upload_preview_processing">"Verarbeitung läuft …"</string>
<string name="screen_media_upload_preview_title">"Medien hinzufügen"</string>
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
<string name="screen_report_content_block_user_hint">"Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Nutzers ausblenden möchtest"</string>
<string name="screen_report_content_explanation">"Diese Nachricht wird dem Administrator deines Homeservers gemeldet. Dieser kann allerdings keine verschlüsselten Nachrichten lesen."</string>
<string name="screen_report_content_explanation">"Diese Nachricht wird an deinen Kontoanbieter gemeldet. Verschlüsselte Nachrichten kann dieser nicht lesen."</string>
<string name="screen_report_content_hint">"Grund für die Meldung dieses Inhalts"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Foto aufnehmen"</string>
@@ -29,7 +47,7 @@
<string name="screen_room_attachment_source_location">"Standort teilen"</string>
<string name="screen_room_attachment_source_poll">"Umfrage"</string>
<string name="screen_room_attachment_text_formatting">"Textformatierung"</string>
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist derzeit nicht verfügbar"</string>
<string name="screen_room_encrypted_history_banner">"Der Nachrichtenverlauf ist derzeit nicht verfügbar."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Der Nachrichtenverlauf ist nicht verfügbar. Verifiziere dieses Gerät, um deinen Nachrichtenverlauf zu sehen."</string>
<string name="screen_room_invite_again_alert_message">"Möchtest du sie wieder einladen?"</string>
<string name="screen_room_invite_again_alert_title">"Du bist allein in diesem Chat"</string>
@@ -57,6 +75,10 @@
<string name="screen_room_timeline_reactions_show_more">"Mehr anzeigen"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Zusammenfassung der Reaktionen anzeigen"</string>
<string name="screen_room_timeline_read_marker_title">"Neu"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d entfernte Nachricht"</item>
<item quantity="other">"%1$d entfernte Nachrichten"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d Änderung im Chat"</item>
<item quantity="other">"%1$d Änderungen im Chat"</item>
@@ -67,7 +89,7 @@
<string name="screen_room_timeline_upgraded_room_message">"Dieser Chat ist eine Fortsetzung eines anderen Chats"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="one">"%1$s, %2$s und %3$d weitere Person"</item>
<item quantity="other">"%1$s, %2$s und %3$d weitere Person"</item>
<item quantity="other">"%1$s, %2$s und %3$d weitere Personen"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="one">"%1$s schreibt…"</item>
@@ -19,7 +19,7 @@
<string name="screen_media_upload_preview_processing">"Επεξεργασία…"</string>
<string name="screen_report_content_block_user">"Αποκλεισμός χρήστη"</string>
<string name="screen_report_content_block_user_hint">"Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη"</string>
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον διαχειριστή του οικιακού διακομιστή σας. Δεν θα μπορεί να διαβάσει κρυπτογραφημένα μηνύματα."</string>
<string name="screen_report_content_explanation">"Αυτό το μήνυμα θα αναφερθεί στον πάροχο του λογαριασμού σας. Δεν θα μπορεί να διαβάσει κανένα κρυπτογραφημένο μήνυμα."</string>
<string name="screen_report_content_hint">"Λόγος αναφοράς αυτού του περιεχομένου"</string>
<string name="screen_room_attachment_source_camera">"Κάμερα"</string>
<string name="screen_room_attachment_source_camera_photo">"Τράβηξε φωτογραφία"</string>
@@ -7,6 +7,10 @@
<string name="crypto_event_authenticity_unknown_device">"Krüptitud tundmatu või kustutatud seadme poolt."</string>
<string name="crypto_event_authenticity_unsigned_device">"Krüptitud seadme poolt, mida tema omanik pole verifitseerinud."</string>
<string name="crypto_event_authenticity_unverified_identity">"Krüptitud verifitseerimata kasutaja poolt."</string>
<string name="error_sending_failure_invalid_mime_type">"Vigane MIME-tüüp: %s"</string>
<string name="error_sending_failure_other">"Vea üksikasjad: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Saadetakse verifitseerimata seadmest"</string>
<string name="error_sending_failure_unknown">"Tundmatu viga"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Pööra pilti ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Pilt on pööratud ümber rõhttelje"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Pildi algne variant"</string>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Chiffré par un appareil inconnu ou supprimé."</string>
<string name="crypto_event_authenticity_unsigned_device">"Chiffré par un appareil non vérifié par son propriétaire."</string>
<string name="crypto_event_authenticity_unverified_identity">"Chiffré par un utilisateur non vérifié."</string>
<string name="error_sending_failure_invalid_mime_type">"Type MIME invalide : %s"</string>
<string name="error_sending_failure_missing_media_content">"Contenu multimédia manquant"</string>
<string name="error_sending_failure_other">"Détails de l’erreur : %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Envoi depuis un appareil non vérifié"</string>
<string name="error_sending_failure_unknown">"Erreur inconnue"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Šifrirano nepoznatim ili izbrisanim uređajem."</string>
<string name="crypto_event_authenticity_unsigned_device">"Šifrirano uređajem koji vlasnik nije potvrdio."</string>
<string name="crypto_event_authenticity_unverified_identity">"Šifrirao neprovjereni korisnik."</string>
<string name="error_sending_failure_invalid_mime_type">"Nevažeći MIME tip: %s"</string>
<string name="error_sending_failure_missing_media_content">"Nedostaje medijski sadržaj"</string>
<string name="error_sending_failure_other">"Detalji pogreške: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Slanje s neprovjerenog uređaja"</string>
<string name="error_sending_failure_unknown">"Nepoznata pogreška"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Okreni sliku vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Okrenuto vodoravno"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Izvornik"</string>
@@ -7,6 +7,17 @@
<string name="crypto_event_authenticity_unknown_device">"Dienkripsi oleh perangkat yang tidak dikenal atau dihapus."</string>
<string name="crypto_event_authenticity_unsigned_device">"Dienkripsi oleh perangkat yang tidak diverifikasi oleh pemiliknya."</string>
<string name="crypto_event_authenticity_unverified_identity">"Dienkripsi oleh pengguna yang tidak terverifikasi."</string>
<string name="error_sending_failure_invalid_mime_type">"Tipe MIME tidak valid: %s"</string>
<string name="error_sending_failure_missing_media_content">"Konten media tidak ditemukan"</string>
<string name="error_sending_failure_other">"Detail kesalahan: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Mengirim dari perangkat yang belum terverifikasi"</string>
<string name="error_sending_failure_unknown">"Kesalahan tidak diketahui"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Balikkan gambar secara horizontal"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Dibalik secara horizontal"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Asli"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Balikkan gambar secara vertikal"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Dibalik secara vertikal"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Asli"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Putar gambar ke kiri"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d derajat"</item>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Cifrato da un dispositivo sconosciuto o eliminato."</string>
<string name="crypto_event_authenticity_unsigned_device">"Cifrato da un dispositivo non verificato dal proprietario."</string>
<string name="crypto_event_authenticity_unverified_identity">"Cifrato da un utente non verificato."</string>
<string name="error_sending_failure_invalid_mime_type">"Tipo MIME non valido: %s"</string>
<string name="error_sending_failure_missing_media_content">"Contenuto multimediale mancante"</string>
<string name="error_sending_failure_other">"Dettagli dell\'errore: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Invio da dispositivo non verificato"</string>
<string name="error_sending_failure_unknown">"Errore sconosciuto"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Capovolgi l\'immagine orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Capovolto orizzontalmente"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
@@ -32,7 +37,7 @@
<string name="screen_media_upload_preview_title">"Aggiungi contenuti multimediali"</string>
<string name="screen_report_content_block_user">"Blocca utente"</string>
<string name="screen_report_content_block_user_hint">"Seleziona se vuoi nascondere tutti i messaggi attuali e futuri di questo utente"</string>
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato all\'amministratore dell\'homeserver. Questi non sarà in grado di leggere i messaggi cifrati."</string>
<string name="screen_report_content_explanation">"Questo messaggio verrà segnalato al gestore del tuo account. Quest’ultimo non potrà leggere alcun messaggio cifrato."</string>
<string name="screen_report_content_hint">"Motivo della segnalazione di questo contenuto"</string>
<string name="screen_room_attachment_source_camera">"Fotocamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Scatta foto"</string>
@@ -70,6 +75,10 @@
<string name="screen_room_timeline_reactions_show_more">"Mostra di più"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Mostra il riepilogo delle reazioni"</string>
<string name="screen_room_timeline_read_marker_title">"Nuovo"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d messaggio rimosso"</item>
<item quantity="other">"%1$d messaggi rimossi"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d modifica alla stanza"</item>
<item quantity="other">"%1$d modifiche alla stanza"</item>
@@ -67,6 +67,9 @@
<string name="screen_room_timeline_reactions_show_more">"さらに表示"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"リアクションのまとめを表示"</string>
<string name="screen_room_timeline_read_marker_title">"新着"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="other">"%1$d 件の削除されたメッセージ"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="other">"%1$d 個のルーム更新点"</item>
</plurals>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Zaszyfrowana przez nieznane lub usunięte urządzenie."</string>
<string name="crypto_event_authenticity_unsigned_device">"Zaszyfrowana przez urządzenie niezweryfikowane przez jego właściciela."</string>
<string name="crypto_event_authenticity_unverified_identity">"Zaszyfrowana przez niezweryfikowanego użytkownika."</string>
<string name="error_sending_failure_invalid_mime_type">"Nieprawidłowy typ MIME: %s"</string>
<string name="error_sending_failure_missing_media_content">"Brak treści multimedialnych"</string>
<string name="error_sending_failure_other">"Szczegóły błędu: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Wysyłanie z niezweryfikowanego urządzenia"</string>
<string name="error_sending_failure_unknown">"Nieznany błąd"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Odwróć obraz w poziomie"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Odwrócono w poziomie"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Oryginalny"</string>
@@ -33,7 +38,7 @@
<string name="screen_media_upload_preview_title">"Dodaj media"</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_explanation">"Wiadomość zostanie zgłoszona do Twojego dostawcy konta. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Zrób zdjęcie"</string>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string>
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не звірено його власником."</string>
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано не звіреним користувачем."</string>
<string name="error_sending_failure_invalid_mime_type">"Недійсний тип MIME: %s"</string>
<string name="error_sending_failure_missing_media_content">"Відсутній медіавміст"</string>
<string name="error_sending_failure_other">"Подробиці помилки: %s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"Надсилання з незвіреного пристрою"</string>
<string name="error_sending_failure_unknown">"Невідома помилка"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Повернути зображення по горизонталі"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонталі"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригінал"</string>
@@ -33,7 +38,7 @@
<string name="screen_media_upload_preview_title">"Додати медіа"</string>
<string name="screen_report_content_block_user">"Заблокувати користувача"</string>
<string name="screen_report_content_block_user_hint">"Перевірте, чи хочете ви приховати всі поточні та майбутні повідомлення від цього користувача"</string>
<string name="screen_report_content_explanation">"Це повідомлення буде надіслано адміністраторам вашого домашнього сервера. Вони не зможуть прочитати зашифровані повідомлення."</string>
<string name="screen_report_content_explanation">"Це повідомлення буде передано постачальнику вашого облікового запису. Він не зможе прочитати зашифрованих повідомлень."</string>
<string name="screen_report_content_hint">"Причина скарги на цей вміст"</string>
<string name="screen_room_attachment_source_camera">"Камера"</string>
<string name="screen_room_attachment_source_camera_photo">"Зробити фото"</string>
@@ -7,6 +7,11 @@
<string name="crypto_event_authenticity_unknown_device">"由未知或已删除的设备加密。"</string>
<string name="crypto_event_authenticity_unsigned_device">"由未经其所有者验证的设备加密。"</string>
<string name="crypto_event_authenticity_unverified_identity">"由未经验证的用户加密。"</string>
<string name="error_sending_failure_invalid_mime_type">"无效的 MIME 类型:%s"</string>
<string name="error_sending_failure_missing_media_content">"媒体内容丢失"</string>
<string name="error_sending_failure_other">"错误详细信息:%s"</string>
<string name="error_sending_failure_sending_from_unverified_device">"由未经验证的设备发送"</string>
<string name="error_sending_failure_unknown">"未知错误"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻转图像"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻转"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始"</string>
@@ -31,7 +36,7 @@
<string name="screen_media_upload_preview_title">"添加媒体"</string>
<string name="screen_report_content_block_user">"屏蔽用户"</string>
<string name="screen_report_content_block_user_hint">"请确认是否要隐藏该用户当前和未来的所有消息"</string>
<string name="screen_report_content_explanation">"此消息将举报给服务器管理员。他们无法读取任何加密消息。"</string>
<string name="screen_report_content_explanation">"此消息将举报给账户提供者。他们无法读取任何加密消息。"</string>
<string name="screen_report_content_hint">"举报此内容的理由"</string>
<string name="screen_room_attachment_source_camera">"相机"</string>
<string name="screen_room_attachment_source_camera_photo">"拍摄照片"</string>
@@ -37,7 +37,7 @@
<string name="screen_media_upload_preview_title">"Add media"</string>
<string name="screen_report_content_block_user">"Block user"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_report_content_explanation">"This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages."</string>
<string name="screen_report_content_explanation">"This message will be reported to your account provider. They will not be able to read any encrypted messages."</string>
<string name="screen_report_content_hint">"Reason for reporting this content"</string>
<string name="screen_room_attachment_source_camera">"Camera"</string>
<string name="screen_room_attachment_source_camera_photo">"Take photo"</string>
@@ -1065,6 +1065,33 @@ class MessagesPresenterTest {
}
}
@Test
fun `present - handle action edit caption starts from the formatted caption`() = runTest {
val messageEvent = aMessageEvent(
content = aTimelineItemImageContent(
caption = "Hello world",
htmlCaption = "<b>Hello</b> world",
)
)
val composerRecorder = EventsRecorder<MessageComposerEvent>()
val presenter = createMessagesPresenter(
messageComposerPresenter = { aMessageComposerState(eventSink = composerRecorder, showTextFormatting = true) },
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.EditCaption, messageEvent))
awaitItem()
composerRecorder.assertSingle(
MessageComposerEvent.SetMode(
composerMode = MessageComposerMode.EditCaption(
eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(),
content = "<b>Hello</b> world",
)
)
)
}
}
@Test
fun `present - handle action add caption`() = runTest {
val composerRecorder = EventsRecorder<MessageComposerEvent>()
@@ -383,6 +383,48 @@ class SuggestionsProcessorTest {
)
}
@Test
fun `processing Mention suggestion returns the members matching the start of the query first`() = runTest {
val matchingOnAnyPart = aRoomMember(userId = UserId("@carol:server.org"), displayName = "Not Alice")
val matchingOnUserIdStart = aRoomMember(userId = UserId("@alicia:server.org"), displayName = null)
val matchingOnDisplayNameStart = aRoomMember(userId = UserId("@zoe:server.org"), displayName = "Alice")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ali"),
roomMembersState = RoomMembersState.Ready(
persistentListOf(matchingOnAnyPart, matchingOnUserIdStart, matchingOnDisplayNameStart)
),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { false },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Member(matchingOnDisplayNameStart),
ResolvedSuggestion.Member(matchingOnUserIdStart),
ResolvedSuggestion.Member(matchingOnAnyPart),
)
)
}
@Test
fun `processing Mention suggestion returns room ignoring the case of the query`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "alice")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("RO"),
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.AtRoom,
)
)
}
@Test
fun `processing Mention suggestion with return matching display name but not room if not allowed`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")
@@ -51,7 +51,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -69,6 +68,7 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -880,7 +880,7 @@ class TimelinePresenterTest {
(0 until 3).map { index ->
MatrixTimelineItem.Event(
uniqueId = UniqueId("redacted_$index"),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = RedactedContent),
event = anEventTimelineItem(eventId = EventId("\$R$index"), content = aRedactedContent()),
)
}
),
@@ -951,14 +951,14 @@ class TimelinePresenterTest {
}
@Test
fun `present - PollAnswerSelected event`() = runTest {
fun `present - SendPollResponse event`() = runTest {
val sendPollResponseAction = FakeSendPollResponseAction()
val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction,
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId")))
}
delay(1)
sendPollResponseAction.verifyExecutionCount(1)
@@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() {
matcher = hasText(answer.text),
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id))
eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id)))
}
@Test
@@ -12,12 +12,21 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.test.A_THREAD_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.room.aRoomMember
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -84,6 +93,42 @@ class TimelineItemsFactoryTest {
).inOrder()
}
@Test
fun `redacted message keeps its thread info`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
timestamp = 0L,
content = aRedactedContent(
threadInfo = EventThreadInfo.ThreadResponse(A_THREAD_ID),
),
),
)
)
var threadInfo: TimelineItemThreadInfo? = null
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
threadInfo = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.firstOrNull()
?.threadInfo
cancelAndIgnoreRemainingEvents()
}
assertThat(threadInfo).isEqualTo(TimelineItemThreadInfo.ThreadResponse(A_THREAD_ID))
}
@Test
fun `a key verification request is not rendered`() = runTest {
val factory = aTimelineItemsFactory(
@@ -128,6 +173,111 @@ class TimelineItemsFactoryTest {
}
}
@Test
fun `a deleted message keeps the summary of the thread it heads`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
content = aRedactedContent(
threadInfo = EventThreadInfo.ThreadRoot(
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 3),
),
),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = emptyList(),
renderReadReceipts = false,
)
val threadInfo = awaitItem()
.filterIsInstance<TimelineItem.Event>()
.single()
.threadInfo
assertThat(threadInfo).isInstanceOf(TimelineItemThreadInfo.ThreadRoot::class.java)
assertThat((threadInfo as TimelineItemThreadInfo.ThreadRoot).summary.numberOfReplies).isEqualTo(3)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a sender the timeline knows nothing about is named from the room member list`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
content = aMessageContent(body = "A regular message"),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = listOf(aRoomMember(userId = A_USER_ID, displayName = "Alice")),
renderReadReceipts = false,
)
val event = awaitItem().filterIsInstance<TimelineItem.Event>().single()
assertThat(event.senderProfile).isEqualTo(
ProfileDetails.Ready(
displayName = "Alice",
displayNameAmbiguous = false,
avatarUrl = null,
displayedStatus = null,
)
)
assertThat(event.senderAvatar.name).isEqualTo("Alice")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a sender the room member list knows nothing about keeps the profile the timeline gave`() = runTest {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
computeReadReceipts = false,
computeReactions = false,
)
)
val items = listOf(
MatrixTimelineItem.Event(
uniqueId = UniqueId("event-0"),
event = anEventTimelineItem(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
content = aMessageContent(body = "A regular message"),
),
),
)
factory.timelineItems.test {
factory.replaceWith(
timelineItems = items,
roomMembers = listOf(aRoomMember(userId = A_USER_ID_2, displayName = "Bob")),
renderReadReceipts = false,
)
val event = awaitItem().filterIsInstance<TimelineItem.Event>().single()
assertThat(event.senderProfile).isEqualTo(ProfileDetails.Unavailable)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun TestScope.groupPositionsOf(timestamps: List<Long>): List<TimelineItemGroupPosition> {
val factory = aTimelineItemsFactory(
config = TimelineItemsFactoryConfig(
@@ -229,6 +229,25 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body"))
}
@Test
fun `test create TextMessageType with a formatted body containing only an image falls back to the body`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(
type = TextMessageType(
body = "\uD83D\uDE1C",
formatted = FormattedBody(MessageFormat.HTML, "<img src=\"mxc://matrix.org/anImage\" alt=\"\uD83D\uDE1C\" />")
)
),
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("\uD83D\uDE1C"))
assertThat(result.htmlDocument).isNull()
assertThat(result.plainText).isEqualTo("\uD83D\uDE1C")
}
@Test
fun `test create VideoMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@@ -297,6 +316,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 555L,
caption = "body.mp4 caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
@@ -539,6 +559,7 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() {
fileSize = 888L,
caption = "body.jpg caption",
formattedCaption = SpannedString("formatted"),
htmlCaption = "formatted",
isEdited = true,
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
@@ -15,11 +15,14 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.ReadReceiptData
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@@ -190,6 +193,22 @@ class TimelineItemGrouperTest {
)
}
@Test
fun `a deleted message that still heads a thread is left out of the removed messages group`() {
val threadRoot = aGroupableItem.copy(
id = UniqueId("r1"),
content = TimelineItemRedactedContent,
threadInfo = TimelineItemThreadInfo.ThreadRoot(
summary = ThreadSummary(latestEvent = AsyncData.Uninitialized, numberOfReplies = 2),
latestEventText = null,
),
)
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
val r2 = aGroupableItem.copy(id = UniqueId("r2"), content = TimelineItemRedactedContent)
val result = sut.group(listOf(r0, threadRoot, r2))
assertThat(result).isEqualTo(listOf(r0, threadRoot, r2))
}
@Test
fun `a run of fewer than three redacted events is left untouched`() {
val r0 = aGroupableItem.copy(id = UniqueId("r0"), content = TimelineItemRedactedContent)
@@ -16,23 +16,23 @@ class EmojiTest {
@Test
fun validEmojis() {
// Simple single/multiple single-codepoint emojis per string
assertTrue("👍".containsOnlyEmojisInternal())
assertTrue("😀".containsOnlyEmojisInternal())
assertTrue("🙂🙁".containsOnlyEmojisInternal())
assertTrue("👁❤️🍝".containsOnlyEmojisInternal()) // 👁 is a pictographic
assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojisInternal())
assertTrue("🌍🌎🌏".containsOnlyEmojisInternal())
assertTrue("👍".containsOnlyEmojis())
assertTrue("😀".containsOnlyEmojis())
assertTrue("🙂🙁".containsOnlyEmojis())
assertTrue("👁❤️🍝".containsOnlyEmojis()) // 👁 is a pictographic
assertTrue("👨‍👩‍👦1️⃣🚀👳🏾‍♂️🪩".containsOnlyEmojis())
assertTrue("🌍🌎🌏".containsOnlyEmojis())
// Awkward multi-codepoint graphemes
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojisInternal())
assertTrue("🏴‍☠".containsOnlyEmojisInternal())
assertTrue("👩🏿‍🔧".containsOnlyEmojisInternal())
assertTrue("🧑‍🧑‍🧒‍🧒".containsOnlyEmojis())
assertTrue("🏴‍☠".containsOnlyEmojis())
assertTrue("👩🏿‍🔧".containsOnlyEmojis())
Assert.assertFalse("".containsOnlyEmojisInternal())
Assert.assertFalse(" ".containsOnlyEmojisInternal())
Assert.assertFalse("🙂 🙁".containsOnlyEmojisInternal())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojisInternal())
Assert.assertFalse("Hello".containsOnlyEmojisInternal())
Assert.assertFalse("Hello 👋".containsOnlyEmojisInternal())
Assert.assertFalse("".containsOnlyEmojis())
Assert.assertFalse(" ".containsOnlyEmojis())
Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
Assert.assertFalse("Hello".containsOnlyEmojis())
Assert.assertFalse("Hello 👋".containsOnlyEmojis())
}
}
@@ -16,11 +16,11 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.FakeSendHandle
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -88,7 +88,7 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf<MatrixTimelineItem>(
sender = A_USER_ID,
senderProfile = ProfileDetails.Unavailable,
timestamp = 9442,
content = RedactedContent,
content = aRedactedContent(),
origin = null,
timelineItemDebugInfoProvider = {
TimelineItemDebugInfo(