mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-03-14 09:45:42 +00:00
Merge branch 'master' into dc/ios-chat-tags-ux-improvements
This commit is contained in:
commit
91b5d7faac
21 changed files with 302 additions and 219 deletions
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import chat.simplex.common.AppScreen
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.clear
|
||||
import chat.simplex.common.model.clearAndNotify
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
@ -74,9 +75,13 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
|
|||
if (ModalManager.start.hasModalsOpen()) {
|
||||
ModalManager.start.closeModal()
|
||||
} else if (chatModel.chatId.value != null) {
|
||||
// Since no modals are open, the problem is probably in ChatView
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withApi {
|
||||
withChats {
|
||||
// Since no modals are open, the problem is probably in ChatView
|
||||
chatModel.chatId.value = null
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ChatList, nothing to do. Maybe to show other view except ChatList
|
||||
}
|
||||
|
|
|
@ -29,6 +29,19 @@ private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFF
|
|||
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
|
||||
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
|
||||
|
||||
@Composable
|
||||
actual fun TagsRow(content: @Composable() (() -> Unit)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
val onClick = { platform.androidStartCallActivity(false) }
|
||||
|
|
|
@ -61,7 +61,6 @@ object ChatModel {
|
|||
val incompleteInitializedDbRemoved = mutableStateOf(false)
|
||||
private val _chats = mutableStateOf(SnapshotStateList<Chat>())
|
||||
val chats: State<List<Chat>> = _chats
|
||||
private val chatsContext = ChatsContext()
|
||||
// map of connections network statuses, key is agent connection id
|
||||
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
|
||||
val switchingUsersAndHosts = mutableStateOf(false)
|
||||
|
@ -72,15 +71,18 @@ object ChatModel {
|
|||
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
|
||||
* If you use api call to get the items, use just [add] instead of [addAndNotify].
|
||||
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
|
||||
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
|
||||
private val _chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
|
||||
val chatItems: State<SnapshotStateList<ChatItem>> = _chatItems
|
||||
// declaration of chatsContext should be after any other variable that is directly attached to ChatsContext class, otherwise, strange crash with NullPointerException for "this" parameter in random functions
|
||||
private val chatsContext = ChatsContext()
|
||||
// set listener here that will be notified on every add/delete of a chat item
|
||||
var chatItemsChangesListener: ChatItemsChangesListener? = null
|
||||
val chatState = ActiveChatState()
|
||||
// rhId, chatId
|
||||
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
|
||||
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
|
||||
val groupMembers = mutableStateListOf<GroupMember>()
|
||||
val groupMembersIndexes = mutableStateMapOf<Long, Int>()
|
||||
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
|
||||
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
|
||||
|
||||
// Chat Tags
|
||||
val userTags = mutableStateOf(emptyList<ChatTag>())
|
||||
|
@ -157,7 +159,6 @@ object ChatModel {
|
|||
val updatingProgress = mutableStateOf(null as Float?)
|
||||
var updatingRequest: Closeable? = null
|
||||
|
||||
private val updatingChatsMutex: Mutex = Mutex()
|
||||
val changingActiveUserMutex: Mutex = Mutex()
|
||||
|
||||
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
|
||||
|
@ -321,27 +322,31 @@ object ChatModel {
|
|||
fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
|
||||
|
||||
fun populateGroupMembersIndexes() {
|
||||
groupMembersIndexes.clear()
|
||||
groupMembers.forEachIndexed { i, member ->
|
||||
groupMembersIndexes[member.groupMemberId] = i
|
||||
groupMembersIndexes.value = emptyMap()
|
||||
val gmIndexes = groupMembersIndexes.value.toMutableMap()
|
||||
groupMembers.value.forEachIndexed { i, member ->
|
||||
gmIndexes[member.groupMemberId] = i
|
||||
}
|
||||
groupMembersIndexes.value = gmIndexes
|
||||
}
|
||||
|
||||
fun getGroupMember(groupMemberId: Long): GroupMember? {
|
||||
val memberIndex = groupMembersIndexes[groupMemberId]
|
||||
val memberIndex = groupMembersIndexes.value[groupMemberId]
|
||||
return if (memberIndex != null) {
|
||||
groupMembers[memberIndex]
|
||||
groupMembers.value[memberIndex]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <T> withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock {
|
||||
// running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread
|
||||
suspend fun <T> withChats(action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) {
|
||||
chatsContext.action()
|
||||
}
|
||||
|
||||
class ChatsContext {
|
||||
val chats = _chats
|
||||
val chatItems = _chatItems
|
||||
|
||||
suspend fun addChat(chat: Chat) {
|
||||
chats.add(index = 0, chat)
|
||||
|
@ -694,7 +699,7 @@ object ChatModel {
|
|||
}
|
||||
// update current chat
|
||||
return if (chatId.value == groupInfo.id) {
|
||||
val memberIndex = groupMembersIndexes[member.groupMemberId]
|
||||
val memberIndex = groupMembersIndexes.value[member.groupMemberId]
|
||||
val updated = chatItems.value.map {
|
||||
// Take into account only specific changes, not all. Other member updates are not important and can be skipped
|
||||
if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId &&
|
||||
|
@ -710,12 +715,17 @@ object ChatModel {
|
|||
if (updated != chatItems.value) {
|
||||
chatItems.replaceAll(updated)
|
||||
}
|
||||
val gMembers = groupMembers.value.toMutableList()
|
||||
if (memberIndex != null) {
|
||||
groupMembers[memberIndex] = member
|
||||
gMembers[memberIndex] = member
|
||||
groupMembers.value = gMembers
|
||||
false
|
||||
} else {
|
||||
groupMembers.add(member)
|
||||
groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1
|
||||
gMembers.add(member)
|
||||
groupMembers.value = gMembers
|
||||
val gmIndexes = groupMembersIndexes.value.toMutableMap()
|
||||
gmIndexes[member.groupMemberId] = groupMembers.size - 1
|
||||
groupMembersIndexes.value = gmIndexes
|
||||
true
|
||||
}
|
||||
} else {
|
||||
|
@ -762,7 +772,7 @@ object ChatModel {
|
|||
|
||||
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
|
||||
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
|
||||
withContext(Dispatchers.Main) {
|
||||
withChats {
|
||||
chatItems.addAndNotify(cItem)
|
||||
}
|
||||
return cItem
|
||||
|
@ -770,7 +780,11 @@ object ChatModel {
|
|||
|
||||
fun removeLiveDummy() {
|
||||
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
chatItems.removeLastAndNotify()
|
||||
withApi {
|
||||
withChats {
|
||||
chatItems.removeLastAndNotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -891,19 +905,25 @@ object ChatModel {
|
|||
|
||||
fun replaceConnReqView(id: String, withId: String) {
|
||||
if (id == showingInvitation.value?.connId) {
|
||||
showingInvitation.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = withId
|
||||
withApi {
|
||||
withChats {
|
||||
showingInvitation.value = null
|
||||
chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = withId
|
||||
}
|
||||
}
|
||||
ModalManager.start.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissConnReqView(id: String) {
|
||||
fun dismissConnReqView(id: String) = withApi {
|
||||
if (id == showingInvitation.value?.connId) {
|
||||
showingInvitation.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = null
|
||||
withChats {
|
||||
showingInvitation.value = null
|
||||
chatItems.clearAndNotify()
|
||||
chatModel.chatId.value = null
|
||||
}
|
||||
// Close NewChatView
|
||||
ModalManager.start.closeModals()
|
||||
ModalManager.center.closeModals()
|
||||
|
@ -1334,7 +1354,13 @@ sealed class ChatInfo: SomeChat, NamedChat {
|
|||
is Group -> groupInfo.chatTags
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val contactCard: Boolean
|
||||
get() = when (this) {
|
||||
is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class NetworkStatus {
|
||||
|
|
|
@ -683,6 +683,8 @@ object ChatController {
|
|||
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
|
||||
}
|
||||
val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c)
|
||||
// coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups)
|
||||
interruptIfCancelled()
|
||||
val r = APIResponse.decodeStr(json)
|
||||
if (log) {
|
||||
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
|
||||
|
@ -3044,8 +3046,8 @@ object ChatController {
|
|||
chatModel.users.addAll(users)
|
||||
chatModel.currentUser.value = user
|
||||
if (user == null) {
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
chats.clear()
|
||||
popChatCollector.clear()
|
||||
}
|
||||
|
|
|
@ -45,9 +45,9 @@ suspend fun apiLoadMessages(
|
|||
addChat(chat)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
withChats {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
splits.value = newSplits
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
|
@ -70,8 +70,8 @@ suspend fun apiLoadMessages(
|
|||
)
|
||||
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
|
||||
newItems.addAll(insertAt, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
|
@ -89,8 +89,8 @@ suspend fun apiLoadMessages(
|
|||
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
|
||||
val indexToAddIsLast = indexToAdd == newItems.size
|
||||
newItems.addAll(indexToAdd, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
|
@ -104,8 +104,8 @@ suspend fun apiLoadMessages(
|
|||
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.addAll(0, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = listOf(chat.chatItems.last().id) + newSplits
|
||||
unreadAfterItemId.value = chat.chatItems.last().id
|
||||
totalAfter.value = navInfo.afterTotal
|
||||
|
@ -119,8 +119,8 @@ suspend fun apiLoadMessages(
|
|||
newItems.addAll(oldItems)
|
||||
removeDuplicates(newItems, chat)
|
||||
newItems.addAll(chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats {
|
||||
chatItems.replaceAll(newItems)
|
||||
unreadAfterNewestLoaded.value = 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,6 +114,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) {
|
||||
when (chatInfo) {
|
||||
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
|
||||
var groupMembersJob: Job = remember { Job() }
|
||||
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
|
||||
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
|
||||
val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||
|
@ -220,8 +221,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
hideKeyboard(view)
|
||||
AudioPlayer.stop()
|
||||
chatModel.chatId.value = null
|
||||
chatModel.groupMembers.clear()
|
||||
chatModel.groupMembersIndexes.clear()
|
||||
chatModel.groupMembers.value = emptyList()
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
},
|
||||
info = {
|
||||
if (ModalManager.end.hasModalsOpen()) {
|
||||
|
@ -229,7 +230,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
return@ChatLayout
|
||||
}
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
groupMembersJob.cancel()
|
||||
groupMembersJob = scope.launch(Dispatchers.Default) {
|
||||
// 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
|
||||
|
@ -241,6 +243,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
setGroupMembers(chatRh, chatInfo.groupInfo, chatModel)
|
||||
preloadedLink = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId)
|
||||
}
|
||||
if (!isActive) return@launch
|
||||
|
||||
ModalManager.end.showModalCloseable(true) { close ->
|
||||
val chatInfo = remember { activeChatInfo }.value
|
||||
if (chatInfo is ChatInfo.Direct) {
|
||||
|
@ -276,7 +280,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
},
|
||||
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
|
||||
hideKeyboard(view)
|
||||
withBGApi {
|
||||
groupMembersJob.cancel()
|
||||
groupMembersJob = scope.launch(Dispatchers.Default) {
|
||||
val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId)
|
||||
val stats = r?.second
|
||||
val (_, code) = if (member.memberActive) {
|
||||
|
@ -286,6 +291,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
member to null
|
||||
}
|
||||
setGroupMembers(chatRh, groupInfo, chatModel)
|
||||
if (!isActive) return@launch
|
||||
|
||||
ModalManager.end.closeModals()
|
||||
ModalManager.end.showModalCloseable(true) { close ->
|
||||
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
|
||||
|
@ -431,7 +438,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
chatModel.getChat(chatId)
|
||||
},
|
||||
findModelMember = { memberId ->
|
||||
chatModel.groupMembers.find { it.id == memberId }
|
||||
chatModel.groupMembers.value.find { it.id == memberId }
|
||||
},
|
||||
setReaction = { cInfo, cItem, add, reaction ->
|
||||
withBGApi {
|
||||
|
@ -451,17 +458,19 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
}
|
||||
},
|
||||
showItemDetails = { cInfo, cItem ->
|
||||
suspend fun loadChatItemInfo(): ChatItemInfo? {
|
||||
suspend fun loadChatItemInfo(): ChatItemInfo? = coroutineScope {
|
||||
val ciInfo = chatModel.controller.apiGetChatItemInfo(chatRh, cInfo.chatType, cInfo.apiId, cItem.id)
|
||||
if (ciInfo != null) {
|
||||
if (chatInfo is ChatInfo.Group) {
|
||||
setGroupMembers(chatRh, chatInfo.groupInfo, chatModel)
|
||||
if (!isActive) return@coroutineScope null
|
||||
}
|
||||
}
|
||||
return ciInfo
|
||||
ciInfo
|
||||
}
|
||||
withBGApi {
|
||||
var initialCiInfo = loadChatItemInfo() ?: return@withBGApi
|
||||
groupMembersJob.cancel()
|
||||
groupMembersJob = scope.launch(Dispatchers.Default) {
|
||||
var initialCiInfo = loadChatItemInfo() ?: return@launch
|
||||
ModalManager.end.closeModals()
|
||||
ModalManager.end.showModalCloseable(endButtons = {
|
||||
ShareButton {
|
||||
|
@ -550,7 +559,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
LaunchedEffect(chatInfo.id) {
|
||||
onComposed(chatInfo.id)
|
||||
ModalManager.end.closeModals()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
is ChatInfo.InvalidJSON -> {
|
||||
|
@ -561,7 +572,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
|||
LaunchedEffect(chatInfo.id) {
|
||||
onComposed(chatInfo.id)
|
||||
ModalManager.end.closeModals()
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
|
|
|
@ -83,7 +83,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
|
|||
|
||||
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
|
||||
val s = search.trim().lowercase()
|
||||
val memberContactIds = chatModel.groupMembers
|
||||
val memberContactIds = chatModel.groupMembers.value
|
||||
.filter { it.memberCurrent }
|
||||
.mapNotNull { it.memberContactId }
|
||||
return chatModel.chats.value
|
||||
|
|
|
@ -40,7 +40,7 @@ import chat.simplex.common.views.chat.item.ItemAction
|
|||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
||||
|
||||
|
@ -54,6 +54,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
|||
if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
|
||||
val groupInfo = chat.chatInfo.groupInfo
|
||||
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
GroupChatInfoLayout(
|
||||
chat,
|
||||
groupInfo,
|
||||
|
@ -64,14 +65,16 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
|||
updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel)
|
||||
sendReceipts.value = sendRcpts
|
||||
},
|
||||
members = chatModel.groupMembers
|
||||
members = remember { chatModel.groupMembers }.value
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedByDescending { it.memberRole },
|
||||
developerTools,
|
||||
groupLink,
|
||||
addMembers = {
|
||||
withBGApi {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
setGroupMembers(rhId, groupInfo, chatModel)
|
||||
if (!isActive) return@launch
|
||||
|
||||
ModalManager.end.showModalCloseable(true) { close ->
|
||||
AddGroupMembersView(rhId, groupInfo, false, chatModel, close)
|
||||
}
|
||||
|
|
|
@ -98,8 +98,8 @@ fun GroupMemberInfoView(
|
|||
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
|
||||
withChats {
|
||||
addChat(memberChat)
|
||||
openLoadedChat(memberChat)
|
||||
}
|
||||
openLoadedChat(memberChat)
|
||||
closeAll()
|
||||
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
|
||||
}
|
||||
|
|
|
@ -216,21 +216,26 @@ suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.
|
|||
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) =
|
||||
apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
|
||||
|
||||
fun openLoadedChat(chat: Chat) {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
chatModel.chatState.clear()
|
||||
suspend fun openLoadedChat(chat: Chat) {
|
||||
withChats {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
chatModel.chatState.clear()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiFindMessages(ch: Chat, search: String) {
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search)
|
||||
}
|
||||
|
||||
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
|
||||
// groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled
|
||||
val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId)
|
||||
val currentMembers = chatModel.groupMembers
|
||||
val currentMembers = chatModel.groupMembers.value
|
||||
val newMembers = groupMembers.map { newMember ->
|
||||
val currentMember = currentMembers.find { it.id == newMember.id }
|
||||
val currentMemberStats = currentMember?.activeConn?.connectionStats
|
||||
|
@ -241,9 +246,8 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
|
|||
newMember
|
||||
}
|
||||
}
|
||||
chatModel.groupMembers.clear()
|
||||
chatModel.groupMembersIndexes.clear()
|
||||
chatModel.groupMembers.addAll(newMembers)
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.groupMembers.value = newMembers
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
}
|
||||
|
||||
|
@ -351,14 +355,14 @@ fun TagListAction(
|
|||
) {
|
||||
val userTags = remember { chatModel.userTags }
|
||||
ItemAction(
|
||||
stringResource(MR.strings.list_menu),
|
||||
stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list),
|
||||
painterResource(MR.images.ic_label),
|
||||
onClick = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
if (userTags.value.isEmpty()) {
|
||||
TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close)
|
||||
} else {
|
||||
TagListView(rhId = chat.remoteHostId, chat = chat, close = close)
|
||||
TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false)
|
||||
}
|
||||
}
|
||||
showMenu.value = false
|
||||
|
|
|
@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS }
|
||||
enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES }
|
||||
|
||||
sealed class ActiveFilter {
|
||||
data class PresetTag(val tag: PresetTagKind) : ActiveFilter()
|
||||
|
@ -815,13 +815,13 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
if (oneHandUI.value) {
|
||||
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
|
||||
Divider()
|
||||
TagsView()
|
||||
TagsView(searchText)
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
}
|
||||
} else {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
TagsView()
|
||||
TagsView(searchText)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
@ -925,25 +925,13 @@ private fun ChatListFeatureCards() {
|
|||
private val TAG_MIN_HEIGHT = 35.dp
|
||||
|
||||
@Composable
|
||||
private fun TagsView() {
|
||||
private fun TagsView(searchText: MutableState<TextFieldValue>) {
|
||||
val userTags = remember { chatModel.userTags }
|
||||
val presetTags = remember { chatModel.presetTags }
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }
|
||||
val unreadTags = remember { chatModel.unreadTags }
|
||||
val rhId = chatModel.remoteHostId()
|
||||
|
||||
fun showTagList() {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
val editMode = remember { stateGetOrPut("editMode") { false } }
|
||||
ModalView(close, showClose = true, endButtons = {
|
||||
TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) {
|
||||
Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb))
|
||||
}
|
||||
}) {
|
||||
TagListView(rhId = rhId, close = close, editMode = editMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
|
||||
TagsRow {
|
||||
|
@ -953,7 +941,7 @@ private fun TagsView() {
|
|||
ExpandedTagFilterView(tag)
|
||||
}
|
||||
} else {
|
||||
CollapsedTagsFilterView()
|
||||
CollapsedTagsFilterView(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -963,69 +951,75 @@ private fun TagsView() {
|
|||
else -> false
|
||||
}
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
rowSizeModifier
|
||||
.clip(shape = CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
} else {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag)
|
||||
val showMenu = rememberSaveable { mutableStateOf(false) }
|
||||
val saving = remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
rowSizeModifier
|
||||
.clip(shape = CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
} else {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag)
|
||||
}
|
||||
},
|
||||
onLongClick = { showMenu.value = true },
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current,
|
||||
enabled = !saving.value
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (tag.chatTagEmoji != null) {
|
||||
ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp)
|
||||
} else {
|
||||
Icon(
|
||||
painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label),
|
||||
null,
|
||||
Modifier.size(18.sp.toDp()),
|
||||
tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box {
|
||||
val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else ""
|
||||
val invisibleText = buildAnnotatedString {
|
||||
append(tag.chatTagText)
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) {
|
||||
append(badgeText)
|
||||
}
|
||||
},
|
||||
onLongClick = { showTagList() },
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current
|
||||
)
|
||||
.onRightClick { showTagList() }
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (tag.chatTagEmoji != null) {
|
||||
ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp)
|
||||
} else {
|
||||
Icon(
|
||||
painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label),
|
||||
null,
|
||||
Modifier.size(18.sp.toDp()),
|
||||
tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box {
|
||||
val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else ""
|
||||
val invisibleText = buildAnnotatedString {
|
||||
append(tag.chatTagText)
|
||||
withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) {
|
||||
append(badgeText)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = invisibleText,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
color = Color.Transparent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Visible text with styles
|
||||
val visibleText = buildAnnotatedString {
|
||||
append(tag.chatTagText)
|
||||
withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) {
|
||||
append(badgeText)
|
||||
Text(
|
||||
text = invisibleText,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
color = Color.Transparent,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
// Visible text with styles
|
||||
val visibleText = buildAnnotatedString {
|
||||
append(tag.chatTagText)
|
||||
withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) {
|
||||
append(badgeText)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = visibleText,
|
||||
fontWeight = if (current) FontWeight.Medium else FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = visibleText,
|
||||
fontWeight = if (current) FontWeight.Medium else FontWeight.Normal,
|
||||
fontSize = 15.sp,
|
||||
color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
TagsDropdownMenu(rhId, tag, showMenu, saving)
|
||||
}
|
||||
}
|
||||
val plusClickModifier = Modifier
|
||||
|
@ -1051,23 +1045,8 @@ private fun TagsView() {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TagsRow(content: @Composable() (() -> Unit)) {
|
||||
if (appPlatform.isAndroid) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp)
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() }
|
||||
}
|
||||
}
|
||||
expect fun TagsRow(content: @Composable() (() -> Unit))
|
||||
|
||||
@Composable
|
||||
private fun ExpandedTagFilterView(tag: PresetTagKind) {
|
||||
|
@ -1076,12 +1055,12 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) {
|
|||
is ActiveFilter.PresetTag -> af.tag == tag
|
||||
else -> false
|
||||
}
|
||||
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
val (icon, text) = presetTagLabel(tag, active)
|
||||
val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
|
||||
Row(
|
||||
modifier = rowSizeModifier
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable {
|
||||
if (activeFilter.value == ActiveFilter.PresetTag(tag)) {
|
||||
|
@ -1121,7 +1100,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) {
|
|||
|
||||
|
||||
@Composable
|
||||
private fun CollapsedTagsFilterView() {
|
||||
private fun CollapsedTagsFilterView(searchText: MutableState<TextFieldValue>) {
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }
|
||||
val presetTags = remember { chatModel.presetTags }
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
|
@ -1145,7 +1124,7 @@ private fun CollapsedTagsFilterView() {
|
|||
painterResource(icon),
|
||||
stringResource(text),
|
||||
Modifier.size(18.sp.toDp()),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
|
@ -1155,20 +1134,26 @@ private fun CollapsedTagsFilterView() {
|
|||
)
|
||||
}
|
||||
|
||||
DefaultDropdownMenu(showMenu = showMenu) {
|
||||
if (selectedPresetTag != null) {
|
||||
val onCloseMenuAction = remember { mutableStateOf<(() -> Unit)>({}) }
|
||||
|
||||
DefaultDropdownMenu(showMenu = showMenu, onClosed = onCloseMenuAction) {
|
||||
if (activeFilter.value != null || searchText.value.text.isNotBlank()) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.chat_list_all),
|
||||
painterResource(MR.images.ic_menu),
|
||||
onClick = {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
onCloseMenuAction.value = {
|
||||
searchText.value = TextFieldValue()
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
onCloseMenuAction.value = {}
|
||||
}
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
PresetTagKind.entries.forEach { tag ->
|
||||
if ((presetTags[tag] ?: 0) > 0) {
|
||||
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu)
|
||||
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1179,14 +1164,19 @@ private fun CollapsedTagsFilterView() {
|
|||
fun ItemPresetFilterAction(
|
||||
presetTag: PresetTagKind,
|
||||
active: Boolean,
|
||||
showMenu: MutableState<Boolean>
|
||||
showMenu: MutableState<Boolean>,
|
||||
onCloseMenuAction: MutableState<(() -> Unit)>
|
||||
) {
|
||||
val (icon, text) = presetTagLabel(presetTag, active)
|
||||
ItemAction(
|
||||
stringResource(text),
|
||||
painterResource(icon),
|
||||
color = if (active) MaterialTheme.colors.primary else Color.Unspecified,
|
||||
onClick = {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
|
||||
onCloseMenuAction.value = {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
|
||||
onCloseMenuAction.value = {}
|
||||
}
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
|
@ -1205,26 +1195,18 @@ fun filteredChats(
|
|||
} else {
|
||||
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
|
||||
if (s.isEmpty())
|
||||
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) }
|
||||
chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) }
|
||||
else {
|
||||
chats.filter { chat ->
|
||||
when (val cInfo = chat.chatInfo) {
|
||||
is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && (
|
||||
if (s.isEmpty()) {
|
||||
chat.id == chatModel.chatId.value || filtered(chat, activeFilter)
|
||||
} else {
|
||||
cInfo.anyNameContains(s)
|
||||
})
|
||||
is ChatInfo.Group -> if (s.isEmpty()) {
|
||||
chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
|
||||
} else {
|
||||
cInfo.anyNameContains(s)
|
||||
chat.id == chatModel.chatId.value ||
|
||||
when (val cInfo = chat.chatInfo) {
|
||||
is ChatInfo.Direct -> !cInfo.contact.chatDeleted && !chat.chatInfo.contactCard && cInfo.anyNameContains(s)
|
||||
is ChatInfo.Group -> cInfo.anyNameContains(s)
|
||||
is ChatInfo.Local -> cInfo.anyNameContains(s)
|
||||
is ChatInfo.ContactRequest -> cInfo.anyNameContains(s)
|
||||
is ChatInfo.ContactConnection -> cInfo.contactConnection.localAlias.lowercase().contains(s)
|
||||
is ChatInfo.InvalidJSON -> false
|
||||
}
|
||||
is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s)
|
||||
is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s)
|
||||
is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value)
|
||||
is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1256,6 +1238,10 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean =
|
|||
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business
|
||||
else -> false
|
||||
}
|
||||
PresetTagKind.NOTES -> when (chatInfo) {
|
||||
is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResource, StringResource> =
|
||||
|
@ -1264,6 +1250,7 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResou
|
|||
PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts
|
||||
PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups
|
||||
PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses
|
||||
PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes
|
||||
}
|
||||
|
||||
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
|
||||
|
|
|
@ -5,8 +5,7 @@ import SectionDivider
|
|||
import SectionItemView
|
||||
import TextIconSpaced
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
|
@ -44,12 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource
|
|||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState<Boolean> = remember { mutableStateOf(false) }) {
|
||||
if (remember { editMode }.value) {
|
||||
BackHandler {
|
||||
editMode.value = false
|
||||
}
|
||||
}
|
||||
fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) {
|
||||
val userTags = remember { chatModel.userTags }
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
|
||||
|
@ -77,7 +71,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
|
|||
val topPaddingToContent = topPaddingToContent(false)
|
||||
|
||||
LazyColumnWithScrollBar(
|
||||
modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier,
|
||||
modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier,
|
||||
contentPadding = PaddingValues(
|
||||
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
|
||||
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
|
||||
|
@ -97,7 +91,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
|
|||
}
|
||||
}
|
||||
|
||||
if (oneHandUI.value && !editMode.value) {
|
||||
if (oneHandUI.value && !reorderMode) {
|
||||
item {
|
||||
CreateList()
|
||||
}
|
||||
|
@ -111,15 +105,14 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
|
|||
backgroundColor = if (isDragging) colors.surface else Color.Unspecified
|
||||
) {
|
||||
Column {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val selected = chatTagIds.value.contains(tag.chatTagId)
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
|
||||
.combinedClickable(
|
||||
enabled = !saving.value,
|
||||
.clickable(
|
||||
enabled = !saving.value && !reorderMode,
|
||||
onClick = {
|
||||
if (chat == null) {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
|
@ -139,13 +132,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
|
|||
})
|
||||
}
|
||||
},
|
||||
onLongClick = if (editMode.value) null else {
|
||||
{ showMenu.value = true }
|
||||
},
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = LocalIndication.current
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
.padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
@ -163,21 +150,17 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
|
|||
if (selected) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (editMode.value) {
|
||||
} else if (reorderMode) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = {
|
||||
EditTagAction(rhId, tag, showMenu)
|
||||
DeleteTagAction(rhId, tag, showMenu, saving)
|
||||
})
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value && !editMode.value) {
|
||||
if (!oneHandUI.value && !reorderMode) {
|
||||
item {
|
||||
CreateList()
|
||||
}
|
||||
|
@ -279,7 +262,7 @@ fun ModalData.TagListEditor(
|
|||
|
||||
SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) {
|
||||
Text(
|
||||
generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list),
|
||||
generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list),
|
||||
color = if (disabled) colors.secondary else colors.primary
|
||||
)
|
||||
}
|
||||
|
@ -309,6 +292,15 @@ fun ModalData.TagListEditor(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagsDropdownMenu(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>, saving: MutableState<Boolean>) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = {
|
||||
EditTagAction(rhId, tag, showMenu)
|
||||
DeleteTagAction(rhId, tag, showMenu, saving)
|
||||
ChangeOrderTagAction(rhId, showMenu)
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>, saving: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
|
@ -343,6 +335,21 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Bool
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChangeOrderTagAction(rhId: Long?, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.change_order_chat_list_menu_action),
|
||||
painterResource(MR.images.ic_drag_handle),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
TagListView(rhId = rhId, close = close, reorderMode = true)
|
||||
}
|
||||
},
|
||||
color = MenuTextColor
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>)
|
||||
|
||||
|
|
|
@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) {
|
|||
if (chatInfo is ChatInfo.Direct) {
|
||||
ModalManager.start.closeModals()
|
||||
if (chatInfo.contact.sndReady) {
|
||||
openLoadedChat(chat)
|
||||
withApi {
|
||||
openLoadedChat(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -528,9 +528,9 @@ fun deleteChatDatabaseFilesAndState() {
|
|||
|
||||
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withLongRunningApi {
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
chats.clear()
|
||||
popChatCollector.clear()
|
||||
}
|
||||
|
|
|
@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -16,6 +15,7 @@ fun DefaultDropdownMenu(
|
|||
showMenu: MutableState<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
onClosed: State<() -> Unit> = remember { mutableStateOf({}) },
|
||||
dropdownMenuItems: (@Composable () -> Unit)?
|
||||
) {
|
||||
MaterialTheme(
|
||||
|
@ -31,6 +31,11 @@ fun DefaultDropdownMenu(
|
|||
offset = offset,
|
||||
) {
|
||||
dropdownMenuItems?.invoke()
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onClosed.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
|
|||
if (groupInfo != null) {
|
||||
withChats {
|
||||
updateGroup(rhId = rhId, groupInfo)
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatItems.clearAndNotify()
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
}
|
||||
|
|
|
@ -421,6 +421,7 @@
|
|||
<string name="chat_list_contacts">Contacts</string>
|
||||
<string name="chat_list_groups">Groups</string>
|
||||
<string name="chat_list_businesses">Businesses</string>
|
||||
<string name="chat_list_notes">Notes</string>
|
||||
<string name="chat_list_all">All</string>
|
||||
<string name="chat_list_add_list">Add list</string>
|
||||
|
||||
|
@ -644,6 +645,7 @@
|
|||
<!-- Tags - ChatListNavLinkView.kt -->
|
||||
<string name="create_list">Create list</string>
|
||||
<string name="add_to_list">Add to list</string>
|
||||
<string name="change_list">Change list</string>
|
||||
<string name="save_list">Save list</string>
|
||||
<string name="list_name_field_placeholder">List name...</string>
|
||||
<string name="duplicated_list_error">List name and emoji should be different for all lists.</string>
|
||||
|
@ -651,6 +653,7 @@
|
|||
<string name="delete_chat_list_question">Delete list?</string>
|
||||
<string name="delete_chat_list_warning">All chats will be removed from the list %s, and the list deleted</string>
|
||||
<string name="edit_chat_list_menu_action">Edit</string>
|
||||
<string name="change_order_chat_list_menu_action">Change order</string>
|
||||
|
||||
<!-- Pending contact connection alert dialogues -->
|
||||
<string name="you_invited_a_contact">You invited a contact</string>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-170q-30.94 0-52.97-22.03Q90-214.06 90-245v-470q0-30.94 22.03-52.97Q134.06-790 165-790h209q15.14 0 28.87 5.74Q416.59-778.52 427-768l53 53h315q30.94 0 52.97 22.03Q870-670.94 870-640v395q0 30.94-22.03 52.97Q825.94-170 795-170H165Zm0-75h630v-395H449l-75-75H165v470Zm0 0v-470 470Z"/></svg>
|
After Width: | Height: | Size: 386 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-170q-30.94 0-52.97-22.03Q90-214.06 90-245v-470q0-30.94 22.03-52.97Q134.06-790 165-790h209q15.14 0 28.87 5.74Q416.59-778.52 427-768l53 53h315q30.94 0 52.97 22.03Q870-670.94 870-640v395q0 30.94-22.03 52.97Q825.94-170 795-170H165Z"/></svg>
|
After Width: | Height: | Size: 338 B |
|
@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
|
@ -55,8 +56,12 @@ fun showApp() {
|
|||
// Better to not close fullscreen since it can contain passcode
|
||||
} else {
|
||||
// The last possible cause that can be closed
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withApi {
|
||||
withChats {
|
||||
chatModel.chatId.value = null
|
||||
chatItems.clearAndNotify()
|
||||
}
|
||||
}
|
||||
}
|
||||
chatModel.activeCall.value?.let {
|
||||
withBGApi {
|
||||
|
|
|
@ -21,6 +21,12 @@ import chat.simplex.res.MR
|
|||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
actual fun TagsRow(content: @Composable() (() -> Unit)) {
|
||||
FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
|
|
Loading…
Add table
Reference in a new issue