desktop: better logic when switching chats (#2898)

* desktop: better logic when switching chats

* auto scroll to top when selected chat changes

* multiplatform: members page performance

* preloading group members

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko 2023-08-14 23:05:53 +03:00 committed by GitHub
parent 8dcb70c019
commit 782355ccb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 195 additions and 107 deletions

View File

@ -38,6 +38,7 @@ import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@Composable
@ -51,15 +52,16 @@ fun ChatInfoView(
close: () -> Unit,
) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
val connStats = remember { mutableStateOf(connectionStats) }
val contact = rememberUpdatedState(contact).value
val chat = remember(contact.id) { chatModel.chats.firstOrNull { it.id == contact.id } }
val currentUser = remember { chatModel.currentUser }.value
val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
if (chat != null && currentUser != null) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) {
mutableStateOf(chatModel.contactNetworkStatus(contact))
}
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
chat,
contact,
@ -239,7 +241,7 @@ fun ChatInfoLayout(
currentUser: User,
sendReceipts: State<SendReceipts>,
setSendReceipts: (SendReceipts) -> Unit,
connStats: MutableState<ConnectionStats?>,
connStats: State<ConnectionStats?>,
contactNetworkStatus: NetworkStatus,
customUserProfile: Profile?,
localAlias: String,
@ -256,10 +258,15 @@ fun ChatInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { scrollState.scrollTo(0) }
}
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
) {
Row(
Modifier.fillMaxWidth(),

View File

@ -133,27 +133,45 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chatId.value = null
},
info = {
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
withApi {
// The idea is to preload information before showing a modal because large groups can take time to load all members
var preloadedContactInfo: Pair<ConnectionStats, Profile?>? = null
var preloadedCode: String? = null
var preloadedLink: Pair<String, GroupMemberRole>? = null
if (chat.chatInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
preloadedContactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedCode = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
} else if (chat.chatInfo is ChatInfo.Group) {
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
var groupLink = link?.first
var groupLinkMemberRole = link?.second
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
groupLink = it.first;
groupLinkMemberRole = it.second
preloadedLink = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
}
ModalManager.end.showModalCloseable(true) { close ->
val chat = remember { activeChat }.value
if (chat?.chatInfo is ChatInfo.Direct) {
var contactInfo: Pair<ConnectionStats, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId).second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
setGroupMembers((chat.chatInfo as ChatInfo.Group).groupInfo, chatModel)
link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, link?.first, link?.second, {
link = it
preloadedLink = it
}, close)
}
}

View File

@ -71,6 +71,9 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
close = close,
)
KeyChangeEffect(chatModel.chatId.value) {
close()
}
}
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {

View File

@ -8,8 +8,8 @@ import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@ -33,11 +33,12 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
val currentUser = chatModel.currentUser.value
@ -177,79 +178,92 @@ fun GroupChatInfoLayout(
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
) {
Column(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
KeyChangeEffect(chat.id) {
scope.launch { listState.scrollToItem(0) }
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember(members) { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
LazyColumn(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
state = listState
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
GroupChatInfoHeader(chat.chatInfo)
}
SectionSpacer()
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
SendReceiptsOptionDisabled()
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
SectionView {
if (groupInfo.canEdit) {
EditGroupProfileButton(editGroupProfile)
}
if (groupInfo.groupProfile.description != null || groupInfo.canEdit) {
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
}
GroupPreferencesButton(openPreferences)
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val filteredMembers = remember { derivedStateOf { members.filter { it.chatViewName.lowercase().contains(searchText.value.text.trim()) } } }
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
SendReceiptsOptionDisabled()
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
MembersList(filteredMembers.value, showMemberInfo)
}
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
SectionTextFooter(stringResource(MR.strings.only_group_owners_can_change_prefs))
SectionDividerSpaced(maxTopPadding = true)
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), members.count() + 1)) {
if (groupInfo.canAddMembers) {
if (groupLink == null) {
CreateGroupLinkButton(manageGroupLink)
} else {
GroupLinkButton(manageGroupLink)
}
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
AddMembersButton(tint, onAddMembersClick)
}
if (members.size > 8) {
SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) {
SearchRowView(searchText)
}
}
SectionItemView(minHeight = 54.dp) {
MemberRow(groupInfo.membership, user = true)
}
}
}
SectionBottomSpacer()
items(filteredMembers.value) { member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
item {
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
SectionView {
ClearChatButton(clearChat)
if (groupInfo.canDelete) {
DeleteGroupButton(deleteGroup)
}
if (groupInfo.membership.memberCurrent) {
LeaveGroupButton(leaveGroup)
}
}
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), groupInfo.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), groupInfo.apiId.toString())
}
}
SectionBottomSpacer()
}
}
}
@ -330,18 +344,6 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick
)
}
@Composable
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
Column {
members.forEachIndexed { index, member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
}
}
}
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
Row(

View File

@ -23,7 +23,7 @@ import chat.simplex.common.views.newchat.QRCode
import chat.simplex.res.MR
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
@ -34,7 +34,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
creatingLink = false
}
@ -60,7 +60,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
onGroupLinkUpdated(link)
}
}
}
@ -75,7 +75,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null to null)
onGroupLinkUpdated(null)
}
}
},

View File

@ -368,7 +368,12 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
stringResource(MR.strings.delete_verb),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {
if (chatModel.chatId.value == null) {
ModalManager.center.closeModals()
ModalManager.end.closeModals()
}
}
showMenu.value = false
},
color = Color.Red

View File

@ -61,6 +61,13 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
connectIfOpenedViaUri(url, chatModel)
}
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
var searchInList by rememberSaveable { mutableStateOf("") }
val scope = rememberCoroutineScope()

View File

@ -97,6 +97,12 @@ class ModalManager(private val placement: ModalPlacement? = null) {
modalCount.value = 0
}
fun closeModalsExceptFirst() {
while (modalCount.value > 1) {
closeModal()
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun showInView() {

View File

@ -329,3 +329,43 @@ fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit =
}
}
}
/**
* Runs the [block] only after initial value of the [key1] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun KeyChangeEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val initialKey = remember { key1 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != initialKey) {
block()
anyChange = true
}
}
}
/**
* Runs the [block] only after initial value of the [key1] or [key2] changes, not after initial launch
* */
@Composable
@NonRestartableComposable
fun KeyChangeEffect(
key1: Any?,
key2: Any?,
block: suspend CoroutineScope.() -> Unit
) {
val initialKey = remember { key1 }
val initialKey2 = remember { key2 }
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
}
}
}

View File

@ -40,7 +40,7 @@ actual fun screenOrientation(): ScreenOrientation = ScreenOrientation.UNDEFINED
@Composable // LALAL
actual fun screenWidth(): Dp {
return java.awt.Toolkit.getDefaultToolkit().screenSize.width.dp.also { println("LALAL $it") }
return java.awt.Toolkit.getDefaultToolkit().screenSize.width.dp
/*var width by remember { mutableStateOf(java.awt.Toolkit.getDefaultToolkit().screenSize.width.also { println("LALAL $it") }) }
SideEffect {
if (width != java.awt.Toolkit.getDefaultToolkit().screenSize.width)