refactor: KMP Migration, Messaging Modularization, and Handshake Robustness (#4631)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-24 06:37:33 -06:00 committed by GitHub
parent b3f88bd94f
commit d408964f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
144 changed files with 1460 additions and 664 deletions

View file

@ -0,0 +1,182 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.ui.contact
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@Suppress("LongMethod", "LongParameterList")
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveContactsScreen(
navController: NavHostController,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange
val handleBack: () -> Unit = {
val currentEntry = navController.currentBackStackEntry
val isContactsRoute = currentEntry?.destination?.hasRoute<ContactsRoutes.Contacts>() == true
// Check if we navigated here from another screen (e.g., from Nodes or Map)
val previousEntry = navController.previousBackStackEntry
val isFromDifferentGraph = previousEntry?.destination?.hasRoute<ContactsRoutes.ContactsGraph>() == false
if (isFromDifferentGraph && !isContactsRoute) {
// Navigate back via NavController to return to the previous screen (e.g. Node Details)
navController.navigateUp()
} else {
// Close the detail pane within the adaptive scaffold
scope.launch { navigator.navigateBack(backNavigationBehavior) }
}
}
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
LaunchedEffect(initialContactKey) {
if (initialContactKey != null) {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, initialContactKey)
}
}
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents.collect { event ->
if (
event is ScrollToTopEvent.ConversationsTabPressed &&
navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail
) {
if (navigator.canNavigateBack(backNavigationBehavior)) {
navigator.navigateBack(backNavigationBehavior)
} else {
navigator.navigateTo(ListDetailPaneScaffoldRole.List)
}
}
}
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ContactsScreen(
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = onHandleScannedUri,
onClearSharedContactRequested = onClearSharedContactRequested,
onClearRequestChannelUrl = onClearRequestChannelUrl,
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
launchSingleTop = true
restoreState = true
}
},
onNavigateToMessages = { contactKey ->
scope.launch { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contactKey) }
},
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
activeContactKey = navigator.currentDestination?.contentKey,
)
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
key(contactKey) {
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { navController.navigate(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
}
} ?: PlaceholderScreen()
}
},
)
}
@Composable
private fun PlaceholderScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(
imageVector = MeshtasticIcons.Conversations,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(Res.string.conversations),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View file

@ -0,0 +1,239 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.ui.contact
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeOff
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.Contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ChannelSet
@Suppress("LongMethod")
@Composable
fun ContactItem(
contact: Contact,
selected: Boolean,
modifier: Modifier = Modifier,
isActive: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onNodeChipClick: () -> Unit = {},
channels: ChannelSet? = null,
) = with(contact) {
val isOutlined = !selected && !isActive
val colors =
if (isOutlined) {
CardDefaults.outlinedCardColors(containerColor = Color.Transparent)
} else {
val containerColor = if (selected) Color.Gray else MaterialTheme.colorScheme.surfaceVariant
CardDefaults.cardColors(containerColor = containerColor)
}
val border =
if (isOutlined) {
CardDefaults.outlinedCardBorder()
} else {
null
}
Card(
modifier =
modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.semantics { contentDescription = shortName },
shape = RoundedCornerShape(12.dp),
colors = colors,
border = border,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
ContactHeader(contact = contact, channels = channels, onNodeChipClick = onNodeChipClick)
ChatMetadata(modifier = Modifier.padding(top = 4.dp), contact = contact)
}
}
}
@Composable
private fun ContactHeader(
contact: Contact,
channels: ChannelSet?,
modifier: Modifier = Modifier,
onNodeChipClick: () -> Unit = {},
) {
val colors =
contact.nodeColors?.let {
AssistChipDefaults.assistChipColors(labelColor = Color(it.first), containerColor = Color(it.second))
} ?: AssistChipDefaults.assistChipColors()
Row(modifier = modifier.padding(0.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(
onClick = onNodeChipClick,
modifier =
Modifier.width(IntrinsicSize.Min).height(32.dp).semantics { contentDescription = contact.shortName },
label = {
Text(
text = contact.shortName,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
)
},
colors = colors,
)
// Show unlock icon for broadcast with default PSK
val isBroadcast = with(contact.contactKey) { getOrNull(1) == '^' || endsWith("^all") || endsWith("^broadcast") }
if (isBroadcast && channels != null) {
val channelIndex = contact.contactKey[0].digitToIntOrNull()
channelIndex?.let { index -> SecurityIcon(channels, index) }
}
Text(
modifier = Modifier.padding(start = 8.dp).weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = contact.longName,
)
Text(
text = contact.lastMessageTime?.let { DateFormatter.formatShortDate(it) }.orEmpty(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier,
)
}
}
private const val UNREAD_MESSAGE_LIMIT = 99
@Composable
private fun ChatMetadata(contact: Contact, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = contact.lastMessageText.orEmpty(),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
AnimatedVisibility(visible = contact.isMuted) {
Icon(
modifier = Modifier.padding(start = 4.dp).size(20.dp),
imageVector = Icons.AutoMirrored.TwoTone.VolumeOff,
contentDescription = null,
)
}
AnimatedVisibility(modifier = Modifier.padding(start = 4.dp), visible = contact.unreadCount > 0) {
val text =
if (contact.unreadCount > UNREAD_MESSAGE_LIMIT) {
"$UNREAD_MESSAGE_LIMIT+"
} else {
contact.unreadCount.toString()
}
Text(
text = text,
modifier =
Modifier.background(MaterialTheme.colorScheme.primary, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimary,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
}
}
}
@PreviewLightDark
@Composable
private fun ContactItemPreview() {
val sampleContact =
Contact(
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
lastMessageTime = 0L,
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
)
val contactsList =
listOf(
sampleContact,
sampleContact.copy(
shortName = "0",
longName = "A very long contact name that should be truncated.",
lastMessageTime = 1000L,
),
)
AppTheme { Column { contactsList.forEach { contact -> ContactItem(contact = contact, selected = false) } } }
}

View file

@ -0,0 +1,616 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.ui.contact
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.close_selection
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.currently
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_selection
import org.meshtastic.core.resources.mute_1_week
import org.meshtastic.core.resources.mute_8_hours
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_status_always
import org.meshtastic.core.resources.mute_status_muted_for_days
import org.meshtastic.core.resources.mute_status_muted_for_hours
import org.meshtastic.core.resources.mute_status_unmuted
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.select_all
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
onHandleScannedUri: (Uri, onInvalid: () -> Unit) -> Unit,
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel<ContactsViewModel>(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeContactKey: String? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
var showMuteDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
// State for managing selected contacts
val selectedContactKeys = remember { mutableStateListOf<String>() }
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
// State for contacts list
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
// Create channel placeholders (always show broadcast contacts, even when empty)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val channelPlaceholders =
remember(channels.settings.size) {
(0 until channels.settings.size).map { ch ->
Contact(
contactKey = "$ch^all",
shortName = "$ch",
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
lastMessageTime = null,
lastMessageText = "",
unreadCount = 0,
messageCount = 0,
isMuted = false,
isUnmessageable = false,
nodeColors = null,
)
}
}
val contactsListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents?.collectLatest { event ->
if (event is ScrollToTopEvent.ConversationsTabPressed) {
contactsListState.smartScrollToTop(coroutineScope)
}
}
}
// Derived state for selected contacts and count
val selectedContacts =
remember(pagedContacts.itemCount, selectedContactKeys) {
(0 until pagedContacts.itemCount)
.mapNotNull { pagedContacts[it] }
.filter { it.contactKey in selectedContactKeys }
}
// Get message count directly from repository for selected contacts
var selectedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) {
selectedCount = viewModel.getTotalMessageCount(selectedContactKeys.toList())
}
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
requestChannelSet?.let { ScannedQrCodeDialog(it, onDismiss = { onClearRequestChannelUrl() }) }
// Callback functions for item interaction
val onContactClick: (Contact) -> Unit = { contact ->
if (isSelectionModeActive) {
// If in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
} else {
// If not in selection mode, navigate to messages
onNavigateToMessages(contact.contactKey)
}
}
val onNodeChipClick: (Contact) -> Unit = { contact ->
if (contact.contactKey.contains("!")) {
// if it's a node, look up the nodeNum including the !
val nodeKey = contact.contactKey.substring(1)
val node = viewModel.getNode(nodeKey)
onNavigateToNodeDetails(node.num)
} else {
// Channels
}
}
val onContactLongClick: (Contact) -> Unit = { contact ->
// Enter selection mode and select the item on long press
if (!isSelectionModeActive) {
selectedContactKeys.add(contact.contactKey)
} else {
// If already in selection mode, toggle selection
if (selectedContactKeys.contains(contact.contactKey)) {
selectedContactKeys.remove(contact.contactKey)
} else {
selectedContactKeys.add(contact.contactKey)
}
}
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.conversations),
ourNode = ourNode,
showNodeChip = ourNode != null && connectionState.isConnected(),
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = { onClickNodeChip(it.num) },
)
},
floatingActionButton = {
if (connectionState.isConnected()) {
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uri ->
onHandleScannedUri(uri) { scope.launch { context.showToast(Res.string.channel_invalid) } }
},
onShareChannels = onNavigateToShare,
onDismissSharedContact = { onClearSharedContactRequested() },
isContactContext = true,
)
}
},
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
if (isSelectionModeActive) {
// Display selection toolbar when in selection mode
SelectionToolbar(
selectedCount = selectedContactKeys.size,
onCloseSelection = { selectedContactKeys.clear() },
onMuteSelected = { showMuteDialog = true },
onDeleteSelected = { showDeleteDialog = true },
onSelectAll = {
selectedContactKeys.clear()
selectedContactKeys.addAll(
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
)
},
isAllMuted = isAllMuted, // Pass the derived state
)
}
ContactListViewPaged(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
activeContactKey = activeContactKey,
onClick = onContactClick,
onLongClick = onContactLongClick,
onNodeChipClick = onNodeChipClick,
listState = contactsListState,
channels = channels,
)
}
}
if (showDeleteDialog) {
DeleteConfirmationDialog(
selectedCount = selectedCount,
onDismiss = { showDeleteDialog = false },
onConfirm = {
showDeleteDialog = false
viewModel.deleteContacts(selectedContactKeys.toList())
selectedContactKeys.clear()
},
)
}
// Get contact settings for the dialog
val contactSettings by viewModel.getContactSettings().collectAsStateWithLifecycle(initialValue = emptyMap())
if (showMuteDialog) {
MuteNotificationsDialog(
selectedContactKeys = selectedContactKeys.toList(),
contactSettings = contactSettings,
onDismiss = { showMuteDialog = false },
onConfirm = { muteUntil ->
showMuteDialog = false
viewModel.setMuteUntil(selectedContactKeys.toList(), muteUntil)
selectedContactKeys.clear()
},
)
}
}
@Suppress("LongMethod")
@Composable
private fun MuteNotificationsDialog(
selectedContactKeys: List<String>,
contactSettings: Map<String, ContactSettings>,
onDismiss: () -> Unit,
onConfirm: (Long) -> Unit, // Lambda to handle the confirmed mute duration
) {
// Options for mute duration
val muteOptions = remember {
listOf(
Res.string.unmute to 0L,
Res.string.mute_8_hours to TimeConstants.EIGHT_HOURS.inWholeMilliseconds,
Res.string.mute_1_week to 7.days.inWholeMilliseconds,
Res.string.mute_always to Long.MAX_VALUE,
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableIntStateOf(2) } // Default to "Always"
MeshtasticDialog(
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
titleRes = Res.string.mute_notifications,
confirmTextRes = Res.string.okay,
onConfirm = {
val selectedMuteDuration = muteOptions[selectedOptionIndex].second
onConfirm(selectedMuteDuration)
onDismiss() // Dismiss the dialog after confirming
},
dismissTextRes = Res.string.cancel,
text = {
Column {
// Show current mute status
selectedContactKeys.forEach { contactKey ->
contactSettings[contactKey]?.let { settings ->
val now = nowMillis
val statusText =
when {
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {
val remaining = settings.muteUntil - now
if (remaining > 0) {
val (days, hours) = formatMuteRemainingTime(remaining)
if (days >= 1) {
stringResource(Res.string.mute_status_muted_for_days, days, hours)
} else {
stringResource(Res.string.mute_status_muted_for_hours, hours)
}
} else {
stringResource(Res.string.mute_status_unmuted)
}
}
settings.muteUntil == Long.MAX_VALUE -> stringResource(Res.string.mute_status_always)
else -> stringResource(Res.string.mute_status_unmuted)
}
Text(
text = stringResource(Res.string.currently) + " " + statusText,
modifier = Modifier.padding(bottom = 8.dp),
)
}
}
muteOptions.forEachIndexed { index, (stringRes, _) ->
val isSelected = index == selectedOptionIndex
val text = stringResource(stringRes)
Row(
modifier =
Modifier.fillMaxWidth()
.selectable(selected = isSelected, onClick = { selectedOptionIndex = index })
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = isSelected, onClick = { selectedOptionIndex = index })
Text(text = text, modifier = Modifier.padding(start = 8.dp))
}
}
}
},
)
}
@Composable
private fun DeleteConfirmationDialog(
selectedCount: Int, // Number of items to be deleted
onDismiss: () -> Unit,
onConfirm: () -> Unit, // Lambda to handle the delete action
) {
val deleteMessage =
pluralStringResource(
Res.plurals.delete_messages,
selectedCount,
selectedCount, // Pass the count as a format argument
)
MeshtasticTextDialog(
titleRes = Res.string.are_you_sure,
message = deleteMessage,
confirmTextRes = Res.string.delete,
onConfirm = {
onConfirm()
onDismiss() // Dismiss the dialog after confirming
},
onDismiss = onDismiss,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionToolbar(
selectedCount: Int,
onCloseSelection: () -> Unit,
onMuteSelected: () -> Unit,
onDeleteSelected: () -> Unit,
onSelectAll: () -> Unit,
isAllMuted: Boolean,
) {
TopAppBar(
title = { Text(text = "$selectedCount") },
navigationIcon = {
IconButton(onClick = onCloseSelection) {
Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close_selection))
}
},
actions = {
IconButton(onClick = onMuteSelected) {
Icon(
imageVector =
if (isAllMuted) {
MeshtasticIcons.VolumeUpTwoTone
} else {
MeshtasticIcons.VolumeMuteTwoTone
},
contentDescription =
if (isAllMuted) {
"Unmute selected"
} else {
"Mute selected"
},
)
}
IconButton(onClick = onDeleteSelected) {
Icon(MeshtasticIcons.Delete, contentDescription = stringResource(Res.string.delete_selection))
}
IconButton(onClick = onSelectAll) {
Icon(MeshtasticIcons.SelectAll, contentDescription = stringResource(Res.string.select_all))
}
},
)
}
@Composable
private fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
channels: ChannelSet? = null,
) {
val haptic = LocalHapticFeedback.current
Box(modifier = modifier.fillMaxSize()) {
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else {
ContactListContentInternal(
contacts = contacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
channels = channels,
haptic = haptic,
)
}
}
}
@Composable
private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: ChannelSet?,
haptic: HapticFeedback,
modifier: Modifier = Modifier,
) {
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
contactListPlaceholdersItems(
placeholders = visiblePlaceholders,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
activeContactKey = activeContactKey,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptic = haptic,
)
contactListAppendLoadingItem(contacts)
}
}
private fun LazyListScope.contactListPlaceholdersItems(
placeholders: List<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptic: HapticFeedback,
) {
items(count = placeholders.size, key = { index -> placeholders[index].contactKey }) { index ->
val contact = placeholders[index]
ContactItem(
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
private fun LazyListScope.contactListPagedItems(
contacts: LazyPagingItems<Contact>,
selectedList: List<String>,
activeContactKey: String?,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: ChannelSet?,
haptic: HapticFeedback,
) {
items(count = contacts.itemCount, key = { index -> contacts[index]?.contactKey ?: index }) { index ->
contacts[index]?.let { contact ->
ContactItem(
contact = contact,
selected = selectedList.contains(contact.contactKey),
isActive = contact.contactKey == activeContactKey,
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick(contact)
},
onNodeChipClick = { onNodeChipClick(contact) },
channels = channels,
)
}
}
}
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
if (contacts.loadState.append is LoadState.Loading) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
}
@Composable
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> = remember(contacts.itemCount, channelPlaceholders) {
val pagedKeys = (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet()
channelPlaceholders.filter { it.contactKey !in pagedKeys }
}

View file

@ -0,0 +1,216 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
import kotlin.collections.map as collectionsMap
@HiltViewModel
class ContactsViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
serviceRepository: ServiceRepository,
) : ViewModel() {
val ourNodeInfo = nodeRepository.ourNodeInfo
val connectionState = serviceRepository.connectionState
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
// Combine node info and myId to reduce argument count in subsequent combines
private val identityFlow: Flow<Pair<MyNodeEntity?, String?>> =
combine(nodeRepository.myNodeInfo, nodeRepository.myId) { info, id -> Pair(info, id) }
/**
* Non-paginated contact list.
*
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
* uses [contactListPaged] instead for better performance with large contact lists.
*
* @see contactListPaged for the paginated version used in ContactsScreen
*/
val contactList =
combine(identityFlow, packetRepository.getContacts(), channels, packetRepository.getContactSettings()) {
identity,
contacts,
channelSet,
settings,
->
val (myNodeInfo, myId) = identity
val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList()
// Add empty channel placeholders (always show Broadcast contacts, even when empty)
val placeholder =
(0 until channelSet.settings.size).associate { ch ->
val contactKey = "$ch${DataPacket.ID_BROADCAST}"
val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch)
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
}
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val userId = if (fromLocal) data.to else data.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val shortName = user.short_name
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
user.long_name
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
lastMessageTime = if (data.time != 0L) data.time else null,
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
.stateInWhileSubscribed(initialValue = emptyList())
val contactListPaged: Flow<PagingData<Contact>> =
combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings ->
val (myNodeInfo, myId) = identity
ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId)
}
.flatMapLatest { params ->
val channelSet = params.channelSet
val settings = params.settings
val myId = params.myId
packetRepository.getContactsPaged().map { pagingData ->
pagingData.map { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = (data.from == DataPacket.ID_LOCAL || (myId != null && data.from == myId))
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val userId = if (fromLocal) data.to else data.from
val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST)
val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
val shortName = user.short_name
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: "Channel ${data.channel}"
} else {
user.long_name
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) data.channel.toString() else shortName,
longName = longName,
lastMessageTime = if (data.time != 0L) data.time else null,
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.is_unmessagable ?: false,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
}
.cachedIn(viewModelScope)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
fun setMuteUntil(contacts: List<String>, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
fun getContactSettings() = packetRepository.getContactSettings()
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
/**
* Get the total message count for a list of contact keys. This queries the repository directly, so it works even if
* contacts aren't loaded in the paged list.
*/
suspend fun getTotalMessageCount(contactKeys: List<String>): Int = if (contactKeys.isEmpty()) {
0
} else {
contactKeys.sumOf { contactKey -> packetRepository.getMessageCount(contactKey) }
}
private data class ContactsPagedParams(
val myNodeNum: Int?,
val channelSet: ChannelSet,
val settings: Map<String, ContactSettings>,
val myId: String?,
)
}

View file

@ -0,0 +1,131 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.ui.sharing
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sample_message
import org.meshtastic.core.resources.share
import org.meshtastic.core.resources.share_to
import org.meshtastic.core.resources.some_username
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.messaging.ui.contact.ContactItem
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@Composable
fun ShareScreen(viewModel: ContactsViewModel = hiltViewModel(), onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
ShareScreen(contacts = contactList, onConfirm = onConfirm, onNavigateUp = onNavigateUp)
}
@Composable
fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigateUp: () -> Unit) {
var selectedContact by remember { mutableStateOf("") }
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.share_to),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(contacts, key = { index, contact -> "${contact.contactKey}#$index" }) { _, contact ->
val selected = contact.contactKey == selectedContact
ContactItem(
contact = contact,
selected = selected,
onClick = { selectedContact = contact.contactKey },
)
}
}
Button(
onClick = { onConfirm(selectedContact) },
modifier = Modifier.fillMaxWidth().padding(24.dp),
enabled = selectedContact.isNotEmpty(),
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Send,
contentDescription = stringResource(Res.string.share),
)
}
}
}
}
@PreviewScreenSizes
@Composable
private fun ShareScreenPreview() {
AppTheme {
ShareScreen(
contacts =
listOf(
Contact(
contactKey = "0^all",
shortName = stringResource(Res.string.some_username),
longName = stringResource(Res.string.unknown_username),
lastMessageTime = 0L,
lastMessageText = stringResource(Res.string.sample_message),
unreadCount = 2,
messageCount = 10,
isMuted = true,
isUnmessageable = false,
),
),
onConfirm = {},
onNavigateUp = {},
)
}
}