diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index c95545725d6..4c0151b3411 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -35,6 +35,8 @@ import com.wire.android.ui.home.conversations.MessageSharedState import com.wire.android.ui.home.messagecomposer.location.LocationPickerParameters import com.wire.android.util.dispatchers.DefaultDispatcherProvider import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.AndroidUiTextResolver +import com.wire.android.util.ui.UiTextResolver import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -68,6 +70,11 @@ object AppModule { @Provides fun provideMessageResourceProvider(): MessageResourceProvider = MessageResourceProvider() + @Singleton + @Provides + fun provideUiTextResolver(@ApplicationContext appContext: Context): UiTextResolver = + AndroidUiTextResolver(appContext) + @Provides fun provideNotificationManagerCompat(appContext: Context): NotificationManagerCompat = NotificationManagerCompat.from(appContext) diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index 537bc16ceab..b46424ed272 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -29,6 +29,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.PlayingAudioInConversation import com.wire.android.ui.home.conversationslist.showLegalHoldIndicator +import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Connection import com.wire.kalium.logic.data.conversation.ConversationDetails.Group @@ -45,6 +46,7 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus @Suppress("LongMethod") fun ConversationDetailsWithEvents.toConversationItem( userTypeMapper: UserTypeMapper, + uiTextResolver: UiTextResolver, searchQuery: String, selfUserTeamId: TeamId?, playingAudioMessage: PlayingAudioMessage @@ -55,7 +57,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), - lastMessageContent = lastMessage.toUIPreview(unreadEventCount), + lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, unreadEventCount = unreadEventCount @@ -82,7 +84,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), - lastMessageContent = lastMessage.toUIPreview(unreadEventCount), + lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, unreadEventCount = unreadEventCount @@ -120,7 +122,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, showLegalHoldIndicator = conversationDetails.conversation.legalHoldStatus.showLegalHoldIndicator(), - lastMessageContent = lastMessage.toUIPreview(unreadEventCount), + lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), badgeEventType = parsePrivateConversationEventType( conversationDetails.otherUser.connectionStatus, conversationDetails.otherUser.deleted, diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt index 335b016d609..78e660cb6a7 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt @@ -26,6 +26,7 @@ import com.wire.android.ui.markdown.MarkdownPreview import com.wire.android.ui.markdown.getFirstInlines import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.UiTextResolver import com.wire.android.util.ui.toUIText import com.wire.kalium.logic.data.conversation.UnreadEventCount import com.wire.kalium.logic.data.message.AssetType @@ -36,24 +37,29 @@ import com.wire.kalium.logic.data.message.MessagePreviewContent.WithUser import com.wire.kalium.logic.data.message.UnreadEventType @Suppress("ReturnCount") -fun MessagePreview?.toUIPreview(unreadEventCount: UnreadEventCount): UILastMessageContent { +fun MessagePreview?.toUIPreview( + unreadEventCount: UnreadEventCount, + uiTextResolver: UiTextResolver +): UILastMessageContent { if (this == null) { return UILastMessageContent.None } return when { // when unread event count is empty show last message - unreadEventCount.isEmpty() -> uiLastMessageContent() + unreadEventCount.isEmpty() -> uiLastMessageContent(uiTextResolver) // when there are only unread message events also show last message - unreadEventCount.size == 1 && unreadEventCount.keys.first() == UnreadEventType.MESSAGE -> uiLastMessageContent() + unreadEventCount.size == 1 && unreadEventCount.keys.first() == UnreadEventType.MESSAGE -> + uiLastMessageContent(uiTextResolver) // for the one type events show last message only where their count equals one - unreadEventCount.size == 1 && unreadEventCount.values.first() == 1 -> uiLastMessageContent() + unreadEventCount.size == 1 && unreadEventCount.values.first() == 1 -> + uiLastMessageContent(uiTextResolver) // for the rest take 1 or 2 most prioritized events with count to last message - else -> multipleUnreadEventsToLastMessage(unreadEventCount) + else -> multipleUnreadEventsToLastMessage(unreadEventCount, uiTextResolver) } } -private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount): UILastMessageContent { +private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount, uiTextResolver: UiTextResolver): UILastMessageContent { val unreadContentTexts = unreadEventCount .toSortedMap() .mapNotNull { type -> @@ -98,7 +104,14 @@ private fun multipleUnreadEventsToLastMessage(unreadEventCount: UnreadEventCount val second = unreadContentTexts.values.elementAt(1) UILastMessageContent.MultipleMessage(listOf(first, second)) } else { - UILastMessageContent.TextMessage(MessageBody(first), markdownPreview = first.toMarkdownPreviewOrNull()) + UILastMessageContent.TextMessage( + MessageBody( + message = first, + markdownDocument = (first as? UIText.DynamicString)?.value?.toMarkdownDocument() + ), + markdownPreview = first.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) } } @@ -109,7 +122,7 @@ private fun String?.userUiText(isSelfMessage: Boolean): UIText = when { } @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth") -fun MessagePreview.uiLastMessageContent(): UILastMessageContent { +fun MessagePreview.uiLastMessageContent(uiTextResolver: UiTextResolver): UILastMessageContent { return when (content) { is WithUser -> { val userContent = (content as WithUser) @@ -117,99 +130,141 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { when ((userContent)) { is WithUser.Asset -> when ((content as WithUser.Asset).type) { AssetType.AUDIO -> - UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource(R.string.last_message_self_user_shared_audio), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + UIText.StringResource(R.string.last_message_self_user_shared_audio).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } AssetType.IMAGE -> - UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_shared_image - } else { - R.string.last_message_other_user_shared_image - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_shared_image + } else { + R.string.last_message_other_user_shared_image + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } AssetType.VIDEO -> - UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_shared_video - } else { - R.string.last_message_other_user_shared_video - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_shared_video + } else { + R.string.last_message_other_user_shared_video + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } AssetType.GENERIC_ASSET -> - UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_shared_asset - } else { - R.string.last_message_other_user_shared_asset - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_shared_asset + } else { + R.string.last_message_other_user_shared_asset + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } } - is WithUser.ConversationNameChange -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_changed_conversation_name - } else { - R.string.last_message_other_changed_conversation_name - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + is WithUser.ConversationNameChange -> UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_changed_conversation_name + } else { + R.string.last_message_other_changed_conversation_name + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } - is WithUser.Knock -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_knock - } else { - R.string.last_message_other_user_knock - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + is WithUser.Knock -> UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_knock + } else { + R.string.last_message_other_user_knock + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } - is WithUser.MemberJoined -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_joined_conversation - } else { - R.string.last_message_other_user_joined_conversation - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + is WithUser.MemberJoined -> UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_joined_conversation + } else { + R.string.last_message_other_user_joined_conversation + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } - is WithUser.MemberLeft -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_left_conversation - } else { - R.string.last_message_other_user_left_conversation - } - ), - markdownPreview = userUIText.toMarkdownPreviewOrNull() - ) + is WithUser.MemberLeft -> UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_left_conversation + } else { + R.string.last_message_other_user_left_conversation + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } + + is WithUser.MentionedSelf -> UIText.StringResource(R.string.last_message_mentioned).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } + + is WithUser.QuotedSelf -> UIText.StringResource(R.string.last_message_replied).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } is WithUser.MembersAdded -> { val membersAddedContent = (content as WithUser.MembersAdded) @@ -234,7 +289,14 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { } } - UILastMessageContent.TextMessage(MessageBody(previewMessageContent), previewMessageContent.toMarkdownPreviewOrNull()) + UILastMessageContent.TextMessage( + MessageBody( + message = previewMessageContent, + markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() + ), + previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), + uiTextResolver.localeTag() + ) } is WithUser.ConversationMembersRemoved -> { @@ -264,7 +326,14 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { } } - UILastMessageContent.TextMessage(MessageBody(previewMessageContent), previewMessageContent.toMarkdownPreviewOrNull()) + UILastMessageContent.TextMessage( + MessageBody( + message = previewMessageContent, + markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() + ), + previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), + uiTextResolver.localeTag() + ) } is WithUser.TeamMembersRemoved -> { @@ -272,24 +341,24 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { val previewMessageContent = UIText.PluralResource(R.plurals.last_message_team_member_removed, teamMembersRemovedContent.otherUserIdList.size) - UILastMessageContent.TextMessage(MessageBody(previewMessageContent), previewMessageContent.toMarkdownPreviewOrNull()) + UILastMessageContent.TextMessage( + MessageBody( + message = previewMessageContent, + markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() + ), + previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), + uiTextResolver.localeTag() + ) } - is WithUser.MentionedSelf -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource(R.string.last_message_mentioned) - ) - - is WithUser.QuotedSelf -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource(R.string.last_message_replied) - ) - is WithUser.TeamMemberRemoved -> UILastMessageContent.None is WithUser.Text -> UILastMessageContent.SenderWithMessage( sender = userUIText, message = (content as WithUser.Text).messageBody.let { UIText.DynamicString(it) }, - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", + markdownPreview = UIText.DynamicString((content as WithUser.Text).messageBody) + .toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() ) is WithUser.Composite -> { @@ -298,7 +367,9 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { UILastMessageContent.SenderWithMessage( sender = userUIText, message = text, - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", + markdownPreview = text.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() ) } @@ -308,22 +379,30 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { is WithUser.MembersCreationAdded -> UILastMessageContent.None is WithUser.MembersFailedToAdd -> UILastMessageContent.None - is WithUser.Location -> UILastMessageContent.SenderWithMessage( - userUIText, - UIText.StringResource( - if (isSelfMessage) { - R.string.last_message_self_user_shared_location - } else { - R.string.last_message_other_user_shared_location - } + is WithUser.Location -> UIText.StringResource( + if (isSelfMessage) { + R.string.last_message_self_user_shared_location + } else { + R.string.last_message_other_user_shared_location + } + ).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() ) - ) + } - is WithUser.Deleted -> UILastMessageContent.SenderWithMessage( - sender = userUIText, - message = UIText.StringResource(R.string.deleted_message_text), - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" - ) + is WithUser.Deleted -> UIText.StringResource(R.string.deleted_message_text).let { message -> + UILastMessageContent.SenderWithMessage( + userUIText, + message, + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", + markdownPreview = message.toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() + ) + } } } @@ -350,7 +429,14 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { } } - UILastMessageContent.TextMessage(MessageBody(previewMessageContent), previewMessageContent.toMarkdownPreviewOrNull()) + UILastMessageContent.TextMessage( + MessageBody( + message = previewMessageContent, + markdownDocument = (previewMessageContent as? UIText.DynamicString)?.value?.toMarkdownDocument() + ), + previewMessageContent.toMarkdownPreviewOrNull(uiTextResolver), + uiTextResolver.localeTag() + ) } is MessagePreviewContent.Ephemeral -> { @@ -382,15 +468,17 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { is MessagePreviewContent.Draft -> UILastMessageContent.SenderWithMessage( UIText.StringResource(R.string.label_draft), (content as MessagePreviewContent.Draft).message.toUIText(), - separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}", + markdownPreview = (content as MessagePreviewContent.Draft).message.toUIText() + .toMarkdownPreviewOrNull(uiTextResolver), + markdownLocaleTag = uiTextResolver.localeTag() ) Unknown -> UILastMessageContent.None } } -private fun UIText.toMarkdownPreviewOrNull(): MarkdownPreview? = - when (this) { - is UIText.DynamicString -> value.toMarkdownDocument().getFirstInlines() - else -> null - } +private fun UIText.toMarkdownPreviewOrNull(uiTextResolver: UiTextResolver): MarkdownPreview? { + val resolved = uiTextResolver.resolve(this) + return resolved.toMarkdownDocument().getFirstInlines() +} diff --git a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt index 0af2e11fca6..222de95d683 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/RegularMessageContentMapper.kt @@ -28,6 +28,8 @@ import com.wire.android.ui.home.conversations.model.MessageButton import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.home.conversations.model.messagetypes.image.VisualMediaParams +import com.wire.android.ui.markdown.toMarkdownDocument +import com.wire.android.ui.markdown.toMarkdownTextWithMentions import com.wire.android.ui.theme.Accent import com.wire.android.util.getVideoMetaData import com.wire.android.util.time.ISOFormatter @@ -109,7 +111,11 @@ class RegularMessageMapper @Inject constructor( MessageBody( message = UIText.DynamicString(textContent.value, content.textContent?.mentions.orEmpty()), - quotedMessage = quotedMessage + quotedMessage = quotedMessage, + markdownDocument = UIText.DynamicString( + textContent.value, + content.textContent?.mentions.orEmpty() + ).toMarkdownTextWithMentions().second.toMarkdownDocument() ) } @@ -177,9 +183,8 @@ class RegularMessageMapper @Inject constructor( null } - return MessageBody( - when (content) { - is MessageContent.Text -> UIText.DynamicString(content.value, content.mentions) + val uiText = when (content) { + is MessageContent.Text -> UIText.DynamicString(content.value, content.mentions) is MessageContent.Unknown -> content.typeName?.let { UIText.StringResource( messageResourceProvider.sentAMessageWithContent, it @@ -193,8 +198,18 @@ class RegularMessageMapper @Inject constructor( } else -> UIText.StringResource(R.string.sent_a_message_with_unknown_content) - }, - quotedMessage = quotedMessage + } + + val markdownDocument = if (uiText is UIText.DynamicString) { + uiText.toMarkdownTextWithMentions().second.toMarkdownDocument() + } else { + null + } + + return MessageBody( + message = uiText, + quotedMessage = quotedMessage, + markdownDocument = markdownDocument ).let { messageBody -> UIMessageContent.TextMessage( messageBody = messageBody, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index c69878c51e6..64ce3f1a9f5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -66,12 +66,12 @@ import com.wire.android.ui.home.conversations.model.messagetypes.image.ImageMess import com.wire.android.ui.home.conversations.model.messagetypes.image.VisualMediaParams import com.wire.android.ui.home.conversations.model.messagetypes.image.size import com.wire.android.ui.markdown.DisplayMention -import com.wire.android.ui.markdown.MarkdownConstants.MENTION_MARK import com.wire.android.ui.markdown.MarkdownDocument import com.wire.android.ui.markdown.MessageColors import com.wire.android.ui.markdown.NodeActions import com.wire.android.ui.markdown.NodeData import com.wire.android.ui.markdown.toMarkdownDocument +import com.wire.android.ui.markdown.toMarkdownTextWithMentions import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @@ -138,9 +138,7 @@ internal fun MessageBody( accent = accent ) - val markdownDocument = remember(text) { - text?.toMarkdownDocument() - } + val markdownDocument = messageBody?.markdownDocument ?: remember(text) { text?.toMarkdownDocument() } markdownDocument?.also { MarkdownDocument( @@ -391,28 +389,7 @@ fun MediaAssetImage( */ fun mapToDisplayMentions(uiText: UIText, resources: Resources): Pair, String> { return if (uiText is UIText.DynamicString) { - val stringBuilder: StringBuilder = StringBuilder(uiText.value) - val mentions = uiText.mentions - .filter { it.start >= 0 && it.length > 0 } - .sortedBy { it.start } - .reversed() - val mentionList = mentions.mapNotNull { mention -> - // secured crash for mentions caused by web when text without mentions contains mention data - if (mention.start + mention.length <= uiText.value.length && uiText.value.elementAt(mention.start) == '@') { - val mentionName = uiText.value.substring(mention.start, mention.start + mention.length) - stringBuilder.insert(mention.start + mention.length, MENTION_MARK) - stringBuilder.insert(mention.start, MENTION_MARK) - DisplayMention( - mention.userId, - mention.length, - mention.isSelfMention, - mentionName - ) - } else { - null - } - }.reversed() - Pair(mentionList, stringBuilder.toString()) + uiText.toMarkdownTextWithMentions() } else { Pair(listOf(), uiText.asString(resources)) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index 76ec4840492..5b9df34c008 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -31,6 +31,7 @@ import com.wire.android.ui.home.conversations.model.messagetypes.image.VisualMed import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration import com.wire.android.ui.markdown.MarkdownConstants +import com.wire.android.ui.markdown.MarkdownNode import com.wire.android.ui.markdown.MarkdownPreview import com.wire.android.ui.theme.Accent import com.wire.android.util.Copyable @@ -290,7 +291,9 @@ sealed interface UILastMessageContent { data class TextMessage( val messageBody: MessageBody, @Transient - val markdownPreview: MarkdownPreview? = null + val markdownPreview: MarkdownPreview? = null, + @Transient + val markdownLocaleTag: String? = null ) : UILastMessageContent @Serializable @@ -299,7 +302,9 @@ sealed interface UILastMessageContent { val message: UIText, val separator: String = MarkdownConstants.NON_BREAKING_SPACE, @Transient - val markdownPreview: MarkdownPreview? = null + val markdownPreview: MarkdownPreview? = null, + @Transient + val markdownLocaleTag: String? = null ) : UILastMessageContent @Serializable @@ -627,7 +632,9 @@ sealed interface UIMessageContent { @Serializable data class MessageBody( val message: UIText, - val quotedMessage: UIQuotedMessage? = null + val quotedMessage: UIQuotedMessage? = null, + @Transient + val markdownDocument: MarkdownNode.Document? = null ) enum class MessageSource { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index c7075e197a0..1e0e44dae1c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -28,6 +28,7 @@ import com.wire.android.mapper.toConversationItem import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig @@ -48,6 +49,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( private val userTypeMapper: UserTypeMapper, private val dispatchers: DispatcherProvider, private val getSelfUser: GetSelfUserUseCase, + private val uiTextResolver: UiTextResolver, ) { @Suppress("LongParameterList") suspend operator fun invoke( @@ -100,6 +102,7 @@ class GetConversationsFromSearchUseCase @Inject constructor( pagingData.map { it.toConversationItem( userTypeMapper = userTypeMapper, + uiTextResolver = uiTextResolver, searchQuery = searchQuery, selfUserTeamId = getSelfUser()?.teamId, playingAudioMessage = playingAudioMessage diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index f861876b391..f880049a59f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -44,6 +44,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationItemType import com.wire.android.ui.home.conversationslist.model.ConversationSection import com.wire.android.ui.home.conversationslist.model.ConversationsSource import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.MutedConversationStatus @@ -108,6 +109,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( @CurrentAccount val currentAccount: UserId, private val userTypeMapper: UserTypeMapper, private val getSelfUser: GetSelfUserUseCase, + private val uiTextResolver: UiTextResolver, ) : ConversationListViewModel, ViewModel() { @AssistedFactory @@ -225,6 +227,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( conversations.map { conversationDetails -> conversationDetails.toConversationItem( userTypeMapper = userTypeMapper, + uiTextResolver = uiTextResolver, searchQuery = searchQuery, selfUserTeamId = getSelfUser()?.teamId, playingAudioMessage = playingAudioMessage diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index b4d146fe461..da6ed794a28 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -123,7 +123,8 @@ fun ConversationItemFactory( when (val messageContent = conversation.lastMessageContent) { is UILastMessageContent.TextMessage -> LastMessageSubtitle( messageContent.messageBody.message, - messageContent.markdownPreview + messageContent.markdownPreview, + messageContent.markdownLocaleTag ) is UILastMessageContent.MultipleMessage -> LastMultipleMessages(messageContent.messages, messageContent.separator) @@ -131,7 +132,8 @@ fun ConversationItemFactory( messageContent.sender, messageContent.message, messageContent.separator, - messageContent.markdownPreview + messageContent.markdownPreview, + messageContent.markdownLocaleTag ) is UILastMessageContent.Connection -> ConnectionLabel(connectionInfo = messageContent) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt index 79fc9b5f332..456c169c04b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt @@ -21,15 +21,14 @@ package com.wire.android.ui.home.conversationslist.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.style.TextOverflow import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.markdown.MarkdownInline import com.wire.android.ui.markdown.MarkdownPreview +import com.wire.android.ui.markdown.MarkdownNode import com.wire.android.ui.markdown.MessageColors import com.wire.android.ui.markdown.NodeData -import com.wire.android.ui.markdown.getFirstInlines -import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.ui.theme.Accent import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -37,13 +36,24 @@ import com.wire.android.util.ui.UIText import kotlinx.collections.immutable.persistentListOf @Composable -fun LastMessageSubtitle(text: UIText, markdownPreview: MarkdownPreview? = null) { - LastMessageMarkdown(text = text.asString(), markdownPreview = markdownPreview) +fun LastMessageSubtitle(text: UIText, markdownPreview: MarkdownPreview? = null, markdownLocaleTag: String? = null) { + LastMessageMarkdown(text = text.asString(), markdownPreview = markdownPreview, markdownLocaleTag = markdownLocaleTag) } @Composable -fun LastMessageSubtitleWithAuthor(author: UIText, text: UIText, separator: String, markdownPreview: MarkdownPreview? = null) { - LastMessageMarkdown(text = text.asString(), leadingText = "${author.asString()}$separator", markdownPreview = markdownPreview) +fun LastMessageSubtitleWithAuthor( + author: UIText, + text: UIText, + separator: String, + markdownPreview: MarkdownPreview? = null, + markdownLocaleTag: String? = null +) { + LastMessageMarkdown( + text = text.asString(), + leadingText = "${author.asString()}$separator", + markdownPreview = markdownPreview, + markdownLocaleTag = markdownLocaleTag + ) } @Composable @@ -55,7 +65,8 @@ fun LastMultipleMessages(messages: List, separator: String) { private fun LastMessageMarkdown( text: String, leadingText: String = "", - markdownPreview: MarkdownPreview? = null + markdownPreview: MarkdownPreview? = null, + markdownLocaleTag: String? = null ) { val nodeData = NodeData( color = MaterialTheme.wireColorScheme.secondaryText, @@ -69,22 +80,28 @@ private fun LastMessageMarkdown( accent = Accent.Unknown ) - val effectivePreview = remember(text, markdownPreview) { - markdownPreview ?: text.toMarkdownDocument().getFirstInlines() - } - - val leadingInlines = remember(leadingText) { - leadingText.toMarkdownDocument().getFirstInlines()?.children ?: persistentListOf() - } + val locales = LocalConfiguration.current.locales + val currentLocaleTag = if (locales.isEmpty) "" else locales[0].toLanguageTag() + val shouldUsePreview = markdownPreview != null && (markdownLocaleTag == null || markdownLocaleTag == currentLocaleTag) - if (effectivePreview != null) { + if (shouldUsePreview) { + val leadingInlines = if (leadingText.isBlank()) { + persistentListOf() + } else { + persistentListOf( + MarkdownNode.Inline.Text( + leadingText.replace(MarkdownConstants.NON_BREAKING_SPACE, " ") + ) + ) + } MarkdownInline( - inlines = leadingInlines.plus(effectivePreview.children), + inlines = leadingInlines.plus(markdownPreview.children), nodeData = nodeData ) } else { Text( - text = leadingText.replace(MarkdownConstants.NON_BREAKING_SPACE, " ") + text, + text = leadingText.replace(MarkdownConstants.NON_BREAKING_SPACE, " ") + + text.replace(MarkdownConstants.NON_BREAKING_SPACE, " "), style = nodeData.style, color = nodeData.color, maxLines = 1, diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt index f8053b678de..dc44c274a38 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.markdown import com.wire.android.appLogger +import com.wire.android.util.ui.UIText import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import org.commonmark.ext.gfm.strikethrough.Strikethrough @@ -53,6 +54,32 @@ import org.commonmark.node.ThematicBreak fun String.toMarkdownDocument(): MarkdownNode.Document = MarkdownParser.parse(this) +// Builds a markdown-ready string by inserting mention markers and returning display mentions. +fun UIText.DynamicString.toMarkdownTextWithMentions(): Pair, String> { + val stringBuilder: StringBuilder = StringBuilder(value) + val mentions = mentions + .filter { it.start >= 0 && it.length > 0 } + .sortedBy { it.start } + .reversed() + val mentionList = mentions.mapNotNull { mention -> + // Guard against invalid mention ranges (e.g. inconsistent backend data). + if (mention.start + mention.length <= value.length && value[mention.start] == '@') { + val mentionName = value.substring(mention.start, mention.start + mention.length) + stringBuilder.insert(mention.start + mention.length, MarkdownConstants.MENTION_MARK) + stringBuilder.insert(mention.start, MarkdownConstants.MENTION_MARK) + DisplayMention( + mention.userId, + mention.length, + mention.isSelfMention, + mentionName + ) + } else { + null + } + }.reversed() + return Pair(mentionList, stringBuilder.toString()) +} + fun T.toContent(isParentDocument: Boolean = false): MarkdownNode { return when (this) { is Document -> MarkdownNode.Document(convertChildren()) diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt index 921f440c9f3..5115918e3fd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt @@ -18,10 +18,149 @@ package com.wire.android.ui.markdown import org.commonmark.node.Document +import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser object MarkdownParser { - private val parser = Parser.builder().extensions(MarkdownConstants.supportedExtensions).build() - fun parse(text: String) = (parser.parse(text) as Document).toContent() as MarkdownNode.Document + private val parser = Parser.builder() + .extensions(MarkdownConstants.supportedExtensions) + .includeSourceSpans(IncludeSourceSpans.BLOCKS) + .build() + + // We preserve blank lines across *all* block types by using source spans: + // CommonMark collapses empty lines into block boundaries, so we count + // blank input lines between top-level blocks and insert spacer paragraphs + // (Break-only) to render the exact number of empty lines in the UI. + // We then disable bottom padding on blocks followed by a spacer to avoid + // double spacing (padding + spacer). + fun parse(text: String): MarkdownNode.Document { + val documentNode = parser.parse(text) as Document + val markdownChildren = mutableListOf() + + // Walk top-level blocks in source order and optionally insert spacer paragraphs between them. + val blockNodes = collectTopLevelBlocks(documentNode) + val blankLineCounts = countBlankLinesBetweenBlocks(documentNode, text) + blockNodes.forEachIndexed { index, block -> + // Convert each CommonMark top-level block into our UI model. + markdownChildren.add(block.toContent() as MarkdownNode.Block) + if (index < blankLineCounts.size) { + val blankLines = blankLineCounts[index] + if (blankLines > 0) { + // Insert a spacer block to render the exact number of blank lines. + markdownChildren.add(createSpacerParagraph(blankLines)) + } + } + } + + return MarkdownNode.Document( + adjustPaddingForSpacers(markdownChildren) + ) + } + + private fun collectTopLevelBlocks(document: Document): List { + // Collect direct children (top-level blocks) because source spans are per block. + val blocks = mutableListOf() + var child = document.firstChild + while (child != null) { + blocks.add(child) + child = child.next + } + return blocks + } + + private fun countBlankLinesBetweenBlocks(document: Document, text: String): List { + // Split input by lines so we can count the blank lines between block spans. + val lines = text.split("\n") + val blocks = collectTopLevelBlocks(document) + if (blocks.size < 2) return emptyList() + + val blankCounts = mutableListOf() + for (index in 0 until blocks.size - 1) { + // Source spans point to the lines covered by each block in the original input. + val currentSpan = blocks[index].sourceLineRange() + val nextSpan = blocks[index + 1].sourceLineRange() + blankCounts.add(countBlankLinesBetween(lines, currentSpan, nextSpan)) + } + return blankCounts + } + + private fun countBlankLinesBetween( + lines: List, + currentSpan: Pair?, + nextSpan: Pair? + ): Int { + val start = currentSpan?.second?.plus(1) + val end = nextSpan?.first?.minus(1) + val safeEnd = if (start != null && end != null) { + calculateSafeEnd(start, end, lines.size) + } else { + null + } + + return if (start == null || safeEnd == null) { + 0 + } else { + lines.subList(start, safeEnd + 1).count { it.isBlank() } + } + } + + private fun calculateSafeEnd(start: Int, end: Int, linesSize: Int): Int? { + var safeEnd: Int? = null + if (start <= end && start < linesSize) { + safeEnd = minOf(end, linesSize - 1) + } + return safeEnd + } + + private fun org.commonmark.node.Node.sourceLineRange(): Pair? { + // Reduce all spans to a min/max line range for the block. + val spans = sourceSpans + if (spans.isEmpty()) return null + var minLine = Int.MAX_VALUE + var maxLine = Int.MIN_VALUE + for (span in spans) { + minLine = minOf(minLine, span.lineIndex) + maxLine = maxOf(maxLine, span.lineIndex) + } + return if (minLine == Int.MAX_VALUE || maxLine == Int.MIN_VALUE) null else Pair(minLine, maxLine) + } + + private fun createSpacerParagraph(blankLines: Int): MarkdownNode.Block.Paragraph { + val breaks = List(blankLines) { MarkdownNode.Inline.Break() } + return MarkdownNode.Block.Paragraph(breaks, isParentDocument = false) + } + + private fun adjustPaddingForSpacers(children: List): List { + if (children.isEmpty()) return children + val adjusted = mutableListOf() + for (index in children.indices) { + val block = children[index] + // If a spacer follows, suppress padding on the current block to avoid double spacing. + val nextIsSpacer = index + 1 < children.size && children[index + 1].isSpacerParagraph() + adjusted.add(if (nextIsSpacer) block.withParentDocument(false) else block) + } + return adjusted + } + + private fun MarkdownNode.Block.isSpacerParagraph(): Boolean { + return this is MarkdownNode.Block.Paragraph && children.all { it is MarkdownNode.Inline.Break } + } + + private fun MarkdownNode.Block.withParentDocument(isParentDocument: Boolean): MarkdownNode.Block { + return when (this) { + is MarkdownNode.Block.Heading -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.Paragraph -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.BlockQuote -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.ListBlock.Bullet -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.ListBlock.Ordered -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.ListItem -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.IntendedCode -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.FencedCode -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.Table -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.TableContent.Head -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.TableContent.Body -> copy(isParentDocument = isParentDocument) + is MarkdownNode.Block.ThematicBreak -> copy(isParentDocument = isParentDocument) + } + } } diff --git a/app/src/main/kotlin/com/wire/android/util/ui/UiTextResolver.kt b/app/src/main/kotlin/com/wire/android/util/ui/UiTextResolver.kt new file mode 100644 index 00000000000..bfd5f24beec --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/ui/UiTextResolver.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.util.ui + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Locale +import javax.inject.Inject + +interface UiTextResolver { + + // Resolves UIText outside of Compose without holding Activity/Fragment context. + fun resolve(text: UIText): String + + // Tag used to invalidate precomputed markdown when the locale changes. + fun localeTag(): String +} + +class AndroidUiTextResolver @Inject constructor( + @ApplicationContext private val context: Context +) : UiTextResolver { + + override fun resolve(text: UIText): String = text.asString(context.resources) + + override fun localeTag(): String { + val locales = context.resources.configuration.locales + val locale = if (locales.isEmpty) Locale.getDefault() else locales[0] + return locale.toLanguageTag() + } +} diff --git a/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt index 41c683b92dd..13bf7ad62ef 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/MessagePreviewContentMapperTest.kt @@ -31,12 +31,22 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.assertions.shouldBeInstanceOf +import com.wire.android.util.ui.UiTextResolver import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) class MessagePreviewContentMapperTest { + private val uiTextResolver = object : UiTextResolver { + override fun resolve(text: UIText): String = when (text) { + is UIText.DynamicString -> text.value + is UIText.StringResource -> "res_${text.resId}" + is UIText.PluralResource -> "plural_${text.resId}_${text.count}" + } + + override fun localeTag(): String = "test-locale" + } @Test fun givenMultipleUnreadEvents_whenMappingToUIPreview_thenCorrectSortedUILastMessageContentShouldBeReturned() = runTest { @@ -48,7 +58,8 @@ class MessagePreviewContentMapperTest { val unreadEventCount = mapOf(UnreadEventType.MENTION to mentionCount, UnreadEventType.MISSED_CALL to missedCallCount) - val multipleMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val multipleMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val results = multipleMessage.messages.filterIsInstance() val sortedEventCount = unreadEventCount.toSortedMap() @@ -65,7 +76,8 @@ class MessagePreviewContentMapperTest { val unreadCount = 2 val unreadEventCount = mapOf(UnreadEventType.MISSED_CALL to unreadCount) - val textMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val textMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val result = textMessage.messageBody.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.plurals.unread_event_call @@ -80,7 +92,8 @@ class MessagePreviewContentMapperTest { val unreadCount = 2 val unreadEventCount = mapOf(UnreadEventType.MENTION to unreadCount) - val textMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val textMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val result = textMessage.messageBody.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.plurals.unread_event_mention @@ -95,7 +108,8 @@ class MessagePreviewContentMapperTest { val unreadCount = 2 val unreadEventCount = mapOf(UnreadEventType.REPLY to unreadCount) - val textMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val textMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val result = textMessage.messageBody.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.plurals.unread_event_reply @@ -110,7 +124,8 @@ class MessagePreviewContentMapperTest { val unreadCount = 2 val unreadEventCount = mapOf(UnreadEventType.KNOCK to unreadCount) - val textMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val textMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val result = textMessage.messageBody.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.plurals.unread_event_knock @@ -127,7 +142,8 @@ class MessagePreviewContentMapperTest { val unreadCount = 2 val unreadEventCount = mapOf(UnreadEventType.MESSAGE to unreadCount) - val senderWithMessage = messagePreview.toUIPreview(unreadEventCount).shouldBeInstanceOf() + val senderWithMessage = messagePreview.toUIPreview(unreadEventCount, uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.value shouldBeEqualTo lastMessage @@ -139,7 +155,8 @@ class MessagePreviewContentMapperTest { content = MessagePreviewContent.WithUser.Asset("admin", AssetType.AUDIO), ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.last_message_self_user_shared_audio @@ -152,7 +169,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = true ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.last_message_self_user_shared_image @@ -162,7 +180,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = false ) - val otherUserWithMessage = otherUserPreview.uiLastMessageContent().shouldBeInstanceOf() + val otherUserWithMessage = otherUserPreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val otherSenderResult = otherUserWithMessage.message.shouldBeInstanceOf() otherSenderResult.resId shouldBeEqualTo R.string.last_message_other_user_shared_image @@ -176,7 +195,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = true ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.last_message_self_user_shared_video @@ -188,7 +208,8 @@ class MessagePreviewContentMapperTest { content = MessagePreviewContent.WithUser.ConversationNameChange("admin"), ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.last_message_other_changed_conversation_name @@ -205,8 +226,10 @@ class MessagePreviewContentMapperTest { isSelfMessage = false ) - val selfUserMessage = selfUserMessagePreview.uiLastMessageContent().shouldBeInstanceOf() - val otherUserMessage = otherUserMessagePreview.uiLastMessageContent().shouldBeInstanceOf() + val selfUserMessage = selfUserMessagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() + val otherUserMessage = otherUserMessagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val selfUserResult = selfUserMessage.message.shouldBeInstanceOf() val otherUserResult = otherUserMessage.message.shouldBeInstanceOf() @@ -226,9 +249,11 @@ class MessagePreviewContentMapperTest { ) val selfSenderWithMessage = - selfUserMessagePreview.uiLastMessageContent().shouldBeInstanceOf() + selfUserMessagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val otherSenderWithMessage = - otherUserMessagePreview.uiLastMessageContent().shouldBeInstanceOf() + otherUserMessagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = selfSenderWithMessage.message.shouldBeInstanceOf() val otherUserResult = otherSenderWithMessage.message.shouldBeInstanceOf() @@ -242,7 +267,8 @@ class MessagePreviewContentMapperTest { content = MessagePreviewContent.WithUser.MemberJoined("user"), ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.last_message_other_user_joined_conversation @@ -254,7 +280,8 @@ class MessagePreviewContentMapperTest { content = MessagePreviewContent.WithUser.ConversationMembersRemoved("admin", isSelfUserRemoved = true, listOf()), ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.resId shouldBeEqualTo R.string.last_message_other_removed_only_self_user } @@ -268,7 +295,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = true ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherRemovedUsers.size previewString.resId shouldBeEqualTo R.plurals.last_message_self_removed_users @@ -286,7 +314,8 @@ class MessagePreviewContentMapperTest { ) ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherRemovedUsers.size previewString.resId shouldBeEqualTo R.plurals.last_message_other_removed_self_user_and_others @@ -304,7 +333,8 @@ class MessagePreviewContentMapperTest { ) ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherUsersAdded.size previewString.resId shouldBeEqualTo R.plurals.last_message_other_added_self_user @@ -322,7 +352,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = true ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherUsersAdded.size previewString.resId shouldBeEqualTo R.plurals.last_message_self_added_users @@ -339,7 +370,7 @@ class MessagePreviewContentMapperTest { ) ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver).shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherUsersAdded.size previewString.resId shouldBeEqualTo R.plurals.last_message_other_added_other_users @@ -356,7 +387,8 @@ class MessagePreviewContentMapperTest { ) ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherRemovedUsers.size previewString.resId shouldBeEqualTo R.plurals.last_message_other_removed_self_user_and_others @@ -373,7 +405,8 @@ class MessagePreviewContentMapperTest { ) ) - val uiPreviewMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val uiPreviewMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val previewString = uiPreviewMessage.messageBody.message.shouldBeInstanceOf() previewString.count shouldBeEqualTo otherRemovedUsers.size previewString.resId shouldBeEqualTo R.plurals.last_message_other_removed_other_users @@ -386,7 +419,8 @@ class MessagePreviewContentMapperTest { isSelfMessage = false ) - val senderWithMessage = messagePreview.uiLastMessageContent().shouldBeInstanceOf() + val senderWithMessage = messagePreview.uiLastMessageContent(uiTextResolver) + .shouldBeInstanceOf() val result = senderWithMessage.message.shouldBeInstanceOf() result.resId shouldBeEqualTo R.string.deleted_message_text diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt index e1151715898..fc711fe2fb9 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCaseTest.kt @@ -26,6 +26,8 @@ import com.wire.android.framework.TestUser import com.wire.android.mapper.UserTypeMapper import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.Membership +import com.wire.android.util.ui.UiTextResolver +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationFolder @@ -202,6 +204,9 @@ class GetConversationsFromSearchUseCaseTest { @MockK lateinit var getSelfUser: GetSelfUserUseCase + @MockK + lateinit var uiTextResolver: UiTextResolver + val queryConfig = ConversationQueryConfig( searchQuery = "search", fromArchive = false, @@ -212,6 +217,15 @@ class GetConversationsFromSearchUseCaseTest { init { MockKAnnotations.init(this, relaxUnitFun = true) coEvery { userTypeMapper.toMembership(any()) } returns Membership.Standard + coEvery { uiTextResolver.resolve(any()) } answers { + val text = firstArg() + when (text) { + is UIText.DynamicString -> text.value + is UIText.StringResource -> "res_${text.resId}" + is UIText.PluralResource -> "plural_${text.resId}_${text.count}" + } + } + coEvery { uiTextResolver.localeTag() } returns "test-locale" withPaginatedResult(emptyList()) } @@ -236,7 +250,13 @@ class GetConversationsFromSearchUseCaseTest { } fun arrange() = this to GetConversationsFromSearchUseCase( - useCase, getFavoriteFolderUseCase, observeConversationsFromFolderUseCase, userTypeMapper, dispatcherProvider, getSelfUser + useCase, + getFavoriteFolderUseCase, + observeConversationsFromFolderUseCase, + userTypeMapper, + dispatcherProvider, + getSelfUser, + uiTextResolver ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index 5d8a7960727..b87360ff455 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -36,6 +36,8 @@ import com.wire.android.media.audiomessage.PlayingAudioMessage import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationsSource +import com.wire.android.util.ui.UiTextResolver +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.id.ConversationId @@ -291,6 +293,9 @@ class ConversationListViewModelTest { @MockK lateinit var audioMessagePlayer: ConversationAudioMessagePlayer + @MockK + lateinit var uiTextResolver: UiTextResolver + init { MockKAnnotations.init(this, relaxUnitFun = true) withConversationsPaginated(listOf(TestConversationItem.CONNECTION, TestConversationItem.PRIVATE, TestConversationItem.GROUP)) @@ -307,6 +312,15 @@ class ConversationListViewModelTest { } ) every { audioMessagePlayer.playingAudioMessageFlow } returns flowOf(PlayingAudioMessage.None) + coEvery { uiTextResolver.resolve(any()) } answers { + val text = firstArg() + when (text) { + is UIText.DynamicString -> text.value + is UIText.StringResource -> "res_${text.resId}" + is UIText.PluralResource -> "plural_${text.resId}_${text.count}" + } + } + coEvery { uiTextResolver.localeTag() } returns "test-locale" mockUri() } @@ -344,6 +358,7 @@ class ConversationListViewModelTest { observeLegalHoldStateForSelfUser = observeLegalHoldStateForSelfUserUseCase, userTypeMapper = UserTypeMapper(), getSelfUser = getSelfUser, + uiTextResolver = uiTextResolver, usePagination = true, audioMessagePlayer = audioMessagePlayer, ) diff --git a/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt b/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt index 3abab019e7d..f194650685c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/markdown/MarkdownHelperTest.kt @@ -29,6 +29,9 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.message.mention.MessageMention +import com.wire.kalium.logic.data.user.UserId class MarkdownHelperTest { @@ -95,6 +98,43 @@ class MarkdownHelperTest { ) } + @Test + fun `given text with blank line, when toMarkdownDocument is called, then it should insert a spacer paragraph`() { + val result = "test\n\ntest".toMarkdownDocument() + + assertEquals(3, result.children.size) + val firstParagraph = result.children[0] as MarkdownNode.Block.Paragraph + val spacerParagraph = result.children[1] as MarkdownNode.Block.Paragraph + val lastParagraph = result.children[2] as MarkdownNode.Block.Paragraph + assertEquals(1, firstParagraph.children.size) + assertEquals(1, spacerParagraph.children.size) + assertEquals(1, lastParagraph.children.size) + assertTrue(firstParagraph.children[0] is MarkdownNode.Inline.Text) + assertTrue(spacerParagraph.children[0] is MarkdownNode.Inline.Break) + assertTrue(lastParagraph.children[0] is MarkdownNode.Inline.Text) + assertEquals("test", (firstParagraph.children[0] as MarkdownNode.Inline.Text).literal) + assertEquals("test", (lastParagraph.children[0] as MarkdownNode.Inline.Text).literal) + } + + @Test + fun `given text with two blank lines, when toMarkdownDocument is called, then it should preserve extra empty line`() { + val result = "test\n\n\ntest".toMarkdownDocument() + + assertEquals(3, result.children.size) + val firstParagraph = result.children[0] as MarkdownNode.Block.Paragraph + val spacerParagraph = result.children[1] as MarkdownNode.Block.Paragraph + val lastParagraph = result.children[2] as MarkdownNode.Block.Paragraph + assertEquals(1, firstParagraph.children.size) + assertEquals(2, spacerParagraph.children.size) + assertEquals(1, lastParagraph.children.size) + assertTrue(firstParagraph.children[0] is MarkdownNode.Inline.Text) + assertTrue(spacerParagraph.children[0] is MarkdownNode.Inline.Break) + assertTrue(spacerParagraph.children[1] is MarkdownNode.Inline.Break) + assertTrue(lastParagraph.children[0] is MarkdownNode.Inline.Text) + assertEquals("test", (firstParagraph.children[0] as MarkdownNode.Inline.Text).literal) + assertEquals("test", (lastParagraph.children[0] as MarkdownNode.Inline.Text).literal) + } + @Test fun `given bullet list node, when toContent is called, then it should return Block BulletList`() { val bulletListNode = BulletList() @@ -118,6 +158,28 @@ class MarkdownHelperTest { assertEquals(1, result.children.size) } + @Test + fun `given dynamic text with mention, when toMarkdownTextWithMentions is called, then it should wrap mention with markers`() { + val text = "hi @john\n\nbye" + val mention = MessageMention( + start = 3, + length = 5, + userId = UserId("user-id", "domain"), + isSelfMention = false + ) + val uiText = UIText.DynamicString(text, listOf(mention)) + + val (mentions, markedText) = uiText.toMarkdownTextWithMentions() + + assertEquals(1, mentions.size) + assertEquals("@john", mentions.first().mentionUserName) + assertTrue( + markedText.contains( + "${MarkdownConstants.MENTION_MARK}@john${MarkdownConstants.MENTION_MARK}" + ) + ) + } + @Test fun `given fenced code block, when toContent is called, then it should return Block FencedCode`() { val codeBlockNode = FencedCodeBlock() @@ -134,19 +196,19 @@ class MarkdownHelperTest { val tableBlockNode = TableBlock() tableBlockNode.appendChild( TableHead().apply { - appendChild( - TableRow() - .apply { appendChild(TableCell().apply { appendChild(Text("Header")) }) } - ) - } + appendChild( + TableRow() + .apply { appendChild(TableCell().apply { appendChild(Text("Header")) }) } + ) + } ) tableBlockNode.appendChild( TableBody().apply { - appendChild( - TableRow() - .apply { appendChild(TableCell().apply { appendChild(Text("Cell")) }) } - ) - } + appendChild( + TableRow() + .apply { appendChild(TableCell().apply { appendChild(Text("Cell")) }) } + ) + } ) val result = tableBlockNode.toContent()