From 9d0be9748ebdd731434e7cfc814b13e44f4a3fcb Mon Sep 17 00:00:00 2001 From: Sergei Bakhtiarov Date: Thu, 30 Jul 2026 16:14:51 +0200 Subject: [PATCH 1/2] feat: support for new adminless groups feature events (WPB-25284) --- .../mapper/SystemMessageContentMapper.kt | 3 +++ .../messages/item/SystemMessageItem.kt | 22 +++++++++++++++++++ .../preview/PreviewSystemMessageItem.kt | 15 +++++++++++++ .../ui/home/conversations/model/UIMessage.kt | 5 +++++ app/src/main/res/values/strings.xml | 1 + .../mapper/SystemMessageContentMapperTest.kt | 13 +++++++++++ .../wire/android/util/DateAndTimeParsers.kt | 16 ++++++++++++++ .../com/wire/android/util/SupportPage.kt | 3 ++- .../ui-common/src/main/res/values/strings.xml | 1 + kalium | 2 +- 10 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt index 6c51cd615d9..2486f272d37 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt @@ -81,6 +81,9 @@ class SystemMessageContentMapper @Inject constructor( content, members ) + + is MessageContent.AdminlessDeleteReminder -> + UIMessageContent.SystemMessage.AdminlessDeleteReminder(content.deletionScheduledFor) } private fun mapConversationConversationAppsAccessChanged( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt index 196c555f110..45abc55c3e0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.messages.item +import android.text.format.DateFormat import androidx.annotation.DrawableRes import androidx.annotation.PluralsRes import androidx.annotation.StringRes @@ -63,6 +64,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CustomTabsHelper import com.wire.android.util.SupportPage +import com.wire.android.util.formatMonthDayShortTime import com.wire.android.util.supportUrlResource import com.wire.android.util.ui.MarkdownTextStyle import com.wire.android.util.ui.UIText @@ -610,6 +612,26 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this } } } + + is SystemMessage.AdminlessDeleteReminder -> buildContent( + iconResId = commonR.drawable.ic_info, + iconTintColor = MaterialTheme.wireColorScheme.error, + learnMorePage = SupportPage.ADMINLESS_GROUP_DELETE, + ) { + val is24Hour = DateFormat.is24HourFormat(LocalContext.current) + val markdownTextStyle = DefaultMarkdownTextStyle.copy( + normalColor = MaterialTheme.wireColorScheme.error, + boldColor = MaterialTheme.wireColorScheme.error + ) + buildAnnotatedString { + append( + stringResource( + id = R.string.label_system_message_adminless_delete_reminder, + formatArgs = arrayOf(deletionScheduledFor.formatMonthDayShortTime(is24Hour)) + ).toMarkdownAnnotatedString(markdownTextStyle) + ) + } + } } private fun AnnotatedString.Builder.appendVerticalSpace() = withStyle(ParagraphStyle()) { append(" ") } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt index 389ac48d5cd..76626329d67 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt @@ -29,6 +29,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText import com.wire.android.util.ui.toUIText import com.wire.kalium.logic.data.conversation.Conversation +import kotlinx.datetime.Instant @PreviewMultipleThemes @Composable @@ -486,3 +487,17 @@ fun PreviewSystemMessageConversationMessageAppsAccessEnabled() { ) } } + +@PreviewMultipleThemes +@Composable +fun PreviewSystemMessageAdminlessDeleteReminder() { + WireTheme { + SystemMessageItem( + message = mockMessageWithKnock.copy( + messageContent = UIMessageContent.SystemMessage.AdminlessDeleteReminder( + deletionScheduledFor = Instant.parse("2026-04-23T12:00:00Z") + ) + ) + ) + } +} 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 f60ea33f4b5..09df03f8b10 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 @@ -650,6 +650,11 @@ sealed interface UIMessageContent { val isAuthorSelfUser: Boolean = false, val isAccessEnabled: Boolean ) : SystemMessage + + @Serializable + data class AdminlessDeleteReminder( + val deletionScheduledFor: Instant + ) : SystemMessage } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41f0727a4e1..a8d49cf15a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1903,6 +1903,7 @@ In group conversations, the group admin can overwrite this setting. %1$s disabled **apps** for this conversation Added apps have access to the content of this conversation. You were promoted to group admin + This group will be automatically deleted on %1$s, as there are no eligible group admins. More information about this backend diff --git a/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt index 6911ac9a13b..1eb8e62dced 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt @@ -44,6 +44,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.util.Locale +import kotlinx.datetime.Instant @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) @@ -75,6 +76,18 @@ class SystemMessageContentMapperTest { assertTrue(uiContent is SystemMessage.ConversationMessageTimerDeactivated) } + @Test + fun givenAdminlessDeleteReminder_whenMappingToSystemMessage_thenScheduledDeletionIsPreserved() = runTest { + val (_, mapper) = Arrangement().arrange() + val scheduledDeletion = Instant.parse("2026-04-23T12:00:00Z") + val content = MessageContent.AdminlessDeleteReminder(scheduledDeletion) + + val uiContent = mapper.mapMessage(TestMessage.SYSTEM_MESSAGE.copy(content = content), emptyList()) + + assertIs(uiContent!!) + assertEquals(scheduledDeletion, uiContent.deletionScheduledFor) + } + @Test fun givenMemberDetails_whenMappingToSystemMessageMemberName_thenCorrectValuesShouldBeReturned() = runTest { // Given diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt index 26c4db066d2..1936f71b2e3 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt @@ -17,9 +17,11 @@ */ package com.wire.android.util +import android.text.format.DateFormat import androidx.compose.runtime.Stable import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant +import java.text.SimpleDateFormat import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -39,6 +41,10 @@ fun Instant.formatMediumDateTime(): String = DateAndTimeParsers.formatMediumDate @Stable fun Instant.formatFullDateShortTime(): String = DateAndTimeParsers.formatFullDateShortTime(this) +@Stable +fun Instant.formatMonthDayShortTime(is24Hour: Boolean): String = + DateAndTimeParsers.formatMonthDayShortTime(this, is24Hour) + @Stable fun Instant.uiMessageDateTime(): String = DateAndTimeParsers.uiMessageDateTime(this) @@ -107,6 +113,16 @@ class DateAndTimeParsers private constructor() { fun formatFullDateShortTime(instant: Instant): String = fullDateShortTimeFormatter.format(instant.toJavaInstant()) + fun formatMonthDayShortTime(instant: Instant, is24Hour: Boolean): String { + val locale = Locale.getDefault() + val date = Date.from(instant.toJavaInstant()) + val datePattern = DateFormat.getBestDateTimePattern(locale, "MMMMd") + val timePattern = DateFormat.getBestDateTimePattern(locale, if (is24Hour) "Hm" else "hm") + val formattedDate = SimpleDateFormat(datePattern, locale).format(date) + val formattedTime = SimpleDateFormat(timePattern, locale).format(date) + return "$formattedDate, $formattedTime" + } + fun cellTimeFormat(instant: Instant): String { val timeFormatter = java.text.DateFormat.getTimeInstance( java.text.DateFormat.SHORT, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt index db5852fb98c..daba36cb668 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/SupportPage.kt @@ -52,5 +52,6 @@ enum class SupportPage( LEGAL_HOLD("legal_hold", R.string.url_legal_hold_learn_more), SEARCH("search", R.string.url_learn_about_search), CELLS_CONVERSATION("cells_conversation", R.string.empty_screen_learn_more_link_conversation), - CELLS_ALL_FILES("cells_all_files", R.string.empty_screen_learn_more_link_all_files_screen) + CELLS_ALL_FILES("cells_all_files", R.string.empty_screen_learn_more_link_all_files_screen), + ADMINLESS_GROUP_DELETE("adminless_group_delete", R.string.url_system_message_adminless_group_delete), } diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index f062c97de8b..92786b6cf99 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -59,6 +59,7 @@ https://support.wire.com/hc/articles/360002018278 https://support.wire.com/hc/articles/32207745256221 https://support.wire.com/hc/articles/32207800433309 + https://support.wire.com/hc/en-us/articles/37518608388125-Prevent-adminless-groups Learn more about legal hold Connecting not possible You can not connect to this user due to legal hold. diff --git a/kalium b/kalium index e68500602d0..7056798774e 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e68500602d0914e1263045aadeb69a414517f896 +Subproject commit 7056798774ebcd741dd5a605d108f9bb26bf52b4 From 507c2eace4fb24dfa4b517e9593b1aaf5ff6c960 Mon Sep 17 00:00:00 2001 From: Sergey Bakhtiarov Date: Fri, 28 Aug 2026 10:20:31 +0200 Subject: [PATCH 2/2] feat: show last message for ghost group (WPB-25285) (#5127) --- .../wire/android/mapper/ConversationMapper.kt | 21 ++++- .../mapper/SystemMessageContentMapper.kt | 2 - .../home/conversations/ConversationScreen.kt | 19 ++++ .../messages/ConversationMessagesViewModel.kt | 4 +- .../messages/ConversationMessagesViewState.kt | 1 + .../messages/item/SystemMessageItem.kt | 49 +++++++++-- .../preview/PreviewSystemMessageItem.kt | 11 +-- .../ui/home/conversations/model/UIMessage.kt | 4 - app/src/main/res/values/strings.xml | 1 + .../android/mapper/ConversationMapperTest.kt | 88 +++++++++++++++++++ .../mapper/SystemMessageContentMapperTest.kt | 13 --- ...onversationMessagesViewModelArrangement.kt | 13 +++ .../ConversationMessagesViewModelTest.kt | 22 +++++ kalium | 2 +- 14 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/mapper/ConversationMapperTest.kt 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 1daa7574131..8b2da890a93 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -17,10 +17,12 @@ */ package com.wire.android.mapper +import com.wire.android.R import com.wire.android.model.BadgeEventType import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData +import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.UILastMessageContent import com.wire.android.ui.home.conversationslist.model.BlockState import com.wire.android.ui.home.conversationslist.model.ConversationInfo @@ -30,6 +32,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationItem.Group.C import com.wire.android.ui.home.conversationslist.model.ConversationItem.Group.Regular import com.wire.android.ui.home.conversationslist.model.ConversationItem.PrivateConversation import com.wire.android.util.ui.UiTextResolver +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Connection @@ -59,7 +62,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, legalHoldStatus = conversationDetails.conversation.legalHoldStatus, - lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), + lastMessageContent = adminlessDeleteAwareLastMessage(uiTextResolver), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, unreadEventCount = unreadEventCount @@ -85,7 +88,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, legalHoldStatus = conversationDetails.conversation.legalHoldStatus, - lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), + lastMessageContent = adminlessDeleteAwareLastMessage(uiTextResolver), badgeEventType = parseConversationEventType( mutedStatus = conversationDetails.conversation.mutedStatus, unreadEventCount = unreadEventCount @@ -121,7 +124,7 @@ fun ConversationDetailsWithEvents.toConversationItem( conversationId = conversationDetails.conversation.id, mutedStatus = conversationDetails.conversation.mutedStatus, legalHoldStatus = conversationDetails.conversation.legalHoldStatus, - lastMessageContent = lastMessage.toUIPreview(unreadEventCount, uiTextResolver), + lastMessageContent = adminlessDeleteAwareLastMessage(uiTextResolver), badgeEventType = parsePrivateConversationEventType( conversationDetails.otherUser.connectionStatus, conversationDetails.otherUser.deleted, @@ -179,6 +182,18 @@ fun ConversationDetailsWithEvents.toConversationItem( } } +private fun ConversationDetailsWithEvents.adminlessDeleteAwareLastMessage( + uiTextResolver: UiTextResolver +): UILastMessageContent = if ( + !conversationDetails.conversation.archived && conversationDetails.conversation.adminlessGroupDeletionTimestamp != null +) { + UILastMessageContent.TextMessage( + MessageBody(UIText.StringResource(R.string.last_message_adminless_delete_reminder)) + ) +} else { + lastMessage.toUIPreview(unreadEventCount, uiTextResolver) +} + private fun Group.hasJoinableCall(joinableCallsByConversationId: Map): Boolean = joinableCallsByConversationId.containsKey(conversation.id) && isSelfUserMember diff --git a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt index 2486f272d37..92d031c2609 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt @@ -82,8 +82,6 @@ class SystemMessageContentMapper @Inject constructor( members ) - is MessageContent.AdminlessDeleteReminder -> - UIMessageContent.SystemMessage.AdminlessDeleteReminder(content.deletionScheduledFor) } private fun mapConversationConversationAppsAccessChanged( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index c9997e1acf8..7033a1b08f1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -159,6 +159,7 @@ import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBack import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel +import com.wire.android.ui.home.conversations.messages.item.AdminlessGroupDeleteReminderItem import com.wire.android.ui.home.conversations.messages.item.AssetLocalPathArgs import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem @@ -935,6 +936,7 @@ private fun ConversationScreen( playingAudioMessage = conversationMessagesViewState.playingAudioMessage, assetStatuses = conversationMessagesViewState.assetStatuses, lastUnreadMessageInstant = conversationMessagesViewState.firstUnreadInstant, + adminlessGroupDeletionTimestamp = conversationMessagesViewState.adminlessGroupDeletionTimestamp, unreadEventCount = conversationMessagesViewState.firstUnreadEventIndex, conversationDetailsData = conversationInfoViewState.conversationDetailsData, selectedMessageId = conversationMessagesViewState.searchedMessageId, @@ -1023,6 +1025,7 @@ private fun ConversationScreenContent( conversationId: ConversationId, bottomSheetVisible: Boolean, lastUnreadMessageInstant: Instant?, + adminlessGroupDeletionTimestamp: Instant?, unreadEventCount: Int, playingAudioMessage: PlayingAudioMessage, assetStatuses: PersistentMap, @@ -1085,6 +1088,7 @@ private fun ConversationScreenContent( lazyPagingMessages = lazyPagingMessages, lazyListState = lazyListState, lastUnreadMessageInstant = lastUnreadMessageInstant, + adminlessGroupDeletionTimestamp = adminlessGroupDeletionTimestamp, playingAudioMessage = playingAudioMessage, assetStatuses = assetStatuses, onUpdateConversationReadDate = onUpdateConversationReadDate, @@ -1183,6 +1187,7 @@ fun MessageList( lazyPagingMessages: LazyPagingItems, lazyListState: LazyListState, lastUnreadMessageInstant: Instant?, + adminlessGroupDeletionTimestamp: Instant?, playingAudioMessage: PlayingAudioMessage, assetStatuses: PersistentMap, onUpdateConversationReadDate: (Instant) -> Unit, @@ -1315,6 +1320,14 @@ fun MessageList( modifier = Modifier .fillMaxSize() ) { + if (adminlessGroupDeletionTimestamp != null && lazyPagingMessages.itemCount == 0) { + item( + key = "adminless_group_delete_reminder", + contentType = "adminless_group_delete_reminder", + ) { + AdminlessGroupDeleteReminderItem(adminlessGroupDeletionTimestamp) + } + } items( count = lazyPagingMessages.itemCount, key = lazyPagingMessages.itemKey { it.header.messageId }, @@ -1368,6 +1381,12 @@ fun MessageList( } } + // Multiple children in a reverse-layout item are placed in reverse order, + // so emitting the reminder first displays it below the newest message. + if (index == 0 && adminlessGroupDeletionTimestamp != null) { + AdminlessGroupDeleteReminderItem(adminlessGroupDeletionTimestamp) + } + MessageContainerItem( message = message, conversationDetailsData = conversationDetailsData, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt index f926958c805..097247b9ea7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModel.kt @@ -288,7 +288,9 @@ class ConversationMessagesViewModel( conversationDetailsResult.conversationDetails.isWireCellEnabled() val lastUnreadInstant = conversationDetailsResult.conversationDetails.conversation.lastReadDate conversationViewState = conversationViewState.copy( - firstUnreadInstant = lastUnreadInstant + firstUnreadInstant = lastUnreadInstant, + adminlessGroupDeletionTimestamp = + conversationDetailsResult.conversationDetails.conversation.adminlessGroupDeletionTimestamp, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 4c4620bd4cc..220f1a60c68 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -32,6 +32,7 @@ import kotlinx.datetime.Instant data class ConversationMessagesViewState( val messages: Flow> = emptyFlow(), val firstUnreadInstant: Instant? = null, + val adminlessGroupDeletionTimestamp: Instant? = null, val firstUnreadEventIndex: Int = 0, val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, val playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt index 45abc55c3e0..72fb1c42c0e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/SystemMessageItem.kt @@ -84,7 +84,41 @@ fun SystemMessageItem( failureInteractionAvailable: Boolean = true, onFailedMessageRetryClicked: (String, ConversationId) -> Unit = { _, _ -> }, onFailedMessageCancelClicked: (String) -> Unit = {}, -) = with(message.messageContent.buildContent(isWireCellsEnabled)) { +) { + SystemMessageContentItem( + content = message.messageContent.buildContent(isWireCellsEnabled), + message = message, + modifier = modifier, + initiallyExpanded = initiallyExpanded, + failureInteractionAvailable = failureInteractionAvailable, + onFailedMessageRetryClicked = onFailedMessageRetryClicked, + onFailedMessageCancelClicked = onFailedMessageCancelClicked, + ) +} + +@Composable +fun AdminlessGroupDeleteReminderItem( + deletionScheduledFor: kotlinx.datetime.Instant, + modifier: Modifier = Modifier, + is24HourFormat: Boolean? = null, +) { + SystemMessageContentItem( + content = adminlessGroupDeleteReminderContent(deletionScheduledFor, is24HourFormat), + modifier = modifier, + ) +} + +@Suppress("LongParameterList") +@Composable +private fun SystemMessageContentItem( + content: SystemMessageContent, + modifier: Modifier = Modifier, + message: UIMessage.System? = null, + initiallyExpanded: Boolean = false, + failureInteractionAvailable: Boolean = true, + onFailedMessageRetryClicked: (String, ConversationId) -> Unit = { _, _ -> }, + onFailedMessageCancelClicked: (String) -> Unit = {}, +) = with(content) { val textStyle = MaterialTheme.wireTypography.body01 val lineHeightDp: Dp = with(LocalDensity.current) { textStyle.lineHeight.toDp() } MessageItemTemplate( @@ -138,7 +172,7 @@ fun SystemMessageItem( contentPadding = PaddingValues(horizontal = dimensions().spacing12x, vertical = dimensions().spacing8x), ) } - if (message.sendingFailed) { + if (message?.sendingFailed == true) { MessageSendFailureWarning( messageStatus = message.header.messageStatus.flowStatus as MessageFlowStatus.Failure.Send, isInteractionAvailable = failureInteractionAvailable, @@ -613,12 +647,18 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this } } - is SystemMessage.AdminlessDeleteReminder -> buildContent( +} + +@Composable +private fun adminlessGroupDeleteReminderContent( + deletionScheduledFor: kotlinx.datetime.Instant, + is24HourFormat: Boolean?, +) = buildContent( iconResId = commonR.drawable.ic_info, iconTintColor = MaterialTheme.wireColorScheme.error, learnMorePage = SupportPage.ADMINLESS_GROUP_DELETE, ) { - val is24Hour = DateFormat.is24HourFormat(LocalContext.current) + val is24Hour = is24HourFormat ?: DateFormat.is24HourFormat(LocalContext.current) val markdownTextStyle = DefaultMarkdownTextStyle.copy( normalColor = MaterialTheme.wireColorScheme.error, boldColor = MaterialTheme.wireColorScheme.error @@ -632,7 +672,6 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this ) } } -} private fun AnnotatedString.Builder.appendVerticalSpace() = withStyle(ParagraphStyle()) { append(" ") } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt index 76626329d67..f20436ab41c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/preview/PreviewSystemMessageItem.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.conversations.messages.preview import androidx.compose.runtime.Composable +import com.wire.android.ui.home.conversations.messages.item.AdminlessGroupDeleteReminderItem import com.wire.android.ui.home.conversations.messages.item.SystemMessageItem import com.wire.android.ui.home.conversations.mock.mockMessageWithKnock import com.wire.android.ui.home.conversations.mock.mockUsersUITexts @@ -490,14 +491,10 @@ fun PreviewSystemMessageConversationMessageAppsAccessEnabled() { @PreviewMultipleThemes @Composable -fun PreviewSystemMessageAdminlessDeleteReminder() { +fun PreviewAdminlessGroupDeleteReminder() { WireTheme { - SystemMessageItem( - message = mockMessageWithKnock.copy( - messageContent = UIMessageContent.SystemMessage.AdminlessDeleteReminder( - deletionScheduledFor = Instant.parse("2026-04-23T12:00:00Z") - ) - ) + AdminlessGroupDeleteReminderItem( + deletionScheduledFor = Instant.parse("2026-04-23T12:00:00Z") ) } } 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 09df03f8b10..fe68b97abbd 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 @@ -651,10 +651,6 @@ sealed interface UIMessageContent { val isAccessEnabled: Boolean ) : SystemMessage - @Serializable - data class AdminlessDeleteReminder( - val deletionScheduledFor: Instant - ) : SystemMessage } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8d49cf15a9..0e6ae850e79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1904,6 +1904,7 @@ In group conversations, the group admin can overwrite this setting. Added apps have access to the content of this conversation. You were promoted to group admin This group will be automatically deleted on %1$s, as there are no eligible group admins. + Will be deleted soon More information about this backend diff --git a/app/src/test/kotlin/com/wire/android/mapper/ConversationMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/ConversationMapperTest.kt new file mode 100644 index 00000000000..86958fe1bd4 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/mapper/ConversationMapperTest.kt @@ -0,0 +1,88 @@ +/* + * 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.mapper + +import com.wire.android.R +import com.wire.android.framework.TestConversationDetails +import com.wire.android.framework.TestMessage +import com.wire.android.model.BadgeEventType +import com.wire.android.ui.home.conversations.model.UILastMessageContent +import com.wire.android.ui.home.conversationslist.model.ConversationItem +import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.UiTextResolver +import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents +import com.wire.kalium.logic.data.message.MessagePreviewContent +import com.wire.kalium.logic.data.message.UnreadEventType +import io.mockk.mockk +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ConversationMapperTest { + + @Test + fun givenActiveConversationWithAdminlessDeletion_whenMapping_thenReminderOverridesDraftAndBadgeIsPreserved() { + val item = conversation(archived = false).toConversationItem( + userTypeMapper = mockk(relaxed = true), + uiTextResolver = uiTextResolver, + selfUserTeamId = null, + ) as ConversationItem.Group.Regular + + val preview = assertInstanceOf(UILastMessageContent.TextMessage::class.java, item.lastMessageContent) + assertEquals(R.string.last_message_adminless_delete_reminder, (preview.messageBody.message as UIText.StringResource).resId) + assertEquals(BadgeEventType.UnreadMention, item.badgeEventType) + } + + @Test + fun givenArchivedConversationWithAdminlessDeletion_whenMapping_thenReminderPreviewIsNotUsed() { + val item = conversation(archived = true).toConversationItem( + userTypeMapper = mockk(relaxed = true), + uiTextResolver = uiTextResolver, + selfUserTeamId = null, + ) as ConversationItem.Group.Regular + + val preview = assertInstanceOf(UILastMessageContent.TextMessage::class.java, item.lastMessageContent) + val message = preview.messageBody.message as UIText.PluralResource + assertNotEquals(R.string.last_message_adminless_delete_reminder, message.resId) + assertEquals(BadgeEventType.UnreadMention, item.badgeEventType) + } + + private fun conversation(archived: Boolean) = ConversationDetailsWithEvents( + conversationDetails = TestConversationDetails.GROUP.copy( + conversation = TestConversationDetails.GROUP.conversation.copy( + archived = archived, + adminlessGroupDeletionTimestamp = Instant.parse("2026-08-30T12:00:00Z"), + ) + ), + unreadEventCount = mapOf(UnreadEventType.MENTION to 2), + lastMessage = TestMessage.PREVIEW.copy(content = MessagePreviewContent.Draft("draft")), + ) + + 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" + } +} diff --git a/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt index 1eb8e62dced..6911ac9a13b 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/SystemMessageContentMapperTest.kt @@ -44,7 +44,6 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import java.util.Locale -import kotlinx.datetime.Instant @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) @@ -76,18 +75,6 @@ class SystemMessageContentMapperTest { assertTrue(uiContent is SystemMessage.ConversationMessageTimerDeactivated) } - @Test - fun givenAdminlessDeleteReminder_whenMappingToSystemMessage_thenScheduledDeletionIsPreserved() = runTest { - val (_, mapper) = Arrangement().arrange() - val scheduledDeletion = Instant.parse("2026-04-23T12:00:00Z") - val content = MessageContent.AdminlessDeleteReminder(scheduledDeletion) - - val uiContent = mapper.mapMessage(TestMessage.SYSTEM_MESSAGE.copy(content = content), emptyList()) - - assertIs(uiContent!!) - assertEquals(scheduledDeletion, uiContent.deletionScheduledFor) - } - @Test fun givenMemberDetails_whenMappingToSystemMessageMemberName_thenCorrectValuesShouldBeReturned() = runTest { // Given diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index 91ecc8cda8a..95dec7702bf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant import okio.Path @@ -188,6 +189,18 @@ class ConversationMessagesViewModelArrangement { coEvery { conversationAudioMessagePlayer.observableAudioMessagesState } returns flowOf() } + fun withAdminlessGroupDeletionTimestampFlow(timestamps: Flow) = apply { + coEvery { observeConversationDetails(any()) } returns timestamps.map { timestamp -> + ObserveConversationDetailsUseCase.Result.Success( + ConversationDetails.Group.Regular( + conversation = conversationStub.copy(adminlessGroupDeletionTimestamp = timestamp), + isSelfUserMember = true, + selfRole = Conversation.Member.Role.Member, + ) + ) + } + } + fun withSuccessfulOpenAssetMessage( assetMimeType: String, assetName: String, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index d8bed4ba394..15c646414e2 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -54,10 +54,12 @@ import com.wire.kalium.network.NetworkState import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant import okio.Path.Companion.toPath import com.wire.android.assertions.shouldBeEqualTo import com.wire.android.ui.home.conversations.messages.item.withOfflineIndicator @@ -71,6 +73,26 @@ import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(NavigationTestExtension::class) class ConversationMessagesViewModelTest { + @Test + fun givenAdminlessDeletionTimestampChanges_whenObservingConversation_thenStateIsUpdated() = runTest { + val deletionTimestamps = MutableStateFlow(null) + val scheduledDeletion = Instant.parse("2026-08-30T12:00:00Z") + val (_, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withAdminlessGroupDeletionTimestampFlow(deletionTimestamps) + .arrange() + advanceUntilIdle() + assertEquals(null, viewModel.conversationViewState.adminlessGroupDeletionTimestamp) + + deletionTimestamps.value = scheduledDeletion + advanceUntilIdle() + assertEquals(scheduledDeletion, viewModel.conversationViewState.adminlessGroupDeletionTimestamp) + + deletionTimestamps.value = null + advanceUntilIdle() + assertEquals(null, viewModel.conversationViewState.adminlessGroupDeletionTimestamp) + } + @Test fun `given an message ID, when downloading or fetching into internal storage, then should get message details by ID`() = runTest { val message = TestMessage.ASSET_MESSAGE diff --git a/kalium b/kalium index 7056798774e..ee924d7334c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 7056798774ebcd741dd5a605d108f9bb26bf52b4 +Subproject commit ee924d7334c5cecc621a21835f63fb0386ff90f5