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 6c51cd615d9..92d031c2609 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,7 @@ class SystemMessageContentMapper @Inject constructor( content, members ) + } 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 196c555f110..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 @@ -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 @@ -82,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( @@ -136,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, @@ -610,8 +646,33 @@ private fun SystemMessage.buildContent(isWireCellsEnabled: Boolean) = when (this } } } + } +@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 = is24HourFormat ?: 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(" ") } @Composable 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..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 @@ -29,6 +30,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 +488,13 @@ fun PreviewSystemMessageConversationMessageAppsAccessEnabled() { ) } } + +@PreviewMultipleThemes +@Composable +fun PreviewAdminlessGroupDeleteReminder() { + WireTheme { + 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 f60ea33f4b5..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 @@ -650,6 +650,7 @@ sealed interface UIMessageContent { val isAuthorSelfUser: Boolean = false, val isAccessEnabled: Boolean ) : SystemMessage + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41f0727a4e1..0e6ae850e79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1903,6 +1903,8 @@ 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. + 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/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/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..ee924d7334c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e68500602d0914e1263045aadeb69a414517f896 +Subproject commit ee924d7334c5cecc621a21835f63fb0386ff90f5