diff --git a/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt index b9c970e29dc..c45071cf22a 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/UserDataStore.kt @@ -23,6 +23,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.wire.kalium.logic.data.user.UserAvailabilityStatus @@ -60,6 +61,12 @@ class UserDataStore(private val context: Context, userId: UserId) { context.dataStore.edit { it.clear() } } + fun lastBackupDateSeconds(): Flow = context.dataStore.data.map { it[LAST_BACKUP_DATE_INSTANT] } + + suspend fun setLastBackupDateSeconds(timeStampSeconds: Long) { + context.dataStore.edit { it[LAST_BACKUP_DATE_INSTANT] = timeStampSeconds } + } + private fun getStatusKey(status: UserAvailabilityStatus) = when (status) { UserAvailabilityStatus.AVAILABLE -> SHOW_STATUS_RATIONALE_AVAILABLE UserAvailabilityStatus.BUSY -> SHOW_STATUS_RATIONALE_BUSY @@ -81,6 +88,7 @@ class UserDataStore(private val context: Context, userId: UserId) { private val SHOW_STATUS_RATIONALE_NONE = booleanPreferencesKey("show_status_rationale_none") private val USER_AVATAR_ASSET_ID = stringPreferencesKey("user_avatar_asset_id") private val INITIAL_SYNC_COMPLETED = booleanPreferencesKey("initial_sync_completed") + private val LAST_BACKUP_DATE_INSTANT = longPreferencesKey("last_backup_date_instant") } } diff --git a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt index 77383d97501..b9755ba437d 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/MessagePreviewContentMapper.kt @@ -21,7 +21,9 @@ package com.wire.android.mapper import com.wire.android.R import com.wire.android.ui.home.conversations.model.MessageBody import com.wire.android.ui.home.conversations.model.UILastMessageContent +import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.util.ui.UIText +import com.wire.android.util.ui.toUIText import com.wire.kalium.logic.data.conversation.UnreadEventCount import com.wire.kalium.logic.data.message.AssetType import com.wire.kalium.logic.data.message.MessagePreview @@ -254,7 +256,7 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { is WithUser.Text -> UILastMessageContent.SenderWithMessage( sender = userUIText, message = (content as WithUser.Text).messageBody.let { UIText.DynamicString(it) }, - separator = ": " + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" ) is WithUser.Composite -> { @@ -263,7 +265,7 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { UILastMessageContent.SenderWithMessage( sender = userUIText, message = text, - separator = ": " + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" ) } @@ -335,6 +337,12 @@ fun MessagePreview.uiLastMessageContent(): UILastMessageContent { MessagePreviewContent.VerificationChanged.DegradedProteus -> UILastMessageContent.VerificationChanged(R.string.last_message_conversations_verification_degraded_proteus) + is MessagePreviewContent.Draft -> UILastMessageContent.SenderWithMessage( + UIText.StringResource(R.string.label_draft), + (content as MessagePreviewContent.Draft).message.toUIText(), + separator = ":${MarkdownConstants.NON_BREAKING_SPACE}" + ) + Unknown -> UILastMessageContent.None } } diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt index acad24526f9..61043e355f5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/LoginScreen.kt @@ -83,6 +83,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.dialogErrorStrings +import com.wire.android.util.ui.UIText import kotlinx.coroutines.launch @RootNavGraph @@ -344,9 +345,10 @@ data class LoginDialogErrorData( val dismissOnClickOutside: Boolean = true ) -enum class LoginTabItem(@StringRes override val titleResId: Int) : TabItem { +enum class LoginTabItem(@StringRes val titleResId: Int) : TabItem { EMAIL(R.string.login_tab_email), SSO(R.string.login_tab_sso); + override val title: UIText = UIText.StringResource(titleResId) } @Preview diff --git a/app/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt b/app/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt index ae06af94813..77b4a386b13 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/CollapsingTopBarScaffold.kt @@ -53,9 +53,6 @@ import kotlin.math.roundToInt * the lambda receives elevation value for the [topBarHeader] * @param topBarCollapsing collapsing part of the top bar * @param topBarFooter bar under the [topBarCollapsing], moves with it and ends up directly under [topBarHeader] when collapsed - * @param snackbarHost component to host [Snackbar]s that are pushed to be shown via - * [SnackbarHostState.showSnackbar], typically a [SnackbarHost] - * @param content content of the screen * @param bottomBar bottom bar of the screen, typically a [NavigationBar] * @param floatingActionButton Main action button of the screen, typically a [FloatingActionButton] * @param floatingActionButtonPosition position of the FAB on the screen. See [FabPosition]. @@ -63,6 +60,7 @@ import kotlin.math.roundToInt * @param snapOnFling on collapsing fling, only close the collapsible and don't carry the velocity to the scrollable * @param keepElevationWhenCollapsed if true then keep showing elevation also when scrolling children after top bar is already collapsed; * if false then hide elevation when approaching the end of the collapsing and don't show it when scrolling children + * @param content content of the screen */ @OptIn(ExperimentalMaterialApi::class) @Composable @@ -71,16 +69,16 @@ fun CollapsingTopBarScaffold( topBarHeader: @Composable (elevation: Dp) -> Unit, topBarCollapsing: @Composable () -> Unit, topBarFooter: @Composable () -> Unit = {}, - content: @Composable () -> Unit, bottomBar: @Composable () -> Unit = {}, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, isSwipeable: Boolean = true, snapOnFling: Boolean = true, - keepElevationWhenCollapsed: Boolean = false + keepElevationWhenCollapsed: Boolean = false, + content: @Composable () -> Unit, ) { val maxBarElevationPx = with(LocalDensity.current) { maxBarElevation.toPx() } - val swipeableState = rememberSwipeableState(initialValue = State.EXPANDED) + val swipeableState = rememberSwipeableState(initialValue = State.EXPANDED) // TODO: migrate to AnchoredDraggable var nestedOffsetState by rememberSaveable { mutableStateOf(0f) } var collapsingHeight by rememberSaveable { mutableStateOf(0) } val topBarElevationState by remember { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireTabRow.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireTabRow.kt index ec5bfb09cfb..678ab0c2006 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireTabRow.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireTabRow.kt @@ -18,14 +18,14 @@ package com.wire.android.ui.common -import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab import androidx.compose.material3.TabPosition @@ -35,12 +35,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.foundation.pager.PagerState -import com.wire.android.ui.home.conversations.messagedetails.MessageDetailsTabItem import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.UIText import kotlin.math.absoluteValue @Composable @@ -49,7 +47,7 @@ fun WireTabRow( selectedTabIndex: Int, onTabChange: (Int) -> Unit, containerColor: Color = MaterialTheme.colorScheme.background, - divider: @Composable () -> Unit = @Composable { Divider() }, + divider: @Composable () -> Unit = @Composable { HorizontalDivider() }, upperCaseTitles: Boolean = true, modifier: Modifier = Modifier ) { @@ -64,11 +62,7 @@ fun WireTabRow( ) { tabs.forEachIndexed { index, tabItem -> val selected = selectedTabIndex == index - val text = if (tabItem is MessageDetailsTabItem) { - stringResource(id = tabItem.titleResId, tabItem.count) - } else { - stringResource(id = tabItem.titleResId) - }.let { + val text = tabItem.title.asString().let { if (upperCaseTitles) it.uppercase() else it } @@ -108,6 +102,5 @@ fun PagerState.calculateCurrentTab() = // change the tab if we go over half the if (this.currentPageOffsetFraction.absoluteValue > 0.5f) this.targetPage else this.currentPage interface TabItem { - @get:StringRes - val titleResId: Int + val title: UIText } 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 e9f45c78d65..985fba4191d 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 @@ -1103,8 +1103,9 @@ private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { @Preview @Composable fun PreviewConversationScreen() { + val conversationId = ConversationId("value", "domain") val messageComposerViewState = remember { mutableStateOf(MessageComposerViewState()) } - val messageCompositionState = remember { mutableStateOf(MessageComposition.DEFAULT) } + val messageCompositionState = remember { mutableStateOf(MessageComposition(conversationId)) } val conversationScreenState = rememberConversationScreenState() val messageComposerStateHolder = rememberMessageComposerStateHolder( messageComposerViewState = messageComposerViewState, @@ -1117,7 +1118,7 @@ fun PreviewConversationScreen() { messageComposerViewState = messageComposerViewState, conversationCallViewState = ConversationCallViewState(), conversationInfoViewState = ConversationInfoViewState( - conversationId = ConversationId("value", "domain"), + conversationId = conversationId, conversationName = UIText.DynamicString("Some test conversation") ), conversationMessagesViewState = ConversationMessagesViewState(), diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt index cc505c91329..d77fc125db2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt @@ -202,7 +202,7 @@ class MessageComposerViewModel @Inject constructor( fun saveDraft(messageDraft: MessageDraft) { viewModelScope.launch { - saveMessageDraft(conversationId, messageDraft) + saveMessageDraft(messageDraft) } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index bed7cbcf56c..d2ffb0e7076 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -18,24 +18,28 @@ package com.wire.android.ui.home.conversations.details -import com.wire.android.ui.common.snackbar.SwipeableSnackbar import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -47,7 +51,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -67,6 +70,7 @@ import com.wire.android.appLogger import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.CollapsingTopBarScaffold import com.wire.android.ui.common.MLSVerifiedIcon import com.wire.android.ui.common.MoreOptionIcon import com.wire.android.ui.common.ProteusVerifiedIcon @@ -77,6 +81,7 @@ import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.conversation.ConversationSheetContent import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.calculateCurrentTab import com.wire.android.ui.common.dialogs.ArchiveConversationDialog import com.wire.android.ui.common.dimensions @@ -91,7 +96,6 @@ import com.wire.android.ui.destinations.ConversationMediaScreenDestination import com.wire.android.ui.destinations.EditConversationNameScreenDestination import com.wire.android.ui.destinations.EditGuestAccessScreenDestination import com.wire.android.ui.destinations.EditSelfDeletingMessagesScreenDestination -import com.wire.android.ui.destinations.GroupConversationAllParticipantsScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SearchConversationMessagesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination @@ -157,9 +161,6 @@ fun GroupConversationDetailsScreen( conversationSheetContent = viewModel.conversationSheetContent, bottomSheetEventsHandler = viewModel, onBackPressed = navigator::navigateBack, - openFullListPressed = { - navigator.navigate(NavigationCommand(GroupConversationAllParticipantsScreenDestination(viewModel.conversationId))) - }, onProfilePressed = { participant -> when { participant.isSelf -> navigator.navigate(NavigationCommand(SelfUserProfileScreenDestination)) @@ -259,13 +260,12 @@ fun GroupConversationDetailsScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable private fun GroupConversationDetailsContent( conversationSheetContent: ConversationSheetContent?, bottomSheetEventsHandler: GroupConversationDetailsBottomSheetEventsHandler, onBackPressed: () -> Unit, - openFullListPressed: () -> Unit, onProfilePressed: (UIParticipant) -> Unit, onAddParticipantsPressed: () -> Unit, onEditGuestAccess: () -> Unit, @@ -324,9 +324,8 @@ private fun GroupConversationDetailsContent( archiveConversationDialogState.dismiss() legalHoldSubjectDialogState.dismiss() } - - Scaffold( - topBar = { + CollapsingTopBarScaffold( + topBarHeader = { WireCenterAlignedTopAppBar( elevation = elevationState, titleContent = { @@ -340,42 +339,66 @@ private fun GroupConversationDetailsContent( navigationIconType = NavigationIconType.Close, onNavigationPressed = onBackPressed, actions = { MoreOptionIcon(onButtonClicked = openBottomSheet) } - ) { - conversationSheetState.conversationSheetContent?.let { - GroupConversationDetailsTopBarCollapsing( - title = it.title, - conversationId = it.conversationId, - totalParticipants = groupParticipantsState.data.allCount, - isLoading = isLoading, - onSearchConversationMessagesClick = onSearchConversationMessagesClick, - onConversationMediaClick = onConversationMediaClick, - isUnderLegalHold = it.isUnderLegalHold, - onLegalHoldLearnMoreClick = remember { { legalHoldSubjectDialogState.show(Unit) } } - ) - } - WireTabRow( - tabs = GroupConversationDetailsTabItem.entries, - selectedTabIndex = currentTabState, - onTabChange = { scope.launch { pagerState.animateScrollToPage(it) } }, - modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing16x), - divider = {} // no divider + ) + }, + topBarCollapsing = { + conversationSheetState.conversationSheetContent?.let { + GroupConversationDetailsTopBarCollapsing( + title = it.title, + conversationId = it.conversationId, + totalParticipants = groupParticipantsState.data.allCount, + isLoading = isLoading, + onSearchConversationMessagesClick = onSearchConversationMessagesClick, + onConversationMediaClick = onConversationMediaClick, + isUnderLegalHold = it.isUnderLegalHold, + onLegalHoldLearnMoreClick = remember { { legalHoldSubjectDialogState.show(Unit) } } ) } }, - modifier = Modifier.fillMaxHeight(), - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { data -> - SwipeableSnackbar( - hostState = snackbarHostState, - data = data, - onDismiss = { data.dismiss() } - ) - } + topBarFooter = { + WireTabRow( + tabs = GroupConversationDetailsTabItem.entries, + selectedTabIndex = currentTabState, + onTabChange = { scope.launch { pagerState.animateScrollToPage(it) } }, + modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing16x), + divider = {} // no divider ) }, - ) { internalPadding -> + bottomBar = { + AnimatedContent( + targetState = currentTabState, + label = "Conversation details bottom bar crossfade", + transitionSpec = { + val enter = slideInVertically(initialOffsetY = { it }) + val exit = slideOutVertically(targetOffsetY = { it }) + enter.togetherWith(exit) + }, + modifier = Modifier.fillMaxWidth() + ) { currentTabState -> + Surface( + color = MaterialTheme.wireColorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + when (GroupConversationDetailsTabItem.entries[currentTabState]) { + GroupConversationDetailsTabItem.OPTIONS -> { + // no bottom bar for options tab + } + + GroupConversationDetailsTabItem.PARTICIPANTS -> { + if (groupParticipantsState.addParticipantsEnabled) { + Box(modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x)) { + WirePrimaryButton( + text = stringResource(R.string.conversation_details_group_participants_add), + onClick = onAddParticipantsPressed, + ) + } + } + } + } + } + } + } + ) { var focusedTabIndex: Int by remember { mutableStateOf(initialPageIndex) } val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -384,8 +407,7 @@ private fun GroupConversationDetailsContent( HorizontalPager( state = pagerState, modifier = Modifier - .fillMaxWidth() - .padding(internalPadding) + .fillMaxSize() ) { pageIndex -> when (GroupConversationDetailsTabItem.entries[pageIndex]) { GroupConversationDetailsTabItem.OPTIONS -> GroupConversationOptions( @@ -397,8 +419,6 @@ private fun GroupConversationDetailsContent( GroupConversationDetailsTabItem.PARTICIPANTS -> GroupConversationParticipants( groupParticipantsState = groupParticipantsState, - openFullListPressed = openFullListPressed, - onAddParticipantsPressed = onAddParticipantsPressed, onProfilePressed = onProfilePressed, lazyListState = lazyListStates[pageIndex] ) @@ -538,9 +558,10 @@ private fun VerifiedLabel(text: String, color: Color, icon: @Composable RowScope } } -enum class GroupConversationDetailsTabItem(@StringRes override val titleResId: Int) : TabItem { +enum class GroupConversationDetailsTabItem(@StringRes val titleResId: Int) : TabItem { OPTIONS(R.string.conversation_details_options_tab), PARTICIPANTS(R.string.conversation_details_participants_tab); + override val title: UIText = UIText.StringResource(titleResId) } @Preview @@ -551,7 +572,6 @@ fun PreviewGroupConversationDetails() { conversationSheetContent = null, bottomSheetEventsHandler = GroupConversationDetailsBottomSheetEventsHandler.PREVIEW, onBackPressed = {}, - openFullListPressed = {}, onProfilePressed = {}, onAddParticipantsPressed = {}, onLeaveGroup = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index a76d4d5e61a..7fe01a5322f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -102,8 +102,6 @@ class GroupConversationDetailsViewModel @Inject constructor( savedStateHandle, observeConversationMembers, refreshUsersWithoutMetadata ), GroupConversationDetailsBottomSheetEventsHandler { - override val maxNumberOfItems: Int get() = MAX_NUMBER_OF_PARTICIPANTS - private val groupConversationDetailsNavArgs: GroupConversationDetailsNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = groupConversationDetailsNavArgs.conversationId @@ -418,6 +416,5 @@ class GroupConversationDetailsViewModel @Inject constructor( companion object { const val TAG = "GroupConversationDetailsViewModel" - const val MAX_NUMBER_OF_PARTICIPANTS = 4 } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt index d2fd1fef112..4205d4809dc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantList.kt @@ -52,6 +52,7 @@ fun LazyListScope.folderWithElements( ) = folderWithElements( header = header, items = items.associateBy { it.id.toString() }, + animateItemPlacement = false, factory = { ConversationParticipantItem( uiParticipant = it, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt index 84527fba793..43e1344d1f8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipants.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -31,7 +32,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -39,27 +39,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import com.wire.android.BuildConfig -import com.wire.android.R -import com.wire.android.ui.common.button.WirePrimaryButton -import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.rememberBottomBarElevationState import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions -import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes -import com.wire.android.util.ui.stringWithStyledArgs import com.wire.kalium.logic.data.user.SupportedProtocol @Composable fun GroupConversationParticipants( - openFullListPressed: () -> Unit, onProfilePressed: (UIParticipant) -> Unit, - onAddParticipantsPressed: () -> Unit, groupParticipantsState: GroupConversationParticipantsState, lazyListState: LazyListState = rememberLazyListState() ) { @@ -67,43 +59,22 @@ fun GroupConversationParticipants( Column { LazyColumn( state = lazyListState, - modifier = Modifier.weight(weight = 1f, fill = true) + modifier = Modifier.fillMaxSize() ) { item(key = "participants_list_header") { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.wireColorScheme.surface) - .padding(MaterialTheme.wireDimensions.spacing16x) - ) { - Text( - text = context.resources.stringWithStyledArgs( - R.string.conversation_details_participants_info, - MaterialTheme.wireTypography.body01, - MaterialTheme.wireTypography.body02, - MaterialTheme.wireColorScheme.onBackground, - MaterialTheme.wireColorScheme.onBackground, - groupParticipantsState.data.allCount.toString() - ) - ) - if (groupParticipantsState.addParticipantsEnabled) { - WirePrimaryButton( - text = stringResource(R.string.conversation_details_group_participants_add), - fillMaxWidth = true, - onClick = onAddParticipantsPressed, - modifier = Modifier - .fillMaxWidth() - .padding(top = MaterialTheme.wireDimensions.spacing16x), - ) - } - if (BuildConfig.MLS_SUPPORT_ENABLED && BuildConfig.DEVELOPER_FEATURES_ENABLED) { + if (BuildConfig.MLS_SUPPORT_ENABLED && BuildConfig.DEVELOPER_FEATURES_ENABLED) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.wireColorScheme.surface) + .padding(MaterialTheme.wireDimensions.spacing16x) + ) { val groupParticipants = groupParticipantsState.data.allParticipants MLSProgressIndicator( progress = (groupParticipants) .filter { it.supportedProtocolList.contains(SupportedProtocol.MLS) } .size / (groupParticipantsState.data.allCount).toFloat(), modifier = Modifier - .padding(top = dimensions().spacing16x) .background(MaterialTheme.wireColorScheme.surface) ) } @@ -111,19 +82,6 @@ fun GroupConversationParticipants( } participantsFoldersWithElements(context, groupParticipantsState, onProfilePressed) } - if (groupParticipantsState.showAllVisible) { - Surface( - shadowElevation = lazyListState.rememberBottomBarElevationState().value, - color = MaterialTheme.wireColorScheme.background - ) { - Box(modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x)) { - WireSecondaryButton( - text = stringResource(R.string.conversation_details_show_all_participants, groupParticipantsState.data.allCount), - onClick = openFullListPressed - ) - } - } - } } } @@ -161,12 +119,12 @@ fun MLSProgressIndicator( @PreviewMultipleThemes @Composable -fun PreviewGroupConversationParticipants() { - GroupConversationParticipants({}, {}, {}, GroupConversationParticipantsState.PREVIEW) +fun PreviewGroupConversationParticipants() = WireTheme { + GroupConversationParticipants({}, GroupConversationParticipantsState.PREVIEW) } @PreviewMultipleThemes @Composable -fun PreviewMLSProgressIndicator() { +fun PreviewMLSProgressIndicator() = WireTheme { MLSProgressIndicator(0.25F) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsState.kt index 57384dd6c23..3a2dbcbdee9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsState.kt @@ -26,7 +26,6 @@ import com.wire.kalium.logic.data.user.UserId data class GroupConversationParticipantsState( val data: ConversationParticipantsData = ConversationParticipantsData() ) { - val showAllVisible: Boolean get() = data.allParticipantsCount > data.participants.size || data.allAdminsCount > data.admins.size val addParticipantsEnabled: Boolean get() = data.isSelfAnAdmin && !data.isSelfExternalMember companion object { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index ed91d434103..4395bfe2be1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -68,6 +68,7 @@ import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewM import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf @@ -219,9 +220,10 @@ private fun SnackBarMessage(infoMessages: SharedFlow) { } } -enum class ConversationMediaScreenTabItem(@StringRes override val titleResId: Int) : TabItem { +enum class ConversationMediaScreenTabItem(@StringRes val titleResId: Int) : TabItem { PICTURES(R.string.label_conversation_pictures), FILES(R.string.label_conversation_files); + override val title: UIText = UIText.StringResource(titleResId) } @PreviewMultipleThemes diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt index 8bea1f1e87d..2078b36806e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messagedetails/MessageDetailsScreen.kt @@ -58,6 +58,7 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.ui.UIText import kotlinx.coroutines.launch @RootNavGraph @@ -108,13 +109,16 @@ private fun MessageDetailsScreenContent( onReactionsLearnMore: () -> Unit, onReadReceiptsLearnMore: () -> Unit ) { - val tabItems = provideMessageDetailsTabItems( - messageDetailsState = messageDetailsState, - isSelfMessage = messageDetailsState.isSelfMessage - ) + val tabItems by remember(messageDetailsState) { + derivedStateOf { + val reactions = MessageDetailsTabItem.Reactions(messageDetailsState.reactionsData.reactions.map { it.value.size }.sum()) + val readReceipts = MessageDetailsTabItem.ReadReceipts(messageDetailsState.readReceiptsData.readReceipts.size) + if (messageDetailsState.isSelfMessage) listOf(reactions, readReceipts) else listOf(reactions) + } + } val scope = rememberCoroutineScope() - val lazyListStates: List = MessageDetailsTab.values().map { rememberLazyListState() } - val initialPageIndex = MessageDetailsTab.REACTIONS.ordinal + val lazyListStates: List = tabItems.map { rememberLazyListState() } + val initialPageIndex = tabItems.indexOfFirst { it is MessageDetailsTabItem.Reactions } val pagerState = rememberPagerState(initialPage = initialPageIndex, pageCount = { tabItems.size }) val maxAppBarElevation = MaterialTheme.wireDimensions.topBarShadowElevation val currentTabState by remember { derivedStateOf { pagerState.calculateCurrentTab() } } @@ -150,14 +154,14 @@ private fun MessageDetailsScreenContent( .fillMaxWidth() .padding(internalPadding) ) { pageIndex -> - when (MessageDetailsTab.entries[pageIndex]) { - MessageDetailsTab.REACTIONS -> MessageDetailsReactions( + when (tabItems[pageIndex]) { + is MessageDetailsTabItem.Reactions -> MessageDetailsReactions( reactionsData = messageDetailsState.reactionsData, lazyListState = lazyListStates[pageIndex], onReactionsLearnMore = onReactionsLearnMore ) - MessageDetailsTab.READ_RECEIPTS -> MessageDetailsReadReceipts( + is MessageDetailsTabItem.ReadReceipts -> MessageDetailsReadReceipts( readReceiptsData = messageDetailsState.readReceiptsData, lazyListState = lazyListStates[pageIndex], onReadReceiptsLearnMore = onReadReceiptsLearnMore @@ -176,29 +180,8 @@ private fun MessageDetailsScreenContent( } } -enum class MessageDetailsTab(@StringRes override val titleResId: Int) : TabItem { - REACTIONS(R.string.message_details_reactions_tab), - READ_RECEIPTS(R.string.message_details_read_receipts_tab) +sealed class MessageDetailsTabItem(@StringRes val titleResId: Int, open val count: Int) : TabItem { + override val title: UIText = UIText.StringResource(titleResId) + data class Reactions(override val count: Int) : MessageDetailsTabItem(R.string.message_details_reactions_tab, count) + data class ReadReceipts(override val count: Int) : MessageDetailsTabItem(R.string.message_details_read_receipts_tab, count) } - -/** - * This method creates a new TabItem (data class) and NOT Enum due to enums not being dynamic and we needing to pass - * the total reactions count into [WireTabRow] - */ -private fun provideMessageDetailsTabItems( - messageDetailsState: MessageDetailsState, - isSelfMessage: Boolean -): List { - val reactions = MessageDetailsTabItem( - titleResId = MessageDetailsTab.REACTIONS.titleResId, - count = messageDetailsState.reactionsData.reactions.map { it.value.size }.sum() - ) - val readReceipts = MessageDetailsTabItem( - titleResId = MessageDetailsTab.READ_RECEIPTS.titleResId, - count = messageDetailsState.readReceiptsData.readReceipts.size - ) - - return if (isSelfMessage) listOf(reactions, readReceipts) else listOf(reactions) -} - -data class MessageDetailsTabItem(@StringRes override val titleResId: Int, val count: Int) : TabItem diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt index f552e5e45ed..044d8a70491 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModel.kt @@ -45,7 +45,7 @@ class MessageDraftViewModel @Inject constructor( private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() val conversationId: QualifiedID = conversationNavArgs.conversationId - var state = mutableStateOf(MessageComposition.DEFAULT.copy(messageTextFieldValue = TextFieldValue(""))) + var state = mutableStateOf(MessageComposition(conversationId)) private set init { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 445c4c56c2c..4945a985b23 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -56,9 +56,9 @@ import com.wire.android.ui.home.conversations.model.messagetypes.image.ImportedI import com.wire.android.ui.markdown.DisplayMention import com.wire.android.ui.markdown.MarkdownConstants.MENTION_MARK import com.wire.android.ui.markdown.MarkdownDocument -import com.wire.android.ui.markdown.MarkdownNode +import com.wire.android.ui.markdown.NodeActions import com.wire.android.ui.markdown.NodeData -import com.wire.android.ui.markdown.toContent +import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @@ -71,11 +71,6 @@ import com.wire.kalium.logic.data.asset.AssetTransferStatus.NOT_FOUND import com.wire.kalium.logic.data.asset.AssetTransferStatus.UPLOAD_IN_PROGRESS import kotlinx.collections.immutable.PersistentList import okio.Path -import org.commonmark.Extension -import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension -import org.commonmark.ext.gfm.tables.TablesExtension -import org.commonmark.node.Document -import org.commonmark.parser.Parser // TODO: Here we actually need to implement some logic that will distinguish MentionLabel with Body of the message, // waiting for the backend to implement mapping logic for the MessageBody @@ -103,19 +98,16 @@ internal fun MessageBody( typography = MaterialTheme.wireTypography, searchQuery = searchQuery, mentions = displayMentions, - onLongClick = onLongClick, - onOpenProfile = onOpenProfile, - onLinkClick = onLinkClick + actions = NodeActions( + onLongClick = onLongClick, + onOpenProfile = onOpenProfile, + onLinkClick = onLinkClick + ) ) - val extensions: List = listOf( - StrikethroughExtension.builder().requireTwoTildes(true).build(), - TablesExtension.create() - ) text?.also { - val document = (Parser.builder().extensions(extensions).build().parse(it) as Document).toContent() as MarkdownNode.Document MarkdownDocument( - document, + it.toMarkdownDocument(), nodeData, clickable ) 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 beca022d320..ea03e70aaa1 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 @@ -28,6 +28,7 @@ import com.wire.android.model.ImageAsset import com.wire.android.model.UserAvatarData import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.messagecomposer.SelfDeletionDuration +import com.wire.android.ui.markdown.MarkdownConstants import com.wire.android.ui.theme.Accent import com.wire.android.util.Copyable import com.wire.android.util.MessageDateTime @@ -195,9 +196,16 @@ sealed class UILastMessageContent { data class TextMessage(val messageBody: MessageBody) : UILastMessageContent() - data class SenderWithMessage(val sender: UIText, val message: UIText, val separator: String = " ") : UILastMessageContent() + data class SenderWithMessage( + val sender: UIText, + val message: UIText, + val separator: String = MarkdownConstants.NON_BREAKING_SPACE + ) : UILastMessageContent() - data class MultipleMessage(val messages: List, val separator: String = " ") : UILastMessageContent() + data class MultipleMessage( + val messages: List, + val separator: String = MarkdownConstants.NON_BREAKING_SPACE + ) : UILastMessageContent() data class Connection(val connectionState: ConnectionState, val userId: UserId) : UILastMessageContent() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt index 91dfb94e0de..2b26681bbc2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt @@ -59,6 +59,7 @@ import com.wire.android.ui.home.newconversation.common.CreateNewGroupButton import com.wire.android.ui.home.newconversation.common.SelectParticipantsButtonsAlwaysEnabled import com.wire.android.ui.home.newconversation.common.SelectParticipantsButtonsRow import com.wire.android.ui.home.newconversation.model.Contact +import com.wire.android.util.ui.UIText import kotlinx.collections.immutable.ImmutableSet import kotlinx.coroutines.launch @@ -237,9 +238,10 @@ fun SearchUsersAndServicesScreen( ) } -enum class SearchPeopleTabItem(@StringRes override val titleResId: Int) : TabItem { +enum class SearchPeopleTabItem(@StringRes val titleResId: Int) : TabItem { PEOPLE(R.string.label_add_member_people), SERVICES(R.string.label_add_member_services); + override val title: UIText = UIText.StringResource(titleResId) } enum class SearchPeopleScreenType { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt index a1fbd594491..9b4b0836bec 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/LastMessageSubtitle.kt @@ -21,44 +21,59 @@ package com.wire.android.ui.home.conversationslist.common import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import com.wire.android.ui.markdown.MarkdownConstants +import com.wire.android.ui.markdown.MarkdownInline +import com.wire.android.ui.markdown.NodeData +import com.wire.android.ui.markdown.getFirstInlines +import com.wire.android.ui.markdown.toMarkdownDocument import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.UIText +import kotlinx.collections.immutable.persistentListOf @Composable fun LastMessageSubtitle(text: UIText) { - Text( - text = text.asString(LocalContext.current.resources), - style = MaterialTheme.wireTypography.subline01.copy( - color = MaterialTheme.wireColorScheme.secondaryText - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + LastMessageMarkdown(text = text.asString()) } @Composable fun LastMessageSubtitleWithAuthor(author: UIText, text: UIText, separator: String) { - Text( - text = "${author.asString(LocalContext.current.resources)}$separator${text.asString(LocalContext.current.resources)}", - style = MaterialTheme.wireTypography.subline01.copy( - color = MaterialTheme.wireColorScheme.secondaryText - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + LastMessageMarkdown(text = text.asString(), leadingText = "${author.asString()}$separator") } @Composable fun LastMultipleMessages(messages: List, separator: String) { - Text( - text = messages.map { it.asString() }.joinToString(separator = separator), - style = MaterialTheme.wireTypography.subline01.copy( - color = MaterialTheme.wireColorScheme.secondaryText - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis + LastMessageMarkdown(text = messages.map { it.asString() }.joinToString(separator = separator)) +} + +@Composable +private fun LastMessageMarkdown(text: String, leadingText: String = "") { + val nodeData = NodeData( + color = MaterialTheme.wireColorScheme.secondaryText, + style = MaterialTheme.wireTypography.subline01, + colorScheme = MaterialTheme.wireColorScheme, + typography = MaterialTheme.wireTypography, + searchQuery = "", + mentions = listOf(), + disableLinks = true ) + + val markdownPreview = text.toMarkdownDocument().getFirstInlines() + val leadingInlines = leadingText.toMarkdownDocument().getFirstInlines()?.children ?: persistentListOf() + + if (markdownPreview != null) { + MarkdownInline( + inlines = leadingInlines.plus(markdownPreview.children), + nodeData = nodeData + ) + } else { + Text( + text = leadingText.replace(MarkdownConstants.NON_BREAKING_SPACE, " ") + text, + style = nodeData.style, + color = nodeData.color, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 4d5fa9a7ffc..eeee01e564a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -255,7 +255,7 @@ private fun BaseComposerPreview( ) ) } - val messageComposition = remember { mutableStateOf(MessageComposition.DEFAULT) } + val messageComposition = remember { mutableStateOf(MessageComposition(ConversationId("value", "domain"))) } val selfDeletionTimer = remember { mutableStateOf(SelfDeletionTimer.Enabled(Duration.ZERO)) } MessageComposer( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt index 65e4052361d..bc3e1162519 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/model/MessageComposition.kt @@ -30,19 +30,13 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.draft.MessageDraft data class MessageComposition( + val conversationId: ConversationId, val messageTextFieldValue: TextFieldValue = TextFieldValue(""), val editMessageId: String? = null, val quotedMessage: UIQuotedMessage.UIQuotedData? = null, val quotedMessageId: String? = null, val selectedMentions: List = emptyList(), ) { - companion object { - val DEFAULT = MessageComposition( - messageTextFieldValue = TextFieldValue(text = ""), - quotedMessage = null, - selectedMentions = emptyList() - ) - } val messageText: String get() = messageTextFieldValue.text @@ -152,6 +146,7 @@ fun MutableState.update(block: (MessageComposition) -> Messa fun MessageComposition.toDraft(): MessageDraft { return MessageDraft( + conversationId = conversationId, text = messageTextFieldValue.text, editMessageId = editMessageId, quotedMessageId = quotedMessageId, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt index 72ef6489e4e..ffcd2674b93 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreScreen.kt @@ -25,13 +25,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -44,6 +47,7 @@ import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState @@ -55,6 +59,7 @@ import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.permission.PermissionDenialType +import com.wire.android.util.time.convertTimestampToDateTime @RootNavGraph @Destination @@ -110,18 +115,15 @@ fun BackupAndRestoreContent( .fillMaxHeight() .padding(internalPadding) ) { - Column( + + backupAndRestoreText( modifier = Modifier .weight(1f) - .fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.settings_backup_info), - style = MaterialTheme.wireTypography.body01, - color = MaterialTheme.wireColorScheme.onBackground, - modifier = Modifier.padding(MaterialTheme.wireDimensions.spacing16x) - ) - } + .padding(MaterialTheme.wireDimensions.spacing16x) + .fillMaxWidth(), + backUpAndRestoreState.lastBackupData + ) + Surface( color = MaterialTheme.wireColorScheme.background, shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation @@ -202,6 +204,60 @@ fun BackupAndRestoreContent( ) } +@Composable +private fun backupAndRestoreText(modifier: Modifier, lastBackupTime: Long?) { + Column( + modifier = modifier + ) { + + val lastBackupText: AnnotatedString = lastBackupTime?.let { timeStamp -> + val applicationContext = LocalContext.current.applicationContext + val (date, time) = convertTimestampToDateTime(timeStamp, context = applicationContext) + + val formattedString = stringResource( + id = R.string.settings_backup_last_backup_date, + date, + time + ) + val spannableString = AnnotatedString.Builder(formattedString) + val dateStartIndex = formattedString.indexOf(date) + spannableString.addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = dateStartIndex, + end = dateStartIndex + date.length + ) + + val timeStartIndex = formattedString.indexOf(time) + spannableString.addStyle( + style = SpanStyle(fontWeight = FontWeight.Bold), + start = timeStartIndex, + end = timeStartIndex + time.length, + ) + + spannableString.toAnnotatedString() + } ?: AnnotatedString(stringResource(id = R.string.settings_backup_last_backup_date_no_time)) + + Text( + text = stringResource(id = R.string.settings_backup_info), + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground + ) + Text( + text = stringResource(id = R.string.settings_backup_last_backup_title), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText, + modifier = Modifier + .padding(top = MaterialTheme.wireDimensions.spacing32x) + ) + Text( + text = lastBackupText, + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing16x) + ) + } +} + @Preview @Composable fun PreviewBackupAndRestoreScreen() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt index 4fef8bfd336..58ce03f39d7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt @@ -26,7 +26,8 @@ data class BackupAndRestoreState( val restoreFileValidation: RestoreFileValidation, val restorePasswordValidation: PasswordValidation, val backupCreationProgress: BackupCreationProgress, - val passwordValidation: ValidatePasswordResult = ValidatePasswordResult.Valid + val lastBackupData: Long?, + val passwordValidation: ValidatePasswordResult ) { data class CreatedBackup(val path: Path, val assetName: String, val assetSize: Long, val isEncrypted: Boolean) @@ -37,34 +38,35 @@ data class BackupAndRestoreState( backupCreationProgress = BackupCreationProgress.InProgress(), restorePasswordValidation = PasswordValidation.NotVerified, passwordValidation = ValidatePasswordResult.Valid, + lastBackupData = null ) } } sealed interface PasswordValidation { - object NotVerified : PasswordValidation - object Entered : PasswordValidation - object NotValid : PasswordValidation - object Valid : PasswordValidation + data object NotVerified : PasswordValidation + data object Entered : PasswordValidation + data object NotValid : PasswordValidation + data object Valid : PasswordValidation } sealed interface BackupCreationProgress { data class Finished(val fileName: String) : BackupCreationProgress data class InProgress(val value: Float = 0f) : BackupCreationProgress - object Failed : BackupCreationProgress + data object Failed : BackupCreationProgress } sealed interface BackupRestoreProgress { - object Finished : BackupRestoreProgress + data object Finished : BackupRestoreProgress data class InProgress(val value: Float = 0f) : BackupRestoreProgress - object Failed : BackupRestoreProgress + data object Failed : BackupRestoreProgress } sealed class RestoreFileValidation { - object Initial : RestoreFileValidation() - object ValidNonEncryptedBackup : RestoreFileValidation() - object IncompatibleBackup : RestoreFileValidation() - object WrongBackup : RestoreFileValidation() - object GeneralFailure : RestoreFileValidation() - object PasswordRequired : RestoreFileValidation() + data object Initial : RestoreFileValidation() + data object ValidNonEncryptedBackup : RestoreFileValidation() + data object IncompatibleBackup : RestoreFileValidation() + data object WrongBackup : RestoreFileValidation() + data object GeneralFailure : RestoreFileValidation() + data object PasswordRequired : RestoreFileValidation() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt index 50b0b1eb50f..1caa40781ba 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.appLogger +import com.wire.android.datastore.UserDataStore import com.wire.android.util.FileManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.asset.KaliumFileSystem @@ -44,6 +45,7 @@ import com.wire.kalium.logic.feature.backup.RestoreBackupResult.BackupRestoreFai import com.wire.kalium.logic.feature.backup.RestoreBackupUseCase import com.wire.kalium.logic.feature.backup.VerifyBackupResult import com.wire.kalium.logic.feature.backup.VerifyBackupUseCase +import com.wire.kalium.util.DateTimeUtil import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -61,7 +63,8 @@ class BackupAndRestoreViewModel private val validatePassword: ValidatePasswordUseCase, private val kaliumFileSystem: KaliumFileSystem, private val fileManager: FileManager, - private val dispatcher: DispatcherProvider, + private val userDataStore: UserDataStore, + private val dispatcher: DispatcherProvider ) : ViewModel() { var state by mutableStateOf(BackupAndRestoreState.INITIAL_STATE) @@ -72,7 +75,19 @@ class BackupAndRestoreViewModel @VisibleForTesting internal lateinit var latestImportedBackupTempPath: Path - fun createBackup(password: String) = viewModelScope.launch(dispatcher.main()) { + init { + observeLastBackupDate() + } + + private fun observeLastBackupDate() { + viewModelScope.launch { + userDataStore.lastBackupDateSeconds().collect { + state = state.copy(lastBackupData = it) + } + } + } + + fun createBackup(password: String) = viewModelScope.launch { // TODO: Find a way to update the creation progress more faithfully. For now we will just show this small delays to mimic the // progress also for small backups updateCreationProgress(PROGRESS_25) @@ -98,20 +113,40 @@ class BackupAndRestoreViewModel } } - fun shareBackup() = viewModelScope.launch(dispatcher.main()) { + private suspend fun updateLastBackupDate() { + DateTimeUtil.currentInstant().epochSeconds.also { currentTime -> + userDataStore.setLastBackupDateSeconds(currentTime) + } + } + + fun shareBackup() = viewModelScope.launch { + updateLastBackupDate() latestCreatedBackup?.let { backupData -> withContext(dispatcher.io()) { fileManager.shareWithExternalApp(backupData.path, backupData.assetName) {} } } - state = BackupAndRestoreState.INITIAL_STATE + state = state.copy( + backupRestoreProgress = BackupRestoreProgress.InProgress(), + restoreFileValidation = RestoreFileValidation.Initial, + backupCreationProgress = BackupCreationProgress.InProgress(), + restorePasswordValidation = PasswordValidation.NotVerified, + passwordValidation = ValidatePasswordResult.Valid, + ) } - fun saveBackup(uri: Uri) = viewModelScope.launch(dispatcher.main()) { + fun saveBackup(uri: Uri) = viewModelScope.launch { + updateLastBackupDate() latestCreatedBackup?.let { backupData -> fileManager.copyToUri(backupData.path, uri, dispatcher) } - state = BackupAndRestoreState.INITIAL_STATE + state = state.copy( + backupRestoreProgress = BackupRestoreProgress.InProgress(), + restoreFileValidation = RestoreFileValidation.Initial, + backupCreationProgress = BackupCreationProgress.InProgress(), + restorePasswordValidation = PasswordValidation.NotVerified, + passwordValidation = ValidatePasswordResult.Valid, + ) } fun chooseBackupFileToRestore(uri: Uri) = viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt index 31015c872fc..aa79a58c5f6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownBlockQuote.kt @@ -60,8 +60,8 @@ fun MarkdownBlockQuote(blockQuote: MarkdownNode.Block.BlockQuote, nodeData: Node } MarkdownText( text, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt index e5d6e4cb7e6..e05902c28b0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownCodeBlock.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.markdown import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -39,13 +38,9 @@ fun MarkdownIndentedCodeBlock(indentedCodeBlock: MarkdownNode.Block.IntendedCode fontFamily = FontFamily.Monospace, modifier = Modifier .fillMaxWidth() - .padding(dimensions().spacing4x) - .background(MaterialTheme.wireColorScheme.outlineVariant) - .border( - dimensions().spacing1x, MaterialTheme.wireColorScheme.outline, - shape = RoundedCornerShape(dimensions().spacing4x) - ) - .padding(dimensions().spacing4x) + .padding(vertical = dimensions().spacing4x) + .background(MaterialTheme.wireColorScheme.surfaceVariant, shape = RoundedCornerShape(dimensions().spacing16x)) + .padding(dimensions().spacing8x) ) } @@ -57,12 +52,8 @@ fun MarkdownFencedCodeBlock(fencedCodeBlock: MarkdownNode.Block.FencedCode, node fontFamily = FontFamily.Monospace, modifier = Modifier .fillMaxWidth() - .padding(dimensions().spacing4x) - .background(MaterialTheme.wireColorScheme.outlineVariant) - .border( - dimensions().spacing1x, MaterialTheme.wireColorScheme.outline, - shape = RoundedCornerShape(dimensions().spacing4x) - ) - .padding(dimensions().spacing4x) + .padding(vertical = dimensions().spacing4x) + .background(MaterialTheme.wireColorScheme.surfaceVariant, shape = RoundedCornerShape(dimensions().spacing16x)) + .padding(dimensions().spacing8x) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt index 0cdc8bfb37b..420a2735d74 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownComposer.kt @@ -110,19 +110,27 @@ fun inlineNodeChildren( children.forEach { child -> when (child) { is MarkdownNode.Inline.Text -> { - updatedMentions = appendLinksAndMentions( - annotatedString, - convertTypoGraphs(child.literal), - nodeData.copy(mentions = updatedMentions) - ) + if (nodeData.disableLinks) { + annotatedString.append(convertTypoGraphs(child.literal)) + } else { + updatedMentions = appendLinksAndMentions( + annotatedString, + convertTypoGraphs(child.literal), + nodeData.copy(mentions = updatedMentions) + ) + } } is MarkdownNode.Inline.Image -> { - updatedMentions = appendLinksAndMentions( - annotatedString, - child.destination, - nodeData.copy(mentions = updatedMentions) - ) + if (nodeData.disableLinks) { + annotatedString.append(child.destination) + } else { + updatedMentions = appendLinksAndMentions( + annotatedString, + child.destination, + nodeData.copy(mentions = updatedMentions) + ) + } } is MarkdownNode.Inline.Emphasis -> { @@ -162,20 +170,24 @@ fun inlineNodeChildren( } is MarkdownNode.Inline.Link -> { - annotatedString.pushStyle( - SpanStyle( - color = nodeData.colorScheme.primary, - textDecoration = TextDecoration.Underline + if (nodeData.disableLinks) { + annotatedString.append(child.destination) + } else { + annotatedString.pushStyle( + SpanStyle( + color = nodeData.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) ) - ) - annotatedString.pushStringAnnotation(TAG_URL, child.destination) - updatedMentions = inlineNodeChildren( - child.children, - annotatedString, - nodeData - ) - annotatedString.pop() - annotatedString.pop() + annotatedString.pushStringAnnotation(TAG_URL, child.destination) + updatedMentions = inlineNodeChildren( + child.children, + annotatedString, + nodeData + ) + annotatedString.pop() + annotatedString.pop() + } } is MarkdownNode.Inline.Strikethrough -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownConstants.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownConstants.kt index 5f249b0d26c..ddad205624d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownConstants.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownConstants.kt @@ -17,9 +17,19 @@ */ package com.wire.android.ui.markdown +import org.commonmark.Extension +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension +import org.commonmark.ext.gfm.tables.TablesExtension + object MarkdownConstants { const val TAG_URL = "linkTag" const val TAG_MENTION = "mentionTag" const val MENTION_MARK = "&&" const val BULLET_MARK = "\u2022" + const val NON_BREAKING_SPACE = " " + + val supportedExtensions: List = listOf( + StrikethroughExtension.builder().requireTwoTildes(true).build(), + TablesExtension.create() + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt index e4154c2d0fc..e826c78b3ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHeading.kt @@ -47,8 +47,8 @@ fun MarkdownHeading(heading: MarkdownNode.Block.Heading, nodeData: NodeData) { MarkdownText( annotatedString = text, style = style, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt index f38ae81c551..2e6a35ce550 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownHelper.kt @@ -15,9 +15,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -@file:Suppress("ComplexMethod") +@file:Suppress("ComplexMethod", "TooManyFunctions") + package com.wire.android.ui.markdown +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import org.commonmark.ext.gfm.strikethrough.Strikethrough import org.commonmark.ext.gfm.tables.TableBlock import org.commonmark.ext.gfm.tables.TableBody @@ -46,6 +49,8 @@ import org.commonmark.node.StrongEmphasis import org.commonmark.node.Text import org.commonmark.node.ThematicBreak +fun String.toMarkdownDocument(): MarkdownNode.Document = MarkdownParser.parse(this) + fun T.toContent(isParentDocument: Boolean = false): MarkdownNode { return when (this) { is Document -> MarkdownNode.Document(convertChildren()) @@ -162,6 +167,38 @@ fun MarkdownNode.filterNodesContainingQuery(query: String): MarkdownNode? { } } +fun MarkdownNode.getFirstInlines(): MarkdownPreview? { + return when (this) { + is MarkdownNode.Document -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.BlockQuote -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.FencedCode -> literal.toPreview() + is MarkdownNode.Block.Heading -> children.toPreview() + is MarkdownNode.Block.IntendedCode -> literal.toPreview() + is MarkdownNode.Block.ListBlock.Bullet -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.ListBlock.Ordered -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.ListItem -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.Paragraph -> children.toPreview() + is MarkdownNode.Block.Table -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.TableContent.Body -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.TableContent.Head -> children.firstOrNull()?.getFirstInlines() + is MarkdownNode.Block.ThematicBreak -> null + is MarkdownNode.Inline -> { + throw IllegalArgumentException("It should not go to inline children!") + } + + is MarkdownNode.TableCell -> children.toPreview() + is MarkdownNode.TableRow -> children.firstOrNull()?.children?.toPreview() + } +} + +private fun List.toPreview(): MarkdownPreview { + return MarkdownPreview(this.toPersistentList()) +} + +private fun String.toPreview(): MarkdownPreview { + return MarkdownPreview(persistentListOf(MarkdownNode.Inline.Text(this))) +} + private fun MarkdownNode.containsQuery(query: String): Boolean { return when (this) { is MarkdownNode.Inline.Text -> literal.contains(query, ignoreCase = true) diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt new file mode 100644 index 00000000000..4367080c4b6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownInline.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2024 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.ui.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun MarkdownInline( + inlines: List, + nodeData: NodeData +) { + val annotatedString = buildAnnotatedString { + pushStyle(nodeData.style.toSpanStyle()) + inlineNodeChildren(inlines, this, nodeData) + pop() + } + MarkdownText( + annotatedString, + style = nodeData.style, + color = nodeData.color, + clickable = false, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt index be004646021..c2001ee55df 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownList.kt @@ -44,8 +44,8 @@ fun MarkdownBulletList(bulletList: MarkdownNode.Block.ListBlock.Bullet, nodeData MarkdownText( annotatedString = text, style = MaterialTheme.wireTypography.body01, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile ) MarkdownNodeBlockChildren(children = listItem.children, nodeData = nodeData) } @@ -69,8 +69,8 @@ fun MarkdownOrderedList(orderedList: MarkdownNode.Block.ListBlock.Ordered, nodeD MarkdownText( annotatedString = text, style = MaterialTheme.wireTypography.body01, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile ) MarkdownNodeBlockChildren(children = listItem.children, nodeData = nodeData) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt index e5536f39031..02453a8f0d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownNode.kt @@ -17,6 +17,8 @@ */ package com.wire.android.ui.markdown +import kotlinx.collections.immutable.PersistentList + sealed class MarkdownNode { abstract val children: List abstract val isParentDocument: Boolean @@ -142,3 +144,5 @@ sealed class MarkdownNode { override val isParentDocument: Boolean = false ) : MarkdownNode() } + +data class MarkdownPreview(val children: PersistentList) diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt index 0ff5770176b..26ad71b3819 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParagraph.kt @@ -44,9 +44,9 @@ fun MarkdownParagraph( MarkdownText( annotatedString, style = nodeData.style, - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile, - onClickLink = nodeData.onLinkClick, + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile, + onClickLink = nodeData.actions?.onLinkClick, clickable = clickable ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt new file mode 100644 index 00000000000..921f440c9f3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownParser.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2024 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.ui.markdown + +import org.commonmark.node.Document +import org.commonmark.parser.Parser + +object MarkdownParser { + private val parser = Parser.builder().extensions(MarkdownConstants.supportedExtensions).build() + + fun parse(text: String) = (parser.parse(text) as Document).toContent() as MarkdownNode.Document +} diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt index d346f628622..d9eb7a30ac4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownTable.kt @@ -78,8 +78,8 @@ fun MarkdownTable(tableBlock: MarkdownNode.Block.Table, nodeData: NodeData, onMe modifier = Modifier .weight(1f) .padding(dimensions().spacing8x), - onLongClick = nodeData.onLongClick, - onOpenProfile = nodeData.onOpenProfile + onLongClick = nodeData.actions?.onLongClick, + onOpenProfile = nodeData.actions?.onOpenProfile ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt index 6f86c90bd78..6b83572b581 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/MarkdownText.kt @@ -46,8 +46,8 @@ fun MarkdownText( style: TextStyle = LocalTextStyle.current, clickable: Boolean = true, onClickLink: ((linkText: String) -> Unit)? = null, - onLongClick: (() -> Unit)?, - onOpenProfile: (String) -> Unit + onLongClick: (() -> Unit)? = null, + onOpenProfile: ((String) -> Unit)? = null ) { if (clickable) { @@ -75,7 +75,7 @@ fun MarkdownText( start = offset, end = offset ).firstOrNull()?.let { result -> - onOpenProfile(result.item) + onOpenProfile?.invoke(result.item) } }, onLongClick = onLongClick diff --git a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt index 754166b97b3..376671209c5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt +++ b/app/src/main/kotlin/com/wire/android/ui/markdown/NodeData.kt @@ -32,6 +32,11 @@ data class NodeData( val typography: WireTypography, val mentions: List, val searchQuery: String, + val disableLinks: Boolean = false, + val actions: NodeActions? = null +) + +data class NodeActions( val onLongClick: (() -> Unit)? = null, val onOpenProfile: (String) -> Unit, val onLinkClick: (String) -> Unit diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index a05f9898bf0..922cbd74299 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -106,6 +106,7 @@ import com.wire.android.ui.userprofile.group.RemoveConversationMemberState import com.wire.android.ui.userprofile.other.bottomsheet.OtherUserBottomSheetState import com.wire.android.ui.userprofile.other.bottomsheet.OtherUserProfileBottomSheetContent import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.ConnectionState import kotlinx.coroutines.CoroutineScope @@ -571,10 +572,11 @@ private fun ContentFooter( } } -enum class OtherUserProfileTabItem(@StringRes override val titleResId: Int) : TabItem { +enum class OtherUserProfileTabItem(@StringRes val titleResId: Int) : TabItem { GROUP(R.string.user_profile_group_tab), DETAILS(R.string.user_profile_details_tab), DEVICES(R.string.user_profile_devices_tab); + override val title: UIText = UIText.StringResource(titleResId) } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/kotlin/com/wire/android/util/extension/LazyListScope.kt b/app/src/main/kotlin/com/wire/android/util/extension/LazyListScope.kt index d7034ebe7d7..4f544a0bd0b 100644 --- a/app/src/main/kotlin/com/wire/android/util/extension/LazyListScope.kt +++ b/app/src/main/kotlin/com/wire/android/util/extension/LazyListScope.kt @@ -32,32 +32,31 @@ import com.wire.android.ui.home.conversationslist.common.FolderHeader inline fun LazyListScope.folderWithElements( header: String? = null, items: Map, + animateItemPlacement: Boolean = true, crossinline divider: @Composable () -> Unit = {}, crossinline factory: @Composable (T) -> Unit ) { val list = items.entries.toList() if (items.isNotEmpty()) { - if (header != null) { + if (!header.isNullOrEmpty()) { item(key = "header:$header") { - if (header.isNotEmpty()) { - FolderHeader( - name = header, - modifier = Modifier - .fillMaxWidth() - .animateItemPlacement() - ) - } + FolderHeader( + name = header, + modifier = Modifier + .fillMaxWidth() + .let { if (animateItemPlacement) it.animateItemPlacement() else it } + ) } } itemsIndexed( items = list, - key = { _: Int, item: Map.Entry -> "$header:${item.key}" } + key = { _: Int, item: Map.Entry -> "$item:${item.key}" } ) { index: Int, item: Map.Entry -> Box( modifier = Modifier .wrapContentSize() - .animateItemPlacement() + .let { if (animateItemPlacement) it.animateItemPlacement() else it } ) { factory(item.value) if (index <= list.lastIndex) { diff --git a/app/src/main/kotlin/com/wire/android/util/time/LocalTimeFormater.kt b/app/src/main/kotlin/com/wire/android/util/time/LocalTimeFormater.kt new file mode 100644 index 00000000000..0d389c973ed --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/time/LocalTimeFormater.kt @@ -0,0 +1,44 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util.time + +import android.content.Context +import android.text.format.DateFormat +import androidx.compose.runtime.Stable +import java.util.Date +import java.util.TimeZone + +@Stable +fun convertTimestampToDateTime( + timestampSeconds: Long, + context: Context +): Pair { + + val dateFormat = DateFormat.getDateFormat(context) + val timeFormat = DateFormat.getTimeFormat(context) + + val timezone = TimeZone.getDefault() + dateFormat.timeZone = timezone + timeFormat.timeZone = timezone + + val dateTime = Date(timestampSeconds * 1000) + val date = dateFormat.format(dateTime) + val time = timeFormat.format(dateTime) + + return Pair(date, time) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de8d5bc4c4e..cbf1236e7a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,6 +80,7 @@ Blocked and Retry + *Draft* App Logo Show password @@ -214,6 +215,9 @@ Backups Other Create a backup to preserve your conversation history. You can use this to restore history if you lose your device or switch to a new one.\n\nChoose a strong password to protect the backup file. + Last Backup + The most recent backup from this device has been created on %1$s at %2$s. + You did not create a backup from this device yet. Create a Backup Restore from Backup diff --git a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt index 1bb4ef819c8..a4ec92747cc 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestMessage.kt @@ -179,7 +179,6 @@ object TestMessage { conversationId = ConversationId("value", "domain"), content = MessagePreviewContent.WithUser.MissedCall(TestUser.OTHER_USER.name), isSelfMessage = false, - date = "2022-03-30T15:36:00.000Z", visibility = Message.Visibility.VISIBLE, senderUserId = TestUser.USER_ID ) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index 999f29f6aea..18b042cc93c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -185,7 +185,7 @@ internal class MessageComposerViewModelArrangement { } fun withSaveDraftMessage() = apply { - coEvery { saveMessageDraftUseCase(any(), any()) } returns Unit + coEvery { saveMessageDraftUseCase(any()) } returns Unit } fun arrange() = this to viewModel diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt index 3d058e88995..c0a24932b09 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.conversations.composer import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.message.draft.MessageDraft import io.mockk.coVerify @@ -113,6 +114,7 @@ class MessageComposerViewModelTest { runTest { // given val messageDraft = MessageDraft( + conversationId = ConversationId("value", "domain"), text = "hello", editMessageId = null, quotedMessageId = null, @@ -128,6 +130,6 @@ class MessageComposerViewModelTest { advanceUntilIdle() // then - coVerify(exactly = 1) { arrangement.saveMessageDraftUseCase.invoke(eq(viewModel.conversationId), eq(messageDraft)) } + coVerify(exactly = 1) { arrangement.saveMessageDraftUseCase.invoke(eq(messageDraft)) } } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt index 4a0a102f04a..b3db008321e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt @@ -64,8 +64,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.runTest @@ -86,7 +85,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) @@ -109,7 +108,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) @@ -137,7 +136,7 @@ class GroupConversationDetailsViewModelTest { } val archivingEventTimestamp = 123456789L val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) val conversationDetails = testGroup.copy(conversation = testGroup.conversation.copy(name = "Group name 1")) @@ -185,7 +184,7 @@ class GroupConversationDetailsViewModelTest { } val archivingEventTimestamp = 123456789L val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) @@ -233,7 +232,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size, isSelfAnAdmin = true ) @@ -290,7 +289,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) @@ -316,7 +315,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size, ) @@ -362,7 +361,7 @@ class GroupConversationDetailsViewModelTest { } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size ) @@ -506,7 +505,7 @@ class GroupConversationDetailsViewModelTest { ) = runTest { val members = buildList { for (i in 1..5) { add(testUIParticipant(i)) } } val conversationParticipantsData = ConversationParticipantsData( - participants = members.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), + participants = members, allParticipantsCount = members.size, isSelfAnAdmin = isSelfAnAdmin ) @@ -683,9 +682,10 @@ internal class GroupConversationDetailsViewModelArrangement { @MockK lateinit var updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase - private val conversationDetailsChannel = Channel(capacity = Channel.UNLIMITED) + private val conversationDetailsFlow = MutableSharedFlow(replay = Int.MAX_VALUE) - private val observeParticipantsForConversationChannel = Channel(capacity = Channel.UNLIMITED) + private val observeParticipantsForConversationFlow = + MutableSharedFlow(replay = Int.MAX_VALUE) @MockK private lateinit var refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase @@ -741,14 +741,14 @@ internal class GroupConversationDetailsViewModelArrangement { } suspend fun withConversationDetailUpdate(conversationDetails: ConversationDetails) = apply { - coEvery { observeConversationDetails(any()) } returns conversationDetailsChannel.consumeAsFlow() + coEvery { observeConversationDetails(any()) } returns conversationDetailsFlow .map { ObserveConversationDetailsUseCase.Result.Success(it) } - conversationDetailsChannel.send(conversationDetails) + conversationDetailsFlow.emit(conversationDetails) } suspend fun withConversationMembersUpdate(conversationParticipantsData: ConversationParticipantsData) = apply { - coEvery { observeParticipantsForConversationUseCase(any()) } returns observeParticipantsForConversationChannel.consumeAsFlow() - observeParticipantsForConversationChannel.send(conversationParticipantsData) + coEvery { observeParticipantsForConversationUseCase(any()) } returns observeParticipantsForConversationFlow + observeParticipantsForConversationFlow.emit(conversationParticipantsData) } suspend fun withUpdateConversationAccessUseCaseReturns(result: UpdateConversationAccessRoleUseCase.Result) = apply { diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModelTest.kt index f2dd7666ca1..1315b478248 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/participants/GroupConversationParticipantsViewModelTest.kt @@ -23,7 +23,6 @@ import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.mockUri import com.wire.android.mapper.testUIParticipant -import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel import com.wire.android.ui.home.conversations.details.participants.model.ConversationParticipantsData import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase @@ -32,13 +31,14 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -54,23 +54,25 @@ class GroupConversationParticipantsViewModelTest { add(testUIParticipant(i)) } } - val (_, viewModel) = GroupConversationParticipantsViewModelArrangement() + val (arrangement, viewModel) = Arrangement() .withConversationParticipantsUpdate(members) - .arrange() + .arrange(expectedParticipantsSize) + advanceUntilIdle() // When - Then - assert(viewModel.groupParticipantsState.data.participants.size == expectedParticipantsSize) - assert(viewModel.groupParticipantsState.data.allParticipantsCount == members.size) + coVerify { arrangement.observeParticipantsForConversationUseCase.invoke(any(), eq(expectedParticipantsSize)) } + assertEquals(expectedParticipantsSize, viewModel.groupParticipantsState.data.participants.size) + assertEquals(members.size, viewModel.groupParticipantsState.data.allParticipantsCount) } @Test fun `given a group members, when solving the participants list, then right sizes are passed`() { - val maxNumber = GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS + val maxNumber = 4 testSize(givenParticipantsSize = maxNumber + 1, expectedParticipantsSize = maxNumber) testSize(givenParticipantsSize = maxNumber - 1, expectedParticipantsSize = maxNumber - 1) } } -internal class GroupConversationParticipantsViewModelArrangement { +internal class Arrangement { @MockK private lateinit var savedStateHandle: SavedStateHandle @@ -80,14 +82,6 @@ internal class GroupConversationParticipantsViewModelArrangement { @MockK lateinit var observeParticipantsForConversationUseCase: ObserveParticipantsForConversationUseCase - private val conversationMembersChannel = Channel(capacity = Channel.UNLIMITED) - private val viewModel by lazy { - GroupConversationParticipantsViewModel( - savedStateHandle, - observeParticipantsForConversationUseCase, - refreshUsersWithoutMetadata, - ) - } val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") @@ -95,23 +89,31 @@ internal class GroupConversationParticipantsViewModelArrangement { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns GroupConversationAllParticipantsNavArgs( - conversationId = conversationId - ) + every { + savedStateHandle.navArgs() + } returns GroupConversationAllParticipantsNavArgs(conversationId = conversationId) // Default empty values coEvery { observeParticipantsForConversationUseCase(any(), any()) } returns flowOf() } - suspend fun withConversationParticipantsUpdate(participants: List): GroupConversationParticipantsViewModelArrangement { - coEvery { observeParticipantsForConversationUseCase(any(), any()) } returns conversationMembersChannel.consumeAsFlow() - conversationMembersChannel.send( - ConversationParticipantsData( - participants = participants.take(GroupConversationDetailsViewModel.MAX_NUMBER_OF_PARTICIPANTS), - allParticipantsCount = participants.size + suspend fun withConversationParticipantsUpdate(participants: List): Arrangement { + coEvery { observeParticipantsForConversationUseCase(any(), any()) } answers { + flowOf( + ConversationParticipantsData( + participants = participants.take(this.secondArg() as Int), + allParticipantsCount = participants.size + ) ) - ) + } return this } - fun arrange() = this to viewModel + fun arrange(maxNumberOfItems: Int = -1): Pair = + this to object : GroupConversationParticipantsViewModel( + savedStateHandle, + observeParticipantsForConversationUseCase, + refreshUsersWithoutMetadata, + ) { + override val maxNumberOfItems: Int get() = maxNumberOfItems + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt index 85908c553bc..10d71f8ca99 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/draft/MessageDraftViewModelTest.kt @@ -22,12 +22,12 @@ import com.wire.android.R import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.mockUri +import com.wire.android.framework.TestConversation import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.model.UIQuotedMessage import com.wire.android.ui.home.conversations.usecase.GetQuoteMessageForConversationUseCase import com.wire.android.ui.navArgs import com.wire.android.util.ui.UIText -import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.draft.MessageDraft import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.message.draft.GetMessageDraftUseCase @@ -53,6 +53,7 @@ class MessageDraftViewModelTest { fun `given message draft, when init, then state is properly updated`() = runTest { // given val messageDraft = MessageDraft( + conversationId = TestConversation.ID, text = "hello", editMessageId = null, quotedMessageId = null, @@ -93,6 +94,7 @@ class MessageDraftViewModelTest { fun `given message draft with quoted message, when init, then state is updated`() = runTest { // given val messageDraft = MessageDraft( + conversationId = TestConversation.ID, text = "hello", editMessageId = null, quotedMessageId = "quoted_message_id", @@ -131,6 +133,7 @@ class MessageDraftViewModelTest { fun `given message draft with unavailable quoted message, when init, then quoted data is not updated`() = runTest { // given val messageDraft = MessageDraft( + conversationId = TestConversation.ID, text = "hello", editMessageId = null, quotedMessageId = "quoted_message_id", @@ -160,13 +163,13 @@ class MessageDraftViewModelTest { private class Arrangement { - val conversationId: ConversationId = ConversationId("some-dummy-value", "some.dummy.domain") - init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) mockUri() - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) + every { + savedStateHandle.navArgs() + } returns ConversationNavArgs(conversationId = TestConversation.ID) } @MockK diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index d2b0478014b..3ef7e594b63 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestConversation import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.mock.mockMessageWithText @@ -69,7 +70,7 @@ class MessageComposerStateHolderTest { fun before() { MockKAnnotations.init(this, relaxUnitFun = true) messageComposerViewState = mutableStateOf(MessageComposerViewState()) - messageComposition = mutableStateOf(MessageComposition()) + messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) messageCompositionInputStateHolder = MessageCompositionInputStateHolder( messageComposition = messageComposition, selfDeletionTimer = mutableStateOf(SelfDeletionTimer.Disabled) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt index d803abbedc5..c0e808d610d 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolderTest.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.framework.TestConversation import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.model.update import io.mockk.MockKAnnotations @@ -44,7 +45,7 @@ class MessageCompositionHolderTest { fun before() { MockKAnnotations.init(this, relaxUnitFun = true) - messageComposition = mutableStateOf(MessageComposition()) + messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) state = MessageCompositionHolder(messageComposition = messageComposition, {}) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 6b271caa856..65fde5219a4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestConversation import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.kalium.logic.data.message.SelfDeletionTimer import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,7 +41,7 @@ class MessageCompositionInputStateHolderTest { @BeforeEach fun before() { - messageComposition = mutableStateOf(MessageComposition()) + messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) state = MessageCompositionInputStateHolder( messageComposition = messageComposition, selfDeletionTimer = mutableStateOf(SelfDeletionTimer.Disabled) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt index a1400324457..77feadb3c0e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt @@ -21,8 +21,8 @@ package com.wire.android.ui.home.settings.home import android.net.Uri import androidx.compose.ui.text.input.TextFieldValue import androidx.core.net.toUri -import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider +import com.wire.android.datastore.UserDataStore import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.ui.home.settings.backup.BackupAndRestoreState import com.wire.android.ui.home.settings.backup.BackupAndRestoreViewModel @@ -52,24 +52,43 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.datetime.Instant import okio.IOException import okio.Path.Companion.toPath import okio.buffer import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.internal.assertFalse +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) class BackupAndRestoreViewModelTest { private val dispatcher = TestDispatcherProvider() + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher.main()) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + @Test - fun givenAnEmptyPassword_whenCreatingABackup_thenItCreatesItSuccessfully() = runTest(dispatcher.default()) { + fun givenAnEmptyPassword_whenCreatingABackup_thenItCreatesItSuccessfully() = runTest { // Given val emptyPassword = "" val (arrangement, backupAndRestoreViewModel) = Arrangement() @@ -81,8 +100,8 @@ class BackupAndRestoreViewModelTest { advanceUntilIdle() // Then - assert(backupAndRestoreViewModel.latestCreatedBackup?.isEncrypted == false) assert(backupAndRestoreViewModel.state.backupCreationProgress is BackupCreationProgress.Finished) + assertFalse(backupAndRestoreViewModel.latestCreatedBackup?.isEncrypted!!) coVerify(exactly = 1) { arrangement.createBackupFile(password = emptyPassword) } } @@ -99,8 +118,8 @@ class BackupAndRestoreViewModelTest { advanceUntilIdle() // Then - assert(backupAndRestoreViewModel.latestCreatedBackup?.isEncrypted == true) - assert(backupAndRestoreViewModel.state.backupCreationProgress is BackupCreationProgress.Finished) + assertInstanceOf(BackupCreationProgress.Finished::class.java, backupAndRestoreViewModel.state.backupCreationProgress) + assertTrue(backupAndRestoreViewModel.latestCreatedBackup?.isEncrypted!!) coVerify(exactly = 1) { arrangement.createBackupFile(password = password) } } @@ -154,7 +173,7 @@ class BackupAndRestoreViewModelTest { } @Test - fun givenANonEmptyPassword_whenCreatingABackupWithAGivenError_thenItReturnsAFailure() = runTest(dispatcher.default()) { + fun givenANonEmptyPassword_whenCreatingABackupWithAGivenError_thenItReturnsAFailure() = runTest { // Given val password = "mayTh3ForceBeWIthYou" val (arrangement, backupAndRestoreViewModel) = Arrangement() @@ -172,11 +191,12 @@ class BackupAndRestoreViewModelTest { } @Test - fun givenACreatedBackup_whenSharingIt_thenTheStateIsReset() = runTest(dispatcher.default()) { + fun givenACreatedBackup_whenSharingIt_thenTheStateIsResetButKeepsTheLastBackupDate() = runTest { // Given val storedBackup = BackupAndRestoreState.CreatedBackup("backupFilePath".toPath(), "backupName.zip", 100L, true) val (arrangement, backupAndRestoreViewModel) = Arrangement() .withPreviouslyCreatedBackup(storedBackup) + .withUpdateLastBackupData() .arrange() // When @@ -185,7 +205,12 @@ class BackupAndRestoreViewModelTest { // Then assert(backupAndRestoreViewModel.latestCreatedBackup == storedBackup) - assert(backupAndRestoreViewModel.state == BackupAndRestoreState.INITIAL_STATE) + assertEquals( + BackupAndRestoreState.INITIAL_STATE.copy( + lastBackupData = backupAndRestoreViewModel.state.lastBackupData + ), + backupAndRestoreViewModel.state + ) coVerify(exactly = 1) { arrangement.fileManager.shareWithExternalApp( storedBackup.path, @@ -193,14 +218,18 @@ class BackupAndRestoreViewModelTest { any() ) } + coVerify { + arrangement.userDataStore.setLastBackupDateSeconds(any()) + } } @Test - fun givenACreatedBackup_whenSavingIt_thenTheStateIsReset() = runTest(dispatcher.default()) { + fun givenACreatedBackup_whenSavingIt_thenTheStateIsResetButKeepsTheLastBackupDate() = runTest(dispatcher.default()) { // Given val storedBackup = BackupAndRestoreState.CreatedBackup("backupFilePath".toPath(), "backupName.zip", 100L, true) val (arrangement, backupAndRestoreViewModel) = Arrangement() .withPreviouslyCreatedBackup(storedBackup) + .withUpdateLastBackupData() .arrange() val backupUri = "some-backup".toUri() @@ -210,7 +239,10 @@ class BackupAndRestoreViewModelTest { // Then assert(backupAndRestoreViewModel.latestCreatedBackup == storedBackup) - assert(backupAndRestoreViewModel.state == BackupAndRestoreState.INITIAL_STATE) + assertEquals( + BackupAndRestoreState.INITIAL_STATE.copy(lastBackupData = backupAndRestoreViewModel.state.lastBackupData), + backupAndRestoreViewModel.state + ) coVerify(exactly = 1) { arrangement.fileManager.copyToUri( storedBackup.path, @@ -218,6 +250,9 @@ class BackupAndRestoreViewModelTest { any() ) } + coVerify(exactly = 1) { + arrangement.userDataStore.setLastBackupDateSeconds(any()) + } } @Test @@ -448,6 +483,7 @@ class BackupAndRestoreViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) val mockUri = mockk() mockkStatic(Uri::class) + withGetLastBackupDateSeconds() every { Uri.parse("some-backup") } returns mockUri coEvery { importBackup(any(), any()) } returns RestoreBackupResult.Success coEvery { createBackupFile(any()) } returns CreateBackupResult.Success("".toPath(), 0L, "") @@ -469,6 +505,9 @@ class BackupAndRestoreViewModelTest { @MockK lateinit var fileManager: FileManager + @MockK + lateinit var userDataStore: UserDataStore + val fakeKaliumFileSystem = FakeKaliumFileSystem() private val viewModel = BackupAndRestoreViewModel( @@ -478,7 +517,8 @@ class BackupAndRestoreViewModelTest { kaliumFileSystem = fakeKaliumFileSystem, dispatcher = dispatcher, fileManager = fileManager, - validatePassword = validatePassword + validatePassword = validatePassword, + userDataStore = userDataStore ) fun withSuccessfulCreation(password: String) = apply { @@ -554,6 +594,14 @@ class BackupAndRestoreViewModelTest { every { validatePassword(any()) } returns ValidatePasswordResult.Invalid() } + fun withUpdateLastBackupData() = apply { + coEvery { userDataStore.setLastBackupDateSeconds(any()) } returns Unit + } + + fun withGetLastBackupDateSeconds(result: Flow = flowOf(Instant.DISTANT_PAST.epochSeconds)) = apply { + coEvery { userDataStore.lastBackupDateSeconds() } returns result + } + fun arrange() = this to viewModel } } diff --git a/kalium b/kalium index 8344dd12647..9c9cc58bb7c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 8344dd12647d45e2e27ea1e6526435e8e5f42485 +Subproject commit 9c9cc58bb7c757125148a11b9649ada2ad52d297