android, desktop: chat tags (#5396)

* types and api

* remaining api

* icons for tags (named label due to name conflict)

* icon fix

* wup

* desktop handlers to open list

* updates

* filtering

* progress

* wip dump

* icons

* preset updates

* unread

* + button in tags view

* drag n drop helpers

* chats reorder

* tag chat after list creation (when chat provided)

* updates on unread tags

* initial emoji picker

* fixes and tweaks

* reoder color

* clickable shapes

* paddings

* reachable form

* one hand for tags

* ui tweaks

* input for emojis desktop

* wrap chat tags in desktop

* handling longer texts

* fixed a couple of issues in updates of unread tags

* reset search text on active filter change

* fix multi row alignment

* fix modal paddings

* fix single emoji picker for skin colors

* dependency corrected

* icon, refactor, back action to exit edit mode

* different icon params to make it larger

* refactor

* refactor

* rename

* rename

* refactor

* refactor

* padding

* unread counter size

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Diogo
2024-12-25 11:35:48 +00:00
committed by GitHub
parent 32773c1d6e
commit 84a45cedbe
19 changed files with 1501 additions and 32 deletions

View File

@@ -87,6 +87,9 @@ kotlin {
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0")
// Emojis
implementation("androidx.emoji2:emoji2-emojipicker:1.4.0")
implementation("com.jakewharton:process-phoenix:3.0.0")
val cameraXVersion = "1.3.4"

View File

@@ -0,0 +1,81 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import android.view.ViewGroup
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.EmojiPickerView
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
actual fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
Box(Modifier
.clip(shape = CircleShape)
.clickable {
ModalManager.start.showModalCloseable { close ->
EmojiPicker(close = {
close()
emoji.value = it
})
}
}
.padding(4.dp)
) {
val emojiValue = emoji.value
if (emojiValue != null) {
Text(emojiValue)
} else {
Icon(
painter = painterResource(MR.images.ic_add_reaction),
contentDescription = null,
tint = MaterialTheme.colors.secondary
)
}
}
Spacer(Modifier.width(8.dp))
TagListNameTextField(name, showError = showError)
}
}
@Composable
private fun EmojiPicker(close: (String?) -> Unit) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val topPaddingToContent = topPaddingToContent(false)
Column (
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(
start = DEFAULT_PADDING_HALF,
end = DEFAULT_PADDING_HALF,
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
),
) {
AndroidView(
factory = { context ->
EmojiPickerView(context).apply {
emojiGridColumns = 10
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setOnEmojiPickedListener { pickedEmoji ->
close(pickedEmoji.emoji)
}
}
}
)
}
}

View File

@@ -13,6 +13,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationToDeviceState
import chat.simplex.common.views.migration.MigrationToState
@@ -81,6 +82,12 @@ object ChatModel {
val groupMembers = mutableStateListOf<GroupMember>()
val groupMembersIndexes = mutableStateMapOf<Long, Int>()
// Chat Tags
val userTags = mutableStateOf(emptyList<ChatTag>())
val activeChatTagFilter = mutableStateOf<ActiveFilter?>(null)
val presetTags = mutableStateMapOf<PresetTagKind, Int>()
val unreadTags = mutableStateMapOf<Long, Int>()
// false: default placement, true: floating window.
// Used for deciding to add terminal items on main thread or not. Floating means appPrefs.terminalAlwaysVisible
var terminalsVisible = setOf<Boolean>()
@@ -196,6 +203,116 @@ object ChatModel {
}
}
fun updateChatTags(rhId: Long?) {
val newPresetTags = mutableMapOf<PresetTagKind, Int>()
val newUnreadTags = mutableMapOf<Long, Int>()
for (chat in chats.value.filter { it.remoteHostId == rhId }) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chat.chatInfo)) {
newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1
}
}
if (chat.unreadTag) {
val chatTags: List<Long> = when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> cInfo.contact.chatTags
is ChatInfo.Group -> cInfo.groupInfo.chatTags
else -> emptyList()
}
chatTags.forEach { tag ->
newUnreadTags[tag] = (newUnreadTags[tag] ?: 0) + 1
}
}
}
if (activeChatTagFilter.value is ActiveFilter.PresetTag &&
(newPresetTags[(activeChatTagFilter.value as ActiveFilter.PresetTag).tag] ?: 0) == 0) {
activeChatTagFilter.value = null
}
presetTags.clear()
presetTags.putAll(newPresetTags)
unreadTags.clear()
unreadTags.putAll(newUnreadTags)
}
fun updateChatFavorite(favorite: Boolean, wasFavorite: Boolean) {
val count = presetTags[PresetTagKind.FAVORITES]
if (favorite && !wasFavorite) {
presetTags[PresetTagKind.FAVORITES] = (count ?: 0) + 1
} else if (!favorite && wasFavorite && count != null) {
presetTags[PresetTagKind.FAVORITES] = maxOf(0, count - 1)
if (activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.FAVORITES) && (presetTags[PresetTagKind.FAVORITES] ?: 0) == 0) {
activeChatTagFilter.value = null
}
}
}
fun addPresetChatTags(chatInfo: ChatInfo) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
presetTags[tag] = (presetTags[tag] ?: 0) + 1
}
}
}
fun removePresetChatTags(chatInfo: ChatInfo) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
val count = presetTags[tag]
if (count != null) {
presetTags[tag] = maxOf(0, count - 1)
}
}
}
}
fun markChatTagRead(chat: Chat) {
if (chat.unreadTag) {
chat.chatInfo.chatTags?.let { tags ->
markChatTagRead_(chat, tags)
}
}
}
fun updateChatTagRead(chat: Chat, wasUnread: Boolean) {
val tags = chat.chatInfo.chatTags ?: return
val nowUnread = chat.unreadTag
if (nowUnread && !wasUnread) {
tags.forEach { tag ->
unreadTags[tag] = (unreadTags[tag] ?: 0) + 1
}
} else if (!nowUnread && wasUnread) {
markChatTagRead_(chat, tags)
}
}
fun moveChatTagUnread(chat: Chat, oldTags: List<Long>?, newTags: List<Long>) {
if (chat.unreadTag) {
oldTags?.forEach { t ->
val oldCount = unreadTags[t]
if (oldCount != null) {
unreadTags[t] = maxOf(0, oldCount - 1)
}
}
newTags.forEach { t ->
unreadTags[t] = (unreadTags[t] ?: 0) + 1
}
}
}
private fun markChatTagRead_(chat: Chat, tags: List<Long>) {
for (tag in tags) {
val count = unreadTags[tag]
if (count != null) {
unreadTags[tag] = maxOf(0, count - 1)
}
}
}
// toList() here is to prevent ConcurrentModificationException that is rarely happens but happens
fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
// TODO pass rhId?
@@ -280,6 +397,7 @@ object ChatModel {
updateChatInfo(rhId, cInfo)
} else if (addMissing) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
addPresetChatTags(cInfo)
}
}
@@ -329,6 +447,7 @@ object ChatModel {
}
else -> cItem
}
val wasUnread = chat.unreadTag
chats[i] = chat.copy(
chatItems = arrayListOf(newPreviewItem),
chatStats =
@@ -339,6 +458,8 @@ object ChatModel {
else
chat.chatStats
)
updateChatTagRead(chats[i], wasUnread)
if (appPlatform.isDesktop && cItem.chatDir.sent) {
reorderChat(chats[i], 0)
} else {
@@ -455,6 +576,7 @@ object ChatModel {
if (i >= 0) {
decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount)
chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo)
markChatTagRead(chats[i])
}
// clear current chat
if (chatId.value == cInfo.id) {
@@ -522,11 +644,13 @@ object ChatModel {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
if (lastId != null) {
val wasUnread = chat.unreadTag
val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0
decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(unreadCount = unreadCount)
)
updateChatTagRead(chats[chatIdx], wasUnread)
}
}
}
@@ -537,16 +661,29 @@ object ChatModel {
val chat = chats[chatIndex]
val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0)
val wasUnread = chat.unreadTag
decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount)
chats[chatIndex] = chat.copy(
chatStats = chat.chatStats.copy(
unreadCount = unreadCount,
)
)
updateChatTagRead(chats[chatIndex], wasUnread)
}
fun removeChat(rhId: Long?, id: String) {
chats.removeAll { it.id == id && it.remoteHostId == rhId }
var removed: ChatInfo? = null
chats.removeAll {
val found = it.id == id && it.remoteHostId == rhId
if (found) {
removed = it.chatInfo
}
found
}
removed?.let {
removePresetChatTags(it)
}
}
suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean {
@@ -977,6 +1114,8 @@ data class Chat(
else -> false
}
val unreadTag: Boolean get() = chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat)
val id: String get() = chatInfo.id
fun groupFeatureEnabled(feature: GroupFeature): Boolean =
@@ -1189,6 +1328,12 @@ sealed class ChatInfo: SomeChat, NamedChat {
else -> false
}
val chatTags: List<Long>?
get() = when (this) {
is Direct -> contact.chatTags
is Group -> groupInfo.chatTags
else -> null
}
}
@Serializable
@@ -1232,6 +1377,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
val chatTags: List<Long>,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
@@ -1315,6 +1461,7 @@ data class Contact(
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
chatTags = emptyList()
)
}
}
@@ -1476,6 +1623,7 @@ data class GroupInfo (
override val updatedAt: Instant,
val chatTs: Instant?,
val uiThemes: ThemeModeOverrides? = null,
val chatTags: List<Long>
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -1520,6 +1668,7 @@ data class GroupInfo (
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
uiThemes = null,
chatTags = emptyList()
)
}
}
@@ -3850,6 +3999,13 @@ sealed class ChatItemTTL: Comparable<ChatItemTTL?> {
}
}
@Serializable
data class ChatTag(
val chatTagId: Long,
val chatTagText: String,
val chatTagEmoji: String?
)
@Serializable
class ChatItemInfo(
val itemVersions: List<ChatItemVersion>,

View File

@@ -624,6 +624,8 @@ object ChatController {
val chats = apiGetChats(rhId)
updateChats(chats)
}
chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList()
chatModel.updateChatTags(rhId)
}
private fun startReceiver() {
@@ -879,6 +881,16 @@ object ChatController {
return emptyList()
}
private suspend fun apiGetChatTags(rh: Long?): List<ChatTag>?{
val userId = currentUserId("apiGetChatTags")
val r = sendCmd(rh, CC.ApiGetChatTags(userId))
if (r is CR.ChatTags) return r.userTags
Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}")
return null
}
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search))
if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo
@@ -891,6 +903,28 @@ object ChatController {
return null
}
suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List<ChatTag>? {
val r = sendCmd(rh, CC.ApiCreateChatTag(tag))
if (r is CR.ChatTags) return r.userTags
Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}")
return null
}
suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List<Long>): Pair<List<ChatTag>, List<Long>>? {
val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds))
if (r is CR.TagsUpdated) return r.userTags to r.chatTags
Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}")
return null
}
suspend fun apiDeleteChatTag(rh: Long?, tagId: Long) = sendCommandOkResp(rh, CC.ApiDeleteChatTag(tagId))
suspend fun apiUpdateChatTag(rh: Long?, tagId: Long, tag: ChatTagData) = sendCommandOkResp(rh, CC.ApiUpdateChatTag(tagId, tag))
suspend fun apiReorderChatTags(rh: Long?, tagIds: List<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages)
return processSendMessageCmd(rh, cmd)
@@ -3152,10 +3186,16 @@ sealed class CC {
class TestStorageEncryption(val key: String): CC()
class ApiSaveSettings(val settings: AppSettings): CC()
class ApiGetSettings(val settings: AppSettings): CC()
class ApiGetChatTags(val userId: Long): CC()
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): CC()
class ApiDeleteChatTag(val tagId: Long): CC()
class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC()
class ApiReorderChatTags(val tagIds: List<Long>): CC()
class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
@@ -3307,6 +3347,7 @@ sealed class CC {
is TestStorageEncryption -> "/db test key $key"
is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}"
is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}"
is ApiGetChatTags -> "/_get tags $userId"
is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
@@ -3315,6 +3356,11 @@ sealed class CC {
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
}
is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}"
is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}"
is ApiDeleteChatTag -> "/_delete tag $tagId"
is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}"
is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}"
is ApiCreateChatItems -> {
val msgs = json.encodeToString(composedMessages)
"/_create *$noteFolderId json $msgs"
@@ -3471,10 +3517,16 @@ sealed class CC {
is TestStorageEncryption -> "testStorageEncryption"
is ApiSaveSettings -> "apiSaveSettings"
is ApiGetSettings -> "apiGetSettings"
is ApiGetChatTags -> "apiGetChatTags"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
is ApiSendMessages -> "apiSendMessages"
is ApiCreateChatTag -> "apiCreateChatTag"
is ApiSetChatTags -> "apiSetChatTags"
is ApiDeleteChatTag -> "apiDeleteChatTag"
is ApiUpdateChatTag -> "apiUpdateChatTag"
is ApiReorderChatTags -> "apiReorderChatTags"
is ApiCreateChatItems -> "apiCreateChatItems"
is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem"
@@ -3657,6 +3709,9 @@ sealed class ChatPagination {
@Serializable
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
@Serializable
class ChatTagData(val emoji: String?, val text: String)
@Serializable
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
@@ -5390,6 +5445,7 @@ sealed class CR {
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
@Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List<ChatTag>): CR()
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
@@ -5416,6 +5472,7 @@ sealed class CR {
@Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR()
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List<ChatTag>, val chatTags: List<Long>): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@@ -5574,6 +5631,7 @@ sealed class CR {
is ChatStopped -> "chatStopped"
is ApiChats -> "apiChats"
is ApiChat -> "apiChat"
is ChatTags -> "chatTags"
is ApiChatItemInfo -> "chatItemInfo"
is ServerTestResult -> "serverTestResult"
is ServerOperatorConditions -> "serverOperatorConditions"
@@ -5600,6 +5658,7 @@ sealed class CR {
is ContactCode -> "contactCode"
is GroupMemberCode -> "groupMemberCode"
is ConnectionVerified -> "connectionVerified"
is TagsUpdated -> "tagsUpdated"
is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is ConnectionUserChanged -> "ConnectionUserChanged"
@@ -5748,6 +5807,7 @@ sealed class CR {
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
@@ -5774,6 +5834,7 @@ sealed class CR {
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}")
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" )

View File

@@ -31,6 +31,7 @@ import chat.simplex.common.model.CIDirection.GroupRcv
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.activeCall
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.markChatTagRead
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -2106,6 +2107,7 @@ private fun markUnreadChatAsRead(chatId: String) {
if (success) {
withChats {
replaceChat(chatRh, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
markChatTagRead(chat)
}
}
}

View File

@@ -4,28 +4,34 @@ import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.*
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.markChatTagRead
import chat.simplex.common.model.ChatModel.updateChatTagRead
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.deleteGroupDialog
import chat.simplex.common.views.chat.group.leaveGroupDialog
import chat.simplex.common.views.chat.group.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.contacts.onRequestAccepted
import chat.simplex.common.views.helpers.*
@@ -33,7 +39,6 @@ import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlin.math.min
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
@@ -252,6 +257,7 @@ fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMen
}
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
TagListAction(chat, showMenu)
ClearChatAction(chat, showMenu)
}
DeleteContactAction(chat, chatModel, showMenu)
@@ -291,6 +297,7 @@ fun GroupMenuItems(
}
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
TagListAction(chat, showMenu)
ClearChatAction(chat, showMenu)
if (groupInfo.membership.memberCurrent) {
LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu)
@@ -337,6 +344,28 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat
)
}
@Composable
fun TagListAction(
chat: Chat,
showMenu: MutableState<Boolean>
) {
val userTags = remember { chatModel.userTags }
ItemAction(
stringResource(MR.strings.list_menu),
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)
}
}
showMenu.value = false
}
)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -557,6 +586,7 @@ fun markChatRead(c: Chat, chatModel: ChatModel) {
if (success) {
withChats {
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
markChatTagRead(chat)
}
}
}
@@ -568,6 +598,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
if (chat.chatStats.unreadChat) return
withApi {
val wasUnread = chat.unreadTag
val success = chatModel.controller.apiChatUnread(
chat.remoteHostId,
chat.chatInfo.chatType,
@@ -577,6 +608,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
if (success) {
withChats {
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
updateChatTagRead(chat, wasUnread)
}
}
}
@@ -826,12 +858,20 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
else -> false
}
if (res && newChatInfo != null) {
val chat = chatModel.getChat(chatInfo.id)
val wasUnread = chat?.unreadTag ?: false
val wasFavorite = chatInfo.chatSettings?.favorite ?: false
chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite)
withChats {
updateChatInfo(remoteHostId, newChatInfo)
}
if (chatSettings.enableNtfs != MsgFilter.All) {
ntfManager.cancelNotificationsForChat(chatInfo.id)
}
val updatedChat = chatModel.getChat(chatInfo.id)
if (updatedChat != null) {
chatModel.updateChatTagRead(updatedChat, wasUnread)
}
val current = currentState?.value
if (current != null) {
currentState.value = !current

View File

@@ -16,11 +16,13 @@ import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
@@ -32,21 +34,30 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.mkValidName
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.showInvalidNameAlert
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS }
sealed class ActiveFilter {
data class PresetTag(val tag: PresetTagKind) : ActiveFilter()
data class UserTag(val tag: ChatTag) : ActiveFilter()
data object Unread: ActiveFilter()
}
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
ModalManager.start.closeModals()
ModalManager.end.closeModals()
@@ -557,17 +568,24 @@ private fun BoxScope.unreadBadge(text: String? = "") {
@Composable
private fun ToggleFilterEnabledButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread
IconButton(onClick = {
if (showUnread) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.Unread
}
}) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
tint = if (showUnread) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.background(color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(sp16)
)
@@ -731,6 +749,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val oneHandUI = remember { appPrefs.oneHandUI.state }
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
val activeFilter = remember { chatModel.activeChatTagFilter }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
@@ -753,14 +772,13 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allChats = remember { chatModel.chats }
// In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
val topPaddingToContent = topPaddingToContent(false)
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
LazyColumnWithScrollBar(
@@ -791,11 +809,15 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
) {
if (oneHandUI.value) {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
Divider()
TagsView()
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
TagsView()
Divider()
}
}
}
@@ -815,8 +837,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
}
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
NoChatsView(searchText = searchText)
}
}
if (oneHandUI.value) {
@@ -839,6 +861,41 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
}
}
LaunchedEffect(activeFilter.value) {
searchText.value = TextFieldValue("")
}
}
@Composable
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
val activeFilter = remember { chatModel.activeChatTagFilter }.value
if (searchText.value.text.isBlank()) {
when (activeFilter) {
is ActiveFilter.PresetTag -> Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) // this should not happen
is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
is ActiveFilter.Unread -> {
Row(
Modifier.clip(shape = RoundedCornerShape(percent = 50)).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = MaterialTheme.colors.secondary
)
Text(generalGetString(MR.strings.no_unread_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
}
null -> {
Text(generalGetString(MR.strings.no_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
}
} else {
Text(generalGetString(MR.strings.no_chats_found), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
}
}
@Composable
@@ -860,31 +917,297 @@ private fun ChatListFeatureCards() {
}
}
@Composable
private fun TagsView() {
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 = RoundedCornerShape(percent = 50))) {
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 = 35.dp * fontSizeSqrtMultiplier)
TagsRow {
if (presetTags.size > 1) {
if (presetTags.size + userTags.value.size <= 3) {
PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag ->
ExpandedTagFilterView(tag)
}
} else {
Column(rowSizeModifier, verticalArrangement = Arrangement.Center) {
CollapsedTagsFilterView()
}
}
}
userTags.value.forEach { tag ->
val current = when (val af = activeFilter.value) {
is ActiveFilter.UserTag -> af.tag == tag
else -> false
}
val interactionSource = remember { MutableInteractionSource() }
Column(rowSizeModifier, verticalArrangement = Arrangement.Center) {
Row(
Modifier
.clip(shape = RoundedCornerShape(percent = 50))
.combinedClickable(
onClick = {
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag)
}
},
onLongClick = { showTagList() },
interactionSource = interactionSource,
indication = LocalIndication.current
)
.onRightClick { showTagList() }
.padding(4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (tag.chatTagEmoji != null) {
Text(
tag.chatTagEmoji
)
} else {
Icon(
painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label),
null,
Modifier.size(20.dp),
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.SemiBold,
color = Color.Transparent,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Visible text with styles
val visibleText = buildAnnotatedString {
append(tag.chatTagText)
withStyle(SpanStyle(fontSize = 12.sp, color = MaterialTheme.colors.primary)) {
append(badgeText)
}
}
Text(
text = visibleText,
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal,
color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
val plusClickModifier = Modifier
.clickable {
ModalManager.start.showModalCloseable { close ->
TagListEditor(rhId = rhId, close = close)
}
}
Column(rowSizeModifier, verticalArrangement = Arrangement.Center) {
if (userTags.value.isEmpty()) {
Row(Modifier.clip(shape = RoundedCornerShape(percent = 50)).then(plusClickModifier).padding(vertical = 4.dp), horizontalArrangement = Arrangement.Center) {
Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(2.dp))
Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary)
}
} else {
Icon(
painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(4.dp), tint = MaterialTheme.colors.secondary
)
}
}
}
}
@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() }
}
}
@Composable
private fun ExpandedTagFilterView(tag: PresetTagKind) {
val activeFilter = remember { chatModel.activeChatTagFilter }
val active = when (val af = activeFilter.value) {
is ActiveFilter.PresetTag -> af.tag == tag
else -> false
}
val (icon, text) = presetTagLabel(tag, active)
val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(percent = 50))
.clickable {
if (activeFilter.value == ActiveFilter.PresetTag(tag)) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag)
}
}
.padding(4.dp)
,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
painterResource(icon),
stringResource(text),
tint = color
)
Spacer(Modifier.width(4.dp))
Box {
Text(
stringResource(text),
color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Normal,
)
Text(
stringResource(text),
color = Color.Transparent,
fontWeight = FontWeight.SemiBold
)
}
}
}
@Composable
private fun CollapsedTagsFilterView() {
val activeFilter = remember { chatModel.activeChatTagFilter }
val presetTags = remember { chatModel.presetTags }
val showMenu = remember { mutableStateOf(false) }
val selectedPresetTag = when (val af = activeFilter.value) {
is ActiveFilter.PresetTag -> af.tag
else -> null
}
Column(Modifier
.clip(shape = CircleShape)
.clickable { showMenu.value = true }
.padding(4.dp)
) {
if (selectedPresetTag != null) {
val (icon, text) = presetTagLabel(selectedPresetTag, true)
Icon(
painterResource(icon),
stringResource(text),
tint = MaterialTheme.colors.secondary
)
} else {
Icon(
painterResource(MR.images.ic_menu),
stringResource(MR.strings.chat_list_all),
tint = MaterialTheme.colors.secondary
)
}
DefaultDropdownMenu(showMenu = showMenu) {
if (selectedPresetTag != null) {
ItemAction(
stringResource(MR.strings.chat_list_all),
painterResource(MR.images.ic_menu),
onClick = {
chatModel.activeChatTagFilter.value = null
showMenu.value = false
}
)
}
PresetTagKind.entries.forEach { tag ->
if ((presetTags[tag] ?: 0) > 0) {
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu)
}
}
}
}
}
@Composable
fun ItemPresetFilterAction(
presetTag: PresetTagKind,
active: Boolean,
showMenu: MutableState<Boolean>
) {
val (icon, text) = presetTagLabel(presetTag, active)
ItemAction(
stringResource(text),
painterResource(icon),
onClick = {
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
showMenu.value = false
}
)
}
fun filteredChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchText: String,
chats: List<Chat>
chats: List<Chat>,
activeFilter: ActiveFilter? = null,
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
return if (linkChatId != null) {
chats.filter { it.id == linkChatId }
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD }
if (s.isEmpty())
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && 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)
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) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
cInfo.anyNameContains(s)
}
@@ -898,10 +1221,41 @@ fun filteredChats(
}
}
private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean =
when (activeFilter) {
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo)
is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false
is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
else -> true
}
fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean =
when (tag) {
PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true
PresetTagKind.CONTACTS -> when (chatInfo) {
is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted
is ChatInfo.ContactRequest -> true
is ChatInfo.ContactConnection -> true
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Customer
else -> false
}
PresetTagKind.GROUPS -> when (chatInfo) {
is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null
else -> false
}
PresetTagKind.BUSINESS -> when (chatInfo) {
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business
else -> false
}
}
private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResource, StringResource> =
when (tag) {
PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites
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
}
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }

View File

@@ -191,7 +191,7 @@ private fun ShareList(
val chats by remember(search) {
derivedStateOf {
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local }
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted)
}
}
val topPaddingToContent = topPaddingToContent(false)

View File

@@ -0,0 +1,509 @@
package chat.simplex.common.views.chatlist
import SectionCustomFooter
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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.apiDeleteChatTag
import chat.simplex.common.model.ChatController.apiSetChatTags
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
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
}
}
val userTags = remember { chatModel.userTags }
val oneHandUI = remember { appPrefs.oneHandUI.state }
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
val saving = remember { mutableStateOf(false) }
val chatTagIds = derivedStateOf { chat?.chatInfo?.chatTags ?: emptyList() }
fun reorderTags(tagIds: List<Long>) {
saving.value = true
withBGApi {
try {
chatModel.controller.apiReorderChatTags(rhId, tagIds)
} catch (e: Exception) {
Log.d(TAG, "ChatListTag reorderTags error: ${e.message}")
} finally {
saving.value = false
}
}
}
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
userTags.value = userTags.value.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
reorderTags(userTags.value.map { it.chatTagId })
}
val topPaddingToContent = topPaddingToContent(false)
LazyColumnWithScrollBar(
modifier = if (editMode.value) 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
),
state = listState,
verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top,
) {
@Composable fun CreateList() {
SectionItemView({
ModalManager.start.showModalCloseable { close ->
TagListEditor(rhId = rhId, close = close, chat = chat)
}
}) {
Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.create_list), tint = MaterialTheme.colors.primary)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(stringResource(MR.strings.create_list), color = MaterialTheme.colors.primary)
}
}
if (oneHandUI.value && !editMode.value) {
item {
CreateList()
}
}
itemsIndexed(userTags.value, key = { _, item -> item.chatTagId }) { index, tag ->
DraggableItem(dragDropState, index) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Card(
elevation = elevation,
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,
onClick = {
if (chat == null) {
ModalManager.start.showModalCloseable { close ->
TagListEditor(
rhId = rhId,
tagId = tag.chatTagId,
close = close,
emoji = tag.chatTagEmoji,
name = tag.chatTagText,
)
}
} else {
saving.value = true
setTag(rhId = rhId, tagId = if (selected) null else tag.chatTagId, chat = chat, close = {
saving.value = false
close()
})
}
},
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
) {
if (tag.chatTagEmoji != null) {
Text(
tag.chatTagEmoji
)
} else {
Icon(painterResource(MR.images.ic_label), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
}
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
tag.chatTagText,
color = MenuTextColor,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal
)
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) {
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) {
item {
CreateList()
}
}
}
}
@Composable
fun ModalData.TagListEditor(
rhId: Long?,
chat: Chat? = null,
tagId: Long? = null,
emoji: String? = null,
name: String = "",
close: () -> Unit
) {
val userTags = remember { chatModel.userTags }
val oneHandUI = remember { appPrefs.oneHandUI.state }
val keyboardState by getKeyboardState()
val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } }
val newName = remember { stateGetOrPut("chatTagName") { name } }
val saving = remember { mutableStateOf<Boolean?>(null) }
val trimmedName = remember { derivedStateOf { newName.value.trim() } }
val isDuplicateEmojiOrName = remember {
derivedStateOf {
userTags.value.any { tag ->
tag.chatTagId != tagId &&
((newEmoji.value != null && tag.chatTagEmoji == newEmoji.value) || tag.chatTagText == trimmedName.value)
}
}
}
fun createTag() {
saving.value = true
withBGApi {
try {
val updatedTags = chatModel.controller.apiCreateChatTag(rhId, ChatTagData(newEmoji.value, trimmedName.value))
if (updatedTags != null) {
saving.value = false
userTags.value = updatedTags
close()
} else {
saving.value = null
return@withBGApi
}
if (chat != null) {
val createdTag = updatedTags.firstOrNull() { it.chatTagText == trimmedName.value && it.chatTagEmoji == newEmoji.value }
if (createdTag != null) {
setTag(rhId, createdTag.chatTagId, chat, close = {
saving.value = false
close()
})
}
}
} catch (e: Exception) {
Log.d(TAG, "createChatTag tag error: ${e.message}")
saving.value = null
}
}
}
fun updateTag() {
saving.value = true
withBGApi {
try {
if (chatModel.controller.apiUpdateChatTag(rhId, tagId!!, ChatTagData(newEmoji.value, trimmedName.value))) {
userTags.value = userTags.value.map { tag ->
if (tag.chatTagId == tagId) {
tag.copy(chatTagEmoji = newEmoji.value, chatTagText = trimmedName.value)
} else {
tag
}
}
} else {
saving.value = null
return@withBGApi
}
saving.value = false
close()
} catch (e: Exception) {
Log.d(TAG, "ChatListTagEditor updateChatTag tag error: ${e.message}")
saving.value = null
}
}
}
val showError = derivedStateOf { isDuplicateEmojiOrName.value && saving.value != false }
ColumnWithScrollBar(Modifier.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) WindowInsets.ime.asPaddingValues().calculateBottomPadding().coerceIn(0.dp, WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) else 0.dp))) {
if (oneHandUI.value) {
Spacer(Modifier.weight(1f))
}
ChatTagInput(newName, showError, newEmoji)
val disabled = saving.value == true ||
(trimmedName.value == name && newEmoji.value == emoji) ||
trimmedName.value.isEmpty() ||
isDuplicateEmojiOrName.value
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),
color = if (disabled) colors.secondary else colors.primary
)
}
val showErrorMessage = isDuplicateEmojiOrName.value && saving.value != false
SectionCustomFooter {
Row(
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painterResource(MR.images.ic_error),
contentDescription = stringResource(MR.strings.error),
tint = if (showErrorMessage) Color.Red else Color.Transparent,
modifier = Modifier
.size(19.sp.toDp())
.offset(x = 2.sp.toDp())
)
TextIconSpaced()
Text(
generalGetString(MR.strings.duplicated_list_error),
color = if (showErrorMessage) colors.secondary else Color.Transparent,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
}
}
}
@Composable
private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>, saving: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_chat_list_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteTagDialog(rhId, tag, saving)
showMenu.value = false
},
color = Color.Red
)
}
@Composable
private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.edit_chat_list_menu_action),
painterResource(MR.images.ic_edit),
onClick = {
showMenu.value = false
ModalManager.start.showModalCloseable { close ->
TagListEditor(
rhId = rhId,
tagId = tag.chatTagId,
close = close,
emoji = tag.chatTagEmoji,
name = tag.chatTagText
)
}
},
color = MenuTextColor
)
}
@Composable
expect fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>)
@Composable
fun TagListNameTextField(name: MutableState<String>, showError: State<Boolean>) {
var focused by rememberSaveable { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val strokeColor by remember {
derivedStateOf {
if (showError.value) {
Color.Red
} else {
if (focused) {
CurrentColors.value.colors.secondary.copy(alpha = 0.6f)
} else {
CurrentColors.value.colors.secondary.copy(alpha = 0.3f)
}
}
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BasicTextField(
value = name.value,
onValueChange = { name.value = it },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 50.dp)
.onFocusChanged { focused = it.isFocused }
.focusRequester(focusRequester),
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.TextFieldDecorationBox(
value = name.value,
innerTextField = innerTextField,
placeholder = {
Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp))
},
contentPadding = PaddingValues(),
label = null,
visualTransformation = VisualTransformation.None,
leadingIcon = null,
singleLine = true,
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
Divider(color = strokeColor, thickness = if (focused) 2.dp else 1.dp)
}
}
private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) {
withBGApi {
val tagIds: List<Long> = if (tagId == null) {
emptyList()
} else {
listOf(tagId)
}
try {
val result = apiSetChatTags(rh = rhId, type = chat.chatInfo.chatType, id = chat.chatInfo.apiId, tagIds = tagIds)
if (result != null) {
val oldTags = chat.chatInfo.chatTags
chatModel.userTags.value = result.first
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> {
val contact = cInfo.contact.copy(chatTags = result.second)
withChats {
updateContact(rhId, contact)
}
}
is ChatInfo.Group -> {
val group = cInfo.groupInfo.copy(chatTags = result.second)
withChats {
updateGroup(rhId, group)
}
}
else -> {}
}
chatModel.moveChatTagUnread(chat, oldTags, result.second)
close()
}
} catch (e: Exception) {
Log.d(TAG, "setChatTag error: ${e.message}")
}
}
}
private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState<Boolean>) {
withBGApi {
saving.value = true
try {
val tagId = tag.chatTagId
if (apiDeleteChatTag(rhId, tagId)) {
chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId }
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
chatModel.activeChatTagFilter.value = null
}
chatModel.chats.value.forEach { c ->
when (val cInfo = c.chatInfo) {
is ChatInfo.Direct -> {
val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId })
withChats {
updateContact(rhId, contact)
}
}
is ChatInfo.Group -> {
val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId })
withChats {
updateGroup(rhId, group)
}
}
else -> {}
}
}
}
} catch (e: Exception) {
Log.d(TAG, "deleteTag error: ${e.message}")
} finally {
saving.value = false
}
}
}
private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState<Boolean>) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_chat_list_question),
text = String.format(generalGetString(MR.strings.delete_chat_list_warning), tag.chatTagText),
buttons = {
SectionItemView({
AlertManager.shared.hideAlert()
deleteTag(rhId, tag, saving)
}) {
Text(
generalGetString(MR.strings.confirm_verb),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = colors.error
)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(
stringResource(MR.strings.cancel_verb),
Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = colors.primary
)
}
}
)
}

View File

@@ -0,0 +1,177 @@
package chat.simplex.common.views.helpers
/*
* This was adapted from google example of drag and drop for Jetpack Compose
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
*/
import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state =
remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState
internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
val touchY = offset.y.toInt()
val item = state.layoutInfo.visibleItemsInfo.minByOrNull {
val itemCenter = (it.offset - state.layoutInfo.viewportStartOffset) + it.size / 2
kotlin.math.abs(touchY - itemCenter) // Find the item closest to the touch position, needs to take viewportStartOffset into account
}
if (item != null) {
draggingItemIndex = item.index
draggingItemInitialOffset = item.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
if (
draggingItem.index == state.firstVisibleItemIndex ||
targetItem.index == state.firstVisibleItemIndex
) {
state.requestScrollToItem(
state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset
)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll =
when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier =
if (dragging) {
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset }
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier.zIndex(1f).graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}

View File

@@ -188,6 +188,9 @@
<string name="error_updating_user_privacy">Error updating user privacy</string>
<string name="possible_slow_function_title">Slow function</string>
<string name="possible_slow_function_desc">Execution of function takes too long time: %1$d seconds: %2$s</string>
<string name="error_updating_chat_tags">Error updating chat list</string>
<string name="error_creating_chat_tags">Error creating chat list</string>
<string name="error_loading_chat_tags">Error loading chat lists</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
@@ -361,6 +364,7 @@
<string name="revoke_file__confirm">Revoke</string>
<string name="forward_chat_item">Forward</string>
<string name="download_file">Download</string>
<string name="list_menu">List</string>
<string name="message_forwarded_title">Message forwarded</string>
<string name="message_forwarded_desc">No direct connection yet, message is forwarded by admin.</string>
@@ -390,6 +394,10 @@
<string name="you_have_no_chats">You have no chats</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_chats_in_list">No chats in list %s.</string>
<string name="no_unread_chats">No unread chats</string>
<string name="no_chats">No chats</string>
<string name="no_chats_found">No chats found</string>
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
@@ -409,6 +417,12 @@
<string name="forward_files_missing_desc">%1$d file(s) were deleted.</string>
<string name="forward_files_not_accepted_receive_files">Download</string>
<string name="forward_files_messages_deleted_after_selection_title">%1$s messages not forwarded</string>
<string name="chat_list_favorites">Favorites</string>
<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_all">All</string>
<string name="chat_list_add_list">Add list</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
@@ -627,6 +641,16 @@
<string name="favorite_chat">Favorite</string>
<string name="unfavorite_chat">Unfavorite</string>
<!-- Tags - ChatListNavLinkView.kt -->
<string name="create_list">Create list</string>
<string name="add_to_list">Add to 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>
<string name="delete_chat_list_menu_action">Delete</string>
<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>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_a_contact">You invited a contact</string>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M170-368v-75h620v75H170Zm0-150v-75h620v75H170Z"/></svg>

After

Width:  |  Height:  |  Size: 171 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M54.5-279q0-33 16.75-60.25T116.5-381q61-30 123.5-45.25t126-15.25q63.5 0 125.75 15.25T615-381q28.5 14.5 45.25 41.75T677-279v31q0 31-22 53t-53 22H129.5q-31 0-53-22t-22-53v-31Zm677 106q10-17 15.25-36t5.25-39v-35q0-43.5-22.5-83.75T663-434.5q48.5 6 91.25 19.75t80.25 34.25Q869-362 887.25-338t18.25 52v38q0 31-22 53t-53 22h-99ZM366-479q-64 0-109-45t-45-109q0-64 45-109t109-45q64 0 109 45t45 109q0 64-45 109t-109 45Zm382-154.5q0 63.5-45 108.75T594-479.5q-9.5 0-25.25-2.25T543-487q26.5-30.5 40.75-68T598-633.5q0-40.5-14.25-78.25T543-780q12.5-4.5 25.5-5.75T594-787q64 0 109 45t45 108.5Z"/></svg>

After

Width:  |  Height:  |  Size: 683 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="m859-406.5-304 305Q545.93-93 535.21-89q-10.71 4-21.71 4t-21.25-4.25Q482-93.5 473.5-101.5L102.5-474q-8-7.5-12.75-17.97Q85-502.44 85-514v-303.5q0-23.72 16.89-40.61T142.5-875h305q11.41 0 22.11 4.4 10.71 4.39 18.89 12.6L859-488q8.91 8.92 13.21 19.52 4.29 10.6 4.29 21.21 0 11.27-4.5 22.27t-13 18.5Zm-343 266L820-446 447.49-817.5H142.5v301.77L516-140.5ZM246.75-664q20.5 0 35.63-15.04 15.12-15.03 15.12-35.37 0-20.34-15.06-35.47Q267.38-765 247-765q-20.75 0-35.62 15.04-14.88 15.03-14.88 35.37 0 20.34 14.88 35.46Q226.25-664 246.75-664ZM481.5-479Z"/></svg>

After

Width:  |  Height:  |  Size: 646 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="m859-406.5-304 305Q545.93-93 535.21-89q-10.71 4-21.71 4t-21.25-4.25Q482-93.5 473.5-101.5L102.5-474q-8-7.5-12.75-17.97Q85-502.44 85-514v-303.5q0-23.72 16.89-40.61T142.5-875h305q11.41 0 22.11 4.4 10.71 4.39 18.89 12.6L859-488q8.91 8.92 13.21 19.52 4.29 10.6 4.29 21.21 0 11.27-4.5 22.27t-13 18.5ZM246.75-664q20.5 0 35.63-15.04 15.12-15.03 15.12-35.37 0-20.34-15.06-35.47Q267.38-765 247-765q-20.75 0-35.62 15.04-14.88 15.03-14.88 35.37 0 20.34 14.88 35.46Q226.25-664 246.75-664Z"/></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M480-479q-64.5 0-109.75-45.25T325-634q0-64.5 45.25-109.75T480-789q64.5 0 109.75 45.25T635-634q0 64.5-45.25 109.75T480-479ZM169-248v-31.03q0-32.97 16.75-60.22t45.27-41.76Q292-411 354.25-426.25 416.5-441.5 480-441.5t125.75 15.25Q668-411 728.98-381.01q28.52 14.51 45.27 41.76Q791-312 791-279.03V-248q0 30.94-22.03 52.97Q746.94-173 716-173H244q-30.94 0-52.97-22.03Q169-217.06 169-248Z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-130q-30.94 0-52.97-22.03Q90-174.06 90-205v-440q0-30.94 22.03-52.97Q134.06-720 165-720h161v-75.04Q326-826 348.03-848T401-870h158q30.94 0 52.97 22.03Q634-825.94 634-795v75h161q30.94 0 52.97 22.03Q870-675.94 870-645v440q0 30.94-22.03 52.97Q825.94-130 795-130H165Zm236-590h158v-75H401v75Z"/></svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@@ -4,8 +4,7 @@ import SectionDivider
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

View File

@@ -0,0 +1,57 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chat.item.isShortEmoji
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
actual fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>) {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
SingleEmojiInput(emoji)
TagListNameTextField(name, showError = showError)
}
}
@Composable
private fun SingleEmojiInput(
emoji: MutableState<String?>
) {
TextField(
value = emoji.value?.let { TextFieldValue(it) } ?: TextFieldValue(""),
onValueChange = { newValue ->
if (newValue.text == emoji.value) return@TextField
val newValueClamped = newValue.text.replace(emoji.value ?: "", "")
emoji.value = if (isShortEmoji(newValueClamped)) newValueClamped else null
},
singleLine = true,
maxLines = 1,
modifier = Modifier
.size(60.dp)
.padding(4.dp),
placeholder = {
Icon(
painter = painterResource(MR.images.ic_add_reaction),
contentDescription = null,
tint = MaterialTheme.colors.secondary
)
},
shape = RoundedCornerShape(8.dp),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Unspecified,
focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f),
unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f),
cursorColor = MaterialTheme.colors.secondary,
),
)
}