Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<ConversationId, Call>): Boolean =
joinableCallsByConversationId.containsKey(conversation.id) && isSelfUserMember

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class SystemMessageContentMapper @Inject constructor(
content,
members
)

}

private fun mapConversationConversationAppsAccessChanged(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1023,6 +1025,7 @@ private fun ConversationScreenContent(
conversationId: ConversationId,
bottomSheetVisible: Boolean,
lastUnreadMessageInstant: Instant?,
adminlessGroupDeletionTimestamp: Instant?,
unreadEventCount: Int,
playingAudioMessage: PlayingAudioMessage,
assetStatuses: PersistentMap<String, MessageAssetStatus>,
Expand Down Expand Up @@ -1085,6 +1088,7 @@ private fun ConversationScreenContent(
lazyPagingMessages = lazyPagingMessages,
lazyListState = lazyListState,
lastUnreadMessageInstant = lastUnreadMessageInstant,
adminlessGroupDeletionTimestamp = adminlessGroupDeletionTimestamp,
playingAudioMessage = playingAudioMessage,
assetStatuses = assetStatuses,
onUpdateConversationReadDate = onUpdateConversationReadDate,
Expand Down Expand Up @@ -1183,6 +1187,7 @@ fun MessageList(
lazyPagingMessages: LazyPagingItems<UIMessage>,
lazyListState: LazyListState,
lastUnreadMessageInstant: Instant?,
adminlessGroupDeletionTimestamp: Instant?,
playingAudioMessage: PlayingAudioMessage,
assetStatuses: PersistentMap<String, MessageAssetStatus>,
onUpdateConversationReadDate: (Instant) -> Unit,
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import kotlinx.datetime.Instant
data class ConversationMessagesViewState(
val messages: Flow<PagingData<UIMessage>> = emptyFlow(),
val firstUnreadInstant: Instant? = null,
val adminlessGroupDeletionTimestamp: Instant? = null,
val firstUnreadEventIndex: Int = 0,
val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden,
val playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -486,3 +488,13 @@ fun PreviewSystemMessageConversationMessageAppsAccessEnabled() {
)
}
}

@PreviewMultipleThemes
@Composable
fun PreviewAdminlessGroupDeleteReminder() {
WireTheme {
AdminlessGroupDeleteReminderItem(
deletionScheduledFor = Instant.parse("2026-04-23T12:00:00Z")
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ sealed interface UIMessageContent {
val isAuthorSelfUser: Boolean = false,
val isAccessEnabled: Boolean
) : SystemMessage

}
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1903,6 +1903,8 @@ In group conversations, the group admin can overwrite this setting.</string>
<string name="label_system_message_apps_access_disabled_by_other">%1$s disabled **apps** for this conversation</string>
<string name="label_system_message_apps_access_enabled_disclaimer">Added apps have access to the content of this conversation.</string>
<string name="label_system_message_admin_role_assigned">You were promoted to group admin</string>
<string name="label_system_message_adminless_delete_reminder">This group will be automatically deleted on %1$s, as there are no eligible group admins.</string>
<string name="last_message_adminless_delete_reminder">Will be deleted soon</string>
<string name="more_information_about_this_server">More information about this backend</string>

<!-- Pending messages service notification -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading