mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-30 20:45:49 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)}" )
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) } }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user