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 761196a96..291299bc1 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 @@ -211,7 +211,7 @@ class ChatModel(val controller: ChatController) { } } - fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null) { + fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { val markedRead = markItemsReadInCurrentChat(cInfo, range) // update preview val chatIdx = getChatIndex(cInfo.id) @@ -221,7 +221,7 @@ class ChatModel(val controller: ChatController) { if (lastId != null) { chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( - unreadCount = if (range != null) chat.chatStats.unreadCount - markedRead else 0, + unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0, // Can't use minUnreadItemId currently since chat items can have unread items between read items //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f13e824e6..e2c469e47 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1279,8 +1279,8 @@ sealed class ChatPagination { companion object { const val INITIAL_COUNT = 100 - const val PRELOAD_COUNT = 20 - const val UNTIL_PRELOAD_COUNT = 10 + const val PRELOAD_COUNT = 100 + const val UNTIL_PRELOAD_COUNT = 50 } } 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 b9f83ffa5..572bc6944 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 @@ -4,12 +4,14 @@ import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver @@ -24,8 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* @@ -57,9 +58,18 @@ fun ChatView(chatModel: ChatModel) { val chat = activeChat!! BackHandler { chatModel.chatId.value = null } + // We need to have real unreadCount value for displaying it inside top right button + // 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 + } + } + ChatLayout( user, chat, + unreadCount, composeState, composeView = { if (chat.chatInfo.sendMsgEnabled) { @@ -167,8 +177,8 @@ fun ChatView(chatModel: ChatModel) { } } }, - markRead = { range -> - chatModel.markChatItemsRead(chat.chatInfo, range) + markRead = { range, unreadCountAfter -> + chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) withApi { chatModel.controller.apiChatRead( @@ -186,6 +196,7 @@ fun ChatView(chatModel: ChatModel) { fun ChatLayout( user: User, chat: Chat, + unreadCount: State, composeState: MutableState, composeView: (@Composable () -> Unit), attachmentOption: MutableState, @@ -203,7 +214,7 @@ fun ChatLayout( startCall: (CallMediaType) -> Unit, acceptCall: (Contact) -> Unit, addMembers: (GroupInfo) -> Unit, - markRead: (CC.ItemRange) -> Unit, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, ) { Surface( Modifier @@ -223,15 +234,19 @@ fun ChatLayout( sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { + val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) } Scaffold( topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) }, bottomBar = composeView, - modifier = Modifier.navigationBarsWithImePadding() + modifier = Modifier.navigationBarsWithImePadding(), + floatingActionButton = floatingButton.value, ) { contentPadding -> BoxWithConstraints(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, composeState, chatItems, + ChatItemsList( + user, chat, unreadCount, composeState, chatItems, useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage, - receiveFile, joinGroup, acceptCall, markRead) + receiveFile, joinGroup, acceptCall, markRead, floatingButton + ) } } } @@ -337,6 +352,7 @@ val CIListStateSaver = run { fun BoxWithConstraintsScope.ChatItemsList( user: User, chat: Chat, + unreadCount: State, composeState: MutableState, chatItems: List, useLinkPreviews: Boolean, @@ -346,28 +362,20 @@ fun BoxWithConstraintsScope.ChatItemsList( receiveFile: (Long) -> Unit, joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, - markRead: (CC.ItemRange) -> Unit, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + floatingButton: MutableState<@Composable () -> Unit> ) { - val firstVisibleOffset = -with(LocalDensity.current) { maxHeight.roundToPx() } - - // Places first unread message at the top of a screen - val listState = rememberLazyListState( - initialFirstVisibleItemIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0), - initialFirstVisibleItemScrollOffset = firstVisibleOffset - ) + val listState = rememberLazyListState() val scope = rememberCoroutineScope() val uriHandler = LocalUriHandler.current val cxt = LocalContext.current - // Prevent scrolling to bottom on orientation change + // Helps to scroll to bottom after moving from Group to Direct chat + // and prevents scrolling to bottom on orientation change var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType) { - val firstUnreadIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0) - if (shouldAutoScroll && listState.firstVisibleItemIndex != firstUnreadIndex) { - scope.launch { - // Places first unread message at the top of a screen after moving from group to direct chat - listState.scrollToItem(firstUnreadIndex, firstVisibleOffset) - } + if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } } // Don't autoscroll next time until it will be needed shouldAutoScroll = false @@ -432,12 +440,89 @@ fun BoxWithConstraintsScope.ChatItemsList( LaunchedEffect(cItem.id) { scope.launch { delay(750) - markRead(CC.ItemRange(cItem.id, cItem.id)) + markRead(CC.ItemRange(cItem.id, cItem.id), null) } } } } } + FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, markRead, floatingButton, listState) +} + +@Composable +fun BoxWithConstraintsScope.FloatingButtons( + chatItems: List, + unreadCount: State, + minUnreadItemId: Long, + markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + floatingButton: MutableState<@Composable () -> Unit>, + listState: LazyListState +) { + val scope = rememberCoroutineScope() + + val bottomUnreadCount by remember { derivedStateOf { + chatItems.subList( + chatItems.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex, + chatItems.size + ).count { it.isRcvNew } } + } + + val firstItemIsVisible by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } + val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt() + + LaunchedEffect(bottomUnreadCount, firstItemIsVisible) { + val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible + val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible + floatingButton.value = bottomEndFloatingButton( + bottomUnreadCount, + showButtonWithCounter, + showButtonWithArrow, + onClickArrowDown = { + scope.launch { listState.animateScrollToItem(0) } + }, + onClickCounter = { + scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) } + } + ) + } + + val fabSize = 56.dp + val topUnreadCount by remember { + derivedStateOf { unreadCount.value - bottomUnreadCount } + } + val showButtonWithCounter = topUnreadCount > 0 + val height = with(LocalDensity.current) { maxHeight.toPx() } + var showDropDown by remember { mutableStateOf(false) } + + TopEndFloatingButton( + Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd), + topUnreadCount, + showButtonWithCounter, + onClick = { scope.launch { listState.animateScrollBy(height) } }, + onLongClick = { showDropDown = true } + ) + + DropdownMenu( + expanded = showDropDown, + onDismissRequest = { showDropDown = false }, + Modifier.width(220.dp), + offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize) + ) { + DropdownMenuItem( + onClick = { + markRead( + CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), + bottomUnreadCount + ) + showDropDown = false + } + ) { + Text( + generalGetString(R.string.mark_read), + maxLines = 1, + ) + } + } } @Composable @@ -480,6 +565,75 @@ fun MemberImage(member: GroupMember) { ProfileImage(38.dp, member.memberProfile.image) } +@Composable +private fun TopEndFloatingButton( + modifier: Modifier = Modifier, + unreadCount: Int, + showButtonWithCounter: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) = when { + showButtonWithCounter -> { + val interactionSource = interactionSourceWithDetection(onClick, onLongClick) + FloatingActionButton( + {}, // no action here + modifier.size(48.dp), + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp), + interactionSource = interactionSource, + ) { + Text( + unreadCountStr(unreadCount), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } + else -> { + } +} + +private fun bottomEndFloatingButton( + unreadCount: Int, + showButtonWithCounter: Boolean, + showButtonWithArrow: Boolean, + onClickArrowDown: () -> Unit, + onClickCounter: () -> Unit +): @Composable () -> Unit = when { + showButtonWithCounter -> { + { + FloatingActionButton( + onClick = onClickCounter, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.size(48.dp) + ) { + Text( + unreadCountStr(unreadCount), + color = MaterialTheme.colors.primary, + fontSize = 14.sp, + ) + } + } + } + showButtonWithArrow -> { + { + FloatingActionButton( + onClick = onClickArrowDown, + elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + } + } + else -> { + {} + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -507,6 +661,7 @@ fun PreviewChatLayout() { 6, CIDirection.DirectRcv(), Clock.System.now(), "hello" ) ) + val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } ChatLayout( user = User.sampleData, chat = Chat( @@ -514,6 +669,7 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), + unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, @@ -531,7 +687,7 @@ fun PreviewChatLayout() { startCall = {}, acceptCall = { _ -> }, addMembers = { _ -> }, - markRead = { _ -> }, + markRead = { _, _ -> }, ) } } @@ -558,6 +714,7 @@ fun PreviewGroupChatLayout() { 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" ) ) + val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } ChatLayout( user = User.sampleData, chat = Chat( @@ -565,6 +722,7 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), + unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = {}, attachmentOption = remember { mutableStateOf(null) }, @@ -582,7 +740,7 @@ fun PreviewGroupChatLayout() { startCall = {}, acceptCall = { _ -> }, addMembers = { _ -> }, - markRead = { _ -> }, + markRead = { _, _ -> }, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index ec5a2d94b..65161deee 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -140,7 +140,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { contentAlignment = Alignment.Center ) { Text( - if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation), + unreadCountStr(n), color = MaterialTheme.colors.onPrimary, fontSize = 11.sp, modifier = Modifier @@ -163,6 +163,11 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) { } } +@Composable +fun unreadCountStr(n: Int): String { + return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation) +} + @Composable fun ChatStatusImage(chat: Chat) { val s = chat.serverInfo.networkStatus diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt index 306d7113b..e29f2de9c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -16,7 +16,13 @@ package chat.simplex.app.views.helpers +import android.util.Log +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.interaction.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException @@ -31,19 +37,19 @@ import androidx.compose.ui.input.pointer.consumeAllChanges import androidx.compose.ui.input.pointer.consumeDownChange import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.positionChangeConsumed +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import chat.simplex.app.TAG +import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex /** * See original code here: [androidx.compose.foundation.gestures.detectTapGestures] * */ - -interface PressGestureScope : Density { +interface PressGestureScope: Density { suspend fun tryAwaitRelease(): Boolean } @@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture( if (onPress !== NoPressGesture) launch { pressScope.onPress(down.position) } - val longPressTimeout = onLongPress?.let { viewConfiguration.longPressTimeoutMillis } ?: (Long.MAX_VALUE / 2) @@ -81,7 +86,6 @@ suspend fun PointerInputScope.detectGesture( } else { if (shouldConsume) upOrCancel.consumeDownChange() - // If onLongPress event is needed, cancel short press event if (onLongPress != null) pressScope.cancel() @@ -138,7 +142,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange ) { return null } - val consumeCheck = awaitPointerEvent(PointerEventPass.Final) if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) { return null @@ -148,7 +151,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange private class PressGestureScopeImpl( density: Density -) : PressGestureScope, Density by density { +): PressGestureScope, Density by density { private var isReleased = false private var isCanceled = false private val mutex = Mutex(locked = false) @@ -176,3 +179,49 @@ private class PressGestureScopeImpl( return isCanceled } } + +/** + * Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing. + * Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]). + * Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable] + * */ +@Composable +fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource { + val interactionSource = remember { MutableInteractionSource() } + val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis + var topLevelInteraction: Interaction? by remember { mutableStateOf(null) } + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + topLevelInteraction = interaction + } + } + LaunchedEffect(topLevelInteraction is PressInteraction.Press) { + if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect + try { + withTimeout(longPressTimeoutMillis) { + while (isActive) { + delay(10) + when (topLevelInteraction) { + is PressInteraction.Press -> {} + is PressInteraction.Release -> { + onClick(); break + } + is PressInteraction.Cancel -> break + } + } + } + } catch (_: TimeoutCancellationException) { + // Long click happened + onLongClick() + } catch (ex: CancellationException) { + // Canceled coroutine + PressInteraction.Release == short click + if (topLevelInteraction is PressInteraction.Release) + onClick() + Log.e(TAG, ex.stackTraceToString()) + } catch (ex: Exception) { + // Should never be called + Log.e(TAG, ex.stackTraceToString()) + } + } + return interactionSource +}