diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index f612354bf2..18817568e0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -9,6 +9,7 @@ import android.os.SystemClock.elapsedRealtime import android.util.Log import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme @@ -19,7 +20,9 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* import chat.simplex.app.model.ChatModel @@ -36,6 +39,9 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.* import chat.simplex.app.views.onboarding.* import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch class MainActivity: FragmentActivity() { companion object { @@ -317,14 +323,58 @@ fun MainPage( if (chatModel.showCallView.value) ActiveCallView(chatModel) else { showAdvertiseLAAlert = true - val stopped = chatModel.chatRunning.value == false - if (chatModel.chatId.value == null) { - if (chatModel.sharedContent.value == null) - ChatListView(chatModel, setPerformLA, stopped) - else - ShareListView(chatModel, stopped) + BoxWithConstraints { + var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) } + val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) } + Box( + Modifier + .graphicsLayer { + translationX = -offset.value.dp.toPx() + } + ) { + val stopped = chatModel.chatRunning.value == false + if (chatModel.sharedContent.value == null) + ChatListView(chatModel, setPerformLA, stopped) + else + ShareListView(chatModel, stopped) + } + val scope = rememberCoroutineScope() + val onComposed: () -> Unit = { + scope.launch { + offset.animateTo( + if (chatModel.chatId.value == null) 0f else maxWidth.value, + chatListAnimationSpec() + ) + if (offset.value == 0f) { + currentChatId = null + } + } + } + LaunchedEffect(Unit) { + launch { + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .collect { + if (it != null) currentChatId = it + else onComposed() + } + } + launch { + snapshotFlow { chatModel.sharedContent.value } + .distinctUntilChanged() + .filter { it != null } + .collect { + chatModel.chatId.value = null + currentChatId = null + } + } + } + Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ { + currentChatId?.let { + ChatView(it, chatModel, onComposed) + } + } } - else ChatView(chatModel) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index bab6215a17..811e35f968 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +@Stable class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) @@ -382,7 +383,7 @@ interface SomeChat { val updatedAt: Instant } -@Serializable +@Serializable @Stable data class Chat ( val chatInfo: ChatInfo, val chatItems: List, @@ -1015,7 +1016,7 @@ class AChatItem ( val chatItem: ChatItem ) -@Serializable +@Serializable @Stable data class ChatItem ( val chatDir: CIDirection, val meta: CIMeta, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt index 6aebed4889..4048ce6890 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -42,8 +42,7 @@ import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.helpers.withApi import chat.simplex.app.views.usersettings.NotificationsMode import com.google.accompanist.permissions.rememberMultiplePermissionsState -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -357,6 +356,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { @Composable fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { + val scope = rememberCoroutineScope() val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( permissions = listOf( @@ -435,7 +435,7 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa Log.d(TAG, "WebRTCView: webview ready") // for debugging // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) - withApi { + scope.launch { delay(2000L) wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) webView.value = wv diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 74db098fe3..ad9b8fd371 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,10 +1,8 @@ package chat.simplex.app.views.chat -import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri -import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* @@ -52,8 +50,8 @@ import java.io.File import kotlin.math.sign @Composable -fun ChatView(chatModel: ChatModel) { - val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) } +fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { + val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -63,7 +61,6 @@ fun ChatView(chatModel: ChatModel) { val attachmentOption = rememberSaveable { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { // snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value. // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view @@ -102,12 +99,11 @@ fun ChatView(chatModel: ChatModel) { // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0 + chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0 } } ChatLayout( - user, chat, unreadCount, composeState, @@ -120,7 +116,6 @@ fun ChatView(chatModel: ChatModel) { } }, attachmentOption, - scope, attachmentBottomSheetState, chatModel.chatItems, searchText, @@ -228,20 +223,19 @@ fun ChatView(chatModel: ChatModel) { apiFindMessages(c.chatInfo, chatModel, value) searchText.value = value } - } + }, + onComposed, ) } } @Composable fun ChatLayout( - user: User, chat: Chat, unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), attachmentOption: MutableState, - scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, searchValue: State, @@ -260,8 +254,10 @@ fun ChatLayout( markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, + onComposed: () -> Unit, ) { - Surface( + val scope = rememberCoroutineScope() + Box( Modifier .fillMaxWidth() .background(MaterialTheme.colors.background) @@ -292,9 +288,9 @@ fun ChatLayout( ) { contentPadding -> BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) { ChatItemsList( - user, chat, unreadCount, composeState, chatItems, searchValue, + chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, - receiveFile, joinGroup, acceptCall, markRead, setFloatingButton + receiveFile, joinGroup, acceptCall, markRead, setFloatingButton, onComposed, ) } } @@ -445,7 +441,6 @@ val CIListStateSaver = run { @Composable fun BoxWithConstraintsScope.ChatItemsList( - user: User, chat: Chat, unreadCount: State, composeState: MutableState, @@ -461,11 +456,10 @@ fun BoxWithConstraintsScope.ChatItemsList( acceptCall: (Contact) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, + onComposed: () -> Unit, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current - val cxt = LocalContext.current ScrollToBottom(chat.id, listState) var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } // Scroll to bottom when search value changes from something to nothing and back @@ -493,6 +487,16 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } } } + LaunchedEffect(Unit) { + var stopListening = false + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex } + .distinctUntilChanged() + .filter { !stopListening } + .collect { + onComposed() + stopListening = true + } + } LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { itemsIndexed(reversedChatItems) { i, cItem -> CompositionLocalProvider( @@ -555,11 +559,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) } } else { Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem) } } } else { // direct message @@ -570,7 +574,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem) } } @@ -717,7 +721,7 @@ fun PreloadItems( .map { val totalItemsNumber = it.totalItemsCount val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - if (lastVisibleItemIndex > (totalItemsNumber - remaining)) + if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) totalItemsNumber else 0 @@ -931,7 +935,6 @@ fun PreviewChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( - user = User.sampleData, chat = Chat( chatInfo = ChatInfo.Direct.sampleData, chatItems = chatItems, @@ -941,7 +944,6 @@ fun PreviewChatLayout() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, - scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, searchValue, @@ -960,6 +962,7 @@ fun PreviewChatLayout() { markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + onComposed = {}, ) } } @@ -989,7 +992,6 @@ fun PreviewGroupChatLayout() { val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( - user = User.sampleData, chat = Chat( chatInfo = ChatInfo.Group.sampleData, chatItems = chatItems, @@ -999,7 +1001,6 @@ fun PreviewGroupChatLayout() { composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, - scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, searchValue, @@ -1018,6 +1019,7 @@ fun PreviewGroupChatLayout() { markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + onComposed = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index a627357d92..557399de6b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.chat.item -import android.content.* import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,15 +28,11 @@ import kotlinx.datetime.Clock @Composable fun ChatItemView( - user: User, cInfo: ChatInfo, cItem: ChatItem, composeState: MutableState, - cxt: Context, - uriHandler: UriHandler? = null, imageProvider: (() -> ImageGalleryProvider)? = null, showMember: Boolean = false, - chatModelIncognito: Boolean, useLinkPreviews: Boolean, deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long) -> Unit, @@ -46,6 +41,7 @@ fun ChatItemView( scrollToItem: (Long) -> Unit, ) { val context = LocalContext.current + val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } @@ -70,7 +66,7 @@ fun ChatItemView( Column( Modifier .clip(RoundedCornerShape(18.dp)) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick), ) { @Composable fun ContentItem() { if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { @@ -95,13 +91,13 @@ fun ChatItemView( ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { val filePath = getLoadedFilePath(SimplexApp.context, cItem.file) when { - filePath != null -> shareFile(cxt, cItem.text, filePath) - else -> shareText(cxt, cItem.content.text) + filePath != null -> shareFile(context, cItem.text, filePath) + else -> shareText(context, cItem.content.text) } showMenu.value = false }) ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { - copyText(cxt, cItem.content.text) + copyText(context, cItem.content.text) showMenu.value = false }) if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { @@ -233,15 +229,12 @@ private fun showMsgDeliveryErrorAlert(description: String) { fun PreviewChatItemView() { SimpleXTheme { ChatItemView( - User.sampleData, ChatInfo.Direct.sampleData, ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - cxt = LocalContext.current, - chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, @@ -256,13 +249,10 @@ fun PreviewChatItemView() { fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( - User.sampleData, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), useLinkPreviews = true, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - cxt = LocalContext.current, - chatModelIncognito = false, deleteMessage = { _, _ -> }, receiveFile = {}, joinGroup = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 4043a6e55b..e5ad61e1e4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -536,13 +536,11 @@ fun ChatListNavLinkLayout( ) { var modifier = Modifier.fillMaxWidth() if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) - Surface(modifier) { + Box(modifier) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) - .padding(start = 8.dp) - .padding(end = 12.dp), + .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), verticalAlignment = Alignment.Top ) { chatLinkPreview() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt index 68f18faede..200cbaa6d7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt @@ -2,4 +2,6 @@ package chat.simplex.app.views.helpers import androidx.compose.animation.core.* +fun chatListAnimationSpec() = tween(durationMillis = 250, easing = FastOutSlowInEasing) + fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing)