android, desktop: reports dashboard (#5471)

* android, desktop: reports dashboard

* changes

* changes

* unneeded updates and fixes

* changes

* api change

* item moderated/deleted

* a lot of changes

* changes

* reports tag and icon in ChatList

* archived by

* increasing counter when new report arrives

* refactor

* groupInfo button and closing when needed

* fix archived by

* reorder

* simplify

* rename

* filled flag

* Revert "filled flag"

This reverts commit 8b5da85101.

* removed support of archived page and counter

* fix closing modal

* show search button in bar without menu

* removed content filter

* no icon

* Revert "no icon"

This reverts commit 86c725b53e.

* fix tags

* unlogs

* unlogs

* chat item statuses

* background color

* refactor

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2025-01-11 02:41:33 +07:00
committed by GitHub
parent c8c6a832dd
commit 94815bf644
36 changed files with 1171 additions and 436 deletions
@@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
@@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
content: LazyListScope.() -> Unit
) {
@@ -81,6 +81,9 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
chatModel.chatId.value = null
chatItems.clearAndNotify()
}
withChats {
chatItems.clearAndNotify()
}
}
} else {
// ChatList, nothing to do. Maybe to show other view except ChatList
@@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
.graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() }
) Box2@{
currentChatId.value?.let {
ChatView(currentChatId, onComposed)
ChatView(currentChatId, reportsView = false, onComposed = onComposed)
}
}
}
@@ -393,7 +393,7 @@ fun CenterPartOfScreen() {
ModalManager.center.showInView()
}
}
else -> ChatView(currentChatId) {}
else -> ChatView(currentChatId, reportsView = false) {}
}
}
@@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.ChatModel.chatItemsChangesListener
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
@@ -59,28 +58,18 @@ object ChatModel {
val ctrlInitInProgress = mutableStateOf(false)
val dbMigrationInProgress = mutableStateOf(false)
val incompleteInitializedDbRemoved = mutableStateOf(false)
private val _chats = mutableStateOf(SnapshotStateList<Chat>())
val chats: State<List<Chat>> = _chats
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat
val chatId = mutableStateOf<String?>(null)
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addAndNotify].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
private val _chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
val chatItems: State<SnapshotStateList<ChatItem>> = _chatItems
// declaration of chatsContext should be after any other variable that is directly attached to ChatsContext class, otherwise, strange crash with NullPointerException for "this" parameter in random functions
private val chatsContext = ChatsContext()
// set listener here that will be notified on every add/delete of a chat item
var chatItemsChangesListener: ChatItemsChangesListener? = null
val chatState = ActiveChatState()
val chatsContext = ChatsContext(null)
val reportsChatsContext = ChatsContext(MsgContentTag.Report)
// declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions
val chats: State<List<Chat>> = chatsContext.chats
// rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
@@ -178,6 +167,36 @@ object ChatModel {
// return true if you handled the click
var centerPanelBackgroundClickHandler: (() -> Boolean)? = null
fun chatsForContent(contentTag: MsgContentTag?): State<SnapshotStateList<Chat>> = when(contentTag) {
null -> chatsContext.chats
MsgContentTag.Report -> reportsChatsContext.chats
else -> TODO()
}
fun chatItemsForContent(contentTag: MsgContentTag?): State<SnapshotStateList<ChatItem>> = when(contentTag) {
null -> chatsContext.chatItems
MsgContentTag.Report -> reportsChatsContext.chatItems
else -> TODO()
}
fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) {
null -> chatsContext.chatState
MsgContentTag.Report -> reportsChatsContext.chatState
else -> TODO()
}
fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) {
null -> chatsContext.chatItemsChangesListener
MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener
else -> TODO()
}
fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) {
null -> chatsContext.chatItemsChangesListener = listener
MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener
else -> TODO()
}
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
} else {
@@ -210,7 +229,7 @@ object ChatModel {
for (chat in chats.value.filter { it.remoteHostId == rhId }) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chat.chatInfo)) {
if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) {
newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1
}
}
@@ -250,17 +269,17 @@ object ChatModel {
}
}
fun addPresetChatTags(chatInfo: ChatInfo) {
private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
presetTags[tag] = (presetTags[tag] ?: 0) + 1
}
}
}
fun removePresetChatTags(chatInfo: ChatInfo) {
fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
val count = presetTags[tag]
if (count != null) {
presetTags[tag] = maxOf(0, count - 1)
@@ -269,27 +288,6 @@ object ChatModel {
}
}
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 ->
@@ -304,18 +302,6 @@ object ChatModel {
}
}
}
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?
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
@@ -340,13 +326,35 @@ object ChatModel {
}
// running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread
suspend fun <T> withChats(action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) {
chatsContext.action()
suspend fun <T> withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) {
when {
contentTag == null -> chatsContext.action()
contentTag == MsgContentTag.Report -> reportsChatsContext.action()
else -> TODO()
}
}
class ChatsContext {
val chats = _chats
val chatItems = _chatItems
suspend fun <T> withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) {
if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) {
reportsChatsContext.action()
}
}
class ChatsContext(private val contentTag: MsgContentTag?) {
val chats = mutableStateOf(SnapshotStateList<Chat>())
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addAndNotify].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
// set listener here that will be notified on every add/delete of a chat item
var chatItemsChangesListener: ChatItemsChangesListener? = null
val chatState = ActiveChatState()
fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
suspend fun addChat(chat: Chat) {
chats.add(index = 0, chat)
@@ -385,6 +393,13 @@ object ChatModel {
}
}
fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) {
val i = getChatIndex(rhId, chatId)
if (i >= 0) {
chats[i] = chats[i].copy(chatStats = chatStats)
}
}
suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection))
suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
@@ -402,7 +417,7 @@ object ChatModel {
updateChatInfo(rhId, cInfo)
} else if (addMissing) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
addPresetChatTags(cInfo)
addPresetChatTags(cInfo, Chat.ChatStats())
}
}
@@ -463,7 +478,7 @@ object ChatModel {
else
chat.chatStats
)
updateChatTagRead(chats[i], wasUnread)
updateChatTagReadNoContentTag(chats[i], wasUnread)
if (appPlatform.isDesktop && cItem.chatDir.sent) {
reorderChat(chats[i], 0)
@@ -479,9 +494,9 @@ object ChatModel {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag)
} else {
chatItems.addAndNotify(cItem)
chatItems.addAndNotify(cItem, contentTag)
}
}
}
@@ -500,7 +515,7 @@ object ChatModel {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChat(rhId, cInfo.id)
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
}
res = false
@@ -526,7 +541,7 @@ object ChatModel {
} else {
cItem
}
chatItems.addAndNotify(ci)
chatItems.addAndNotify(ci, contentTag)
true
}
} else {
@@ -551,7 +566,7 @@ object ChatModel {
fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(rhId, cInfo.id)
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
@@ -590,9 +605,9 @@ object ChatModel {
}
}
val popChatCollector = PopChatCollector()
val popChatCollector = PopChatCollector(contentTag)
class PopChatCollector {
class PopChatCollector(contentTag: MsgContentTag?) {
private val subject = MutableSharedFlow<Unit>()
private var remoteHostId: Long? = null
private val chatsToPop = mutableMapOf<ChatId, Instant>()
@@ -602,7 +617,7 @@ object ChatModel {
subject
.throttleLatest(2000)
.collect {
withChats {
withChats(contentTag) {
chats.replaceAll(popCollectedChats())
}
}
@@ -640,11 +655,10 @@ object ChatModel {
}
}
fun markChatItemsRead(remoteHostId: Long?, chatInfo: ChatInfo, itemIds: List<Long>? = null) {
val cInfo = chatInfo
val markedRead = markItemsReadInCurrentChat(chatInfo, itemIds)
fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List<Long>? = null) {
val markedRead = markItemsReadInCurrentChat(id, itemIds)
// update preview
val chatIdx = getChatIndex(remoteHostId, cInfo.id)
val chatIdx = getChatIndex(remoteHostId, id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
@@ -655,12 +669,47 @@ object ChatModel {
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(unreadCount = unreadCount)
)
updateChatTagRead(chats[chatIdx], wasUnread)
updateChatTagReadNoContentTag(chats[chatIdx], wasUnread)
}
}
}
private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) {
private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List<Long>? = null): Int {
var markedRead = 0
if (chatId.value == id) {
val items = chatItems.value
var i = items.lastIndex
val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf()
val markedReadIds = mutableSetOf<Long>()
while (i >= 0) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) {
val newItem = item.withStatus(CIStatus.RcvRead())
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedReadIds.add(item.id)
markedRead++
if (itemIds != null) {
itemIdsFromRange.remove(item.id)
// already set all needed items as read, can finish the loop
if (itemIdsFromRange.isEmpty()) break
}
}
i--
}
chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items)
}
return markedRead
}
private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
val chatIndex = getChatIndex(rhId, chatId)
if (chatIndex == -1) return
@@ -673,21 +722,21 @@ object ChatModel {
unreadCount = unreadCount,
)
)
updateChatTagRead(chats[chatIndex], wasUnread)
updateChatTagReadNoContentTag(chats[chatIndex], wasUnread)
}
fun removeChat(rhId: Long?, id: String) {
var removed: ChatInfo? = null
var removed: Chat? = null
chats.removeAll {
val found = it.id == id && it.remoteHostId == rhId
if (found) {
removed = it.chatInfo
removed = it
}
found
}
removed?.let {
removePresetChatTags(it)
removePresetChatTags(it.chatInfo, it.chatStats)
}
}
@@ -741,9 +790,92 @@ object ChatModel {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounterNoContentTag(rhId, user, 1)
}
fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
changeUnreadCounterNoContentTag(rhId, user, -by)
}
private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
if (i != -1) {
users[i] = users[i].copy(unreadCount = users[i].unreadCount + by)
}
}
fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
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) {
markChatTagReadNoContentTag_(chat, tags)
}
}
fun markChatTagRead(chat: Chat) {
if (chat.unreadTag) {
chat.chatInfo.chatTags?.let { tags ->
markChatTagReadNoContentTag_(chat, tags)
}
}
}
private fun markChatTagReadNoContentTag_(chat: Chat, tags: List<Long>) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
for (tag in tags) {
val count = unreadTags[tag]
if (count != null) {
unreadTags[tag] = maxOf(0, count - 1)
}
}
}
fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) {
changeGroupReportsCounter(rhId, chatId, 1)
}
fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) {
changeGroupReportsCounter(rhId, chatId, -1)
}
private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) {
if (by == 0) return
val i = getChatIndex(rhId, chatId)
if (i >= 0) {
val chat = chats.value[i]
chats[i] = chat.copy(
chatStats = chat.chatStats.copy(
reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0),
)
)
val wasReportsCount = chat.chatStats.reportsCount
val nowReportsCount = chats[i].chatStats.reportsCount
val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0
changeGroupReportsTagNoContentTag(by)
}
}
private fun changeGroupReportsTagNoContentTag(by: Int = 0) {
if (by == 0 || contentTag != null) return
presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by
}
}
fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
val current = currentUser.value ?: return
@@ -773,82 +905,32 @@ object ChatModel {
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withChats {
chatItems.addAndNotify(cItem)
chatItems.addAndNotify(cItem, contentTag = null)
}
return cItem
}
fun removeLiveDummy() {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
withApi {
withChats {
chatItems.removeLastAndNotify()
chatItems.removeLastAndNotify(contentTag = null)
}
}
}
}
private fun markItemsReadInCurrentChat(chatInfo: ChatInfo, itemIds: List<Long>? = null): Int {
val cInfo = chatInfo
var markedRead = 0
if (chatId.value == cInfo.id) {
val items = chatItems.value
var i = items.lastIndex
val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf()
val markedReadIds = mutableSetOf<Long>()
while (i >= 0) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) {
val newItem = item.withStatus(CIStatus.RcvRead())
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedReadIds.add(item.id)
markedRead++
if (itemIds != null) {
itemIdsFromRange.remove(item.id)
// already set all needed items as read, can finish the loop
if (itemIdsFromRange.isEmpty()) break
}
}
i--
}
chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items)
}
return markedRead
}
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounter(rhId, user, 1)
}
fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
changeUnreadCounter(rhId, user, -by)
}
private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) {
val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
if (i != -1) {
users[i] = users[i].copy(unreadCount = users[i].unreadCount + by)
}
}
fun getChatItemIndexOrNull(cItem: ChatItem): Int? {
val reversedChatItems = chatItems.asReversed()
fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List<ChatItem>): Int? {
val index = reversedChatItems.indexOfFirst { it.id == cItem.id }
return if (index != -1) index else null
}
// this function analyses "connected" events and assumes that each member will be there only once
fun getConnectedMemberNames(cItem: ChatItem): Pair<Int, List<String>> {
fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List<ChatItem>): Pair<Int, List<String>> {
var count = 0
val ns = mutableListOf<String>()
var idx = getChatItemIndexOrNull(cItem)
var idx = getChatItemIndexOrNull(cItem, reversedChatItems)
if (cItem.mergeCategory != null && idx != null) {
val reversedChatItems = chatItems.asReversed()
while (idx < reversedChatItems.size) {
val ci = reversedChatItems[idx]
if (ci.mergeCategory != cItem.mergeCategory) break
@@ -865,9 +947,8 @@ object ChatModel {
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List<ChatItem>): Pair<Int?, ChatItem?> {
var i = ciIndex ?: return null to null
val reversedChatItems = chatItems.asReversed()
val fst = reversedChatItems.lastIndex
while (i < fst) {
i++
@@ -880,8 +961,7 @@ object ChatModel {
}
// returns the previous member in the same merge group and the count of members in this group
fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair<GroupMember?, Int> {
val reversedChatItems = chatItems.asReversed()
fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List<ChatItem>): Pair<GroupMember?, Int> {
var prevMember: GroupMember? = null
val names: MutableSet<Long> = mutableSetOf()
for (i in range) {
@@ -1157,7 +1237,13 @@ data class Chat(
}
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
data class ChatStats(
val unreadCount: Int = 0,
// actual only via getChats() and getChat(.initial), otherwise, zero
val reportsCount: Int = 0,
val minUnreadItemId: Long = 0,
val unreadChat: Boolean = false
)
companion object {
val sampleData = Chat(
@@ -1677,6 +1763,9 @@ data class GroupInfo (
val canAddMembers: Boolean
get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
val canModerate: Boolean
get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive
companion object {
val sampleData = GroupInfo(
groupId = 1,
@@ -2310,6 +2399,8 @@ data class ChatItem (
else -> false
}
val isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null
val canBeDeletedForSelf: Boolean
get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete
@@ -2526,8 +2617,8 @@ fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) }
}
fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
@@ -2538,8 +2629,8 @@ fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
fun <T> MutableList<T>.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate)
// Adds item to chatItems and notifies a listener about newly added item
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) }
}
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
@@ -2568,7 +2659,8 @@ fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatIte
}
}
if (toRemove.isNotEmpty()) {
chatItemsChangesListener?.removed(toRemove, value)
chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value)
chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value)
}
}
@@ -2580,7 +2672,7 @@ fun MutableState<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
return res
}
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify(contentTag: MsgContentTag?) {
val removed: Triple<Long, Int, Boolean>
value = SnapshotStateList<ChatItem>().apply {
addAll(value)
@@ -2588,7 +2680,7 @@ fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
val rem = removeLast()
removed = Triple(rem.id, remIndex, rem.isRcvNew)
}
chatItemsChangesListener?.removed(listOf(removed), value)
chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value)
}
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
@@ -2602,7 +2694,8 @@ fun MutableState<SnapshotStateList<Chat>>.clear() {
// Removes all chatItems and notifies a listener about it
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
value = SnapshotStateList()
chatItemsChangesListener?.cleared()
chatModel.chatsContext.chatItemsChangesListener?.cleared()
chatModel.reportsChatsContext.chatItemsChangesListener?.cleared()
}
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()
@@ -3688,6 +3781,17 @@ object MsgContentSerializer : KSerializer<MsgContent> {
}
}
@Serializable
enum class MsgContentTag {
@SerialName("text") Text,
@SerialName("link") Link,
@SerialName("image") Image,
@SerialName("video") Video,
@SerialName("voice") Voice,
@SerialName("file") File,
@SerialName("report") Report,
}
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
// TODO make it dependent on simplexLinkMode preference
@@ -18,6 +18,7 @@ import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
@@ -893,8 +894,8 @@ object ChatController {
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))
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, 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
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
@@ -1497,6 +1498,9 @@ object ChatController {
withChats {
clearChat(chat.remoteHostId, updatedChatInfo)
}
withChats(MsgContentTag.Report) {
clearChat(chat.remoteHostId, updatedChatInfo)
}
ntfManager.cancelNotificationsForChat(chat.chatInfo.id)
close?.invoke()
}
@@ -2402,7 +2406,7 @@ object ChatController {
val cInfo = ChatInfo.ContactRequest(contactRequest)
if (active(r.user)) {
withChats {
if (chatModel.hasChat(rhId, contactRequest.id)) {
if (hasChat(rhId, contactRequest.id)) {
updateChatInfo(rhId, cInfo)
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf()))
@@ -2412,7 +2416,7 @@ object ChatController {
ntfManager.notifyContactRequestReceived(r.user, cInfo)
}
is CR.ContactUpdated -> {
if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) {
if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) {
val cInfo = ChatInfo.Direct(r.toContact)
withChats {
updateChatInfo(rhId, cInfo)
@@ -2424,10 +2428,13 @@ object ChatController {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.toMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.toMember)
}
}
}
is CR.ContactsMerged -> {
if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) {
if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) {
if (chatModel.chatId.value == r.mergedContact.id) {
chatModel.chatId.value = r.intoContact.id
}
@@ -2472,9 +2479,19 @@ object ChatController {
if (active(r.user)) {
withChats {
addChatItem(rhId, cInfo, cItem)
if (cItem.isActiveReport) {
increaseGroupReportsCounter(rhId, cInfo.id)
}
}
withReportsChatsIfOpen {
if (cItem.isReport) {
addChatItem(rhId, cInfo, cItem)
}
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
withChats {
increaseUnreadCounter(rhId, r.user)
}
}
val file = cItem.file
val mc = cItem.content.msgContent
@@ -2497,6 +2514,11 @@ object ChatController {
withChats {
updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
withReportsChatsIfOpen {
if (cItem.isReport) {
updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
}
}
}
is CR.ChatItemUpdated ->
@@ -2506,13 +2528,20 @@ object ChatController {
withChats {
updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
withReportsChatsIfOpen {
if (r.reaction.chatReaction.chatItem.isReport) {
updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
}
}
is CR.ChatItemsDeleted -> {
if (!active(r.user)) {
r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) ->
if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) {
chatModel.decreaseUnreadCounter(rhId, r.user)
withChats {
decreaseUnreadCounter(rhId, r.user)
}
}
}
return
@@ -2541,6 +2570,67 @@ object ChatController {
upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
withReportsChatsIfOpen {
if (cItem.isReport) {
if (toChatItem == null) {
removeChatItem(rhId, cInfo, cItem)
} else {
upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
}
}
}
is CR.GroupChatItemsDeleted -> {
if (!active(r.user)) {
val users = chatController.listUsers(rhId)
chatModel.users.clear()
chatModel.users.addAll(users)
return
}
val cInfo = ChatInfo.Group(r.groupInfo)
withChats {
r.chatItemIDs.forEach { itemId ->
val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach
if (chatModel.chatId.value != null) {
// Stop voice playback only inside a chat, allow to play in a chat list
AudioPlayer.stop(cItem)
}
val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
ntfManager.cancelNotificationsForChat(cInfo.id)
ntfManager.displayNotification(
r.user,
cInfo.id,
cInfo.displayName,
generalGetString(MR.strings.marked_deleted_description)
)
}
val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) {
CIDeleted.Moderated(Clock.System.now(), r.member_)
} else {
CIDeleted.Deleted(Clock.System.now())
}
upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted)))
if (cItem.isActiveReport) {
decreaseGroupReportsCounter(rhId, cInfo.id)
}
}
}
withReportsChatsIfOpen {
r.chatItemIDs.forEach { itemId ->
val cItem = chatItems.value.firstOrNull { it.id == itemId } ?: return@forEach
if (chatModel.chatId.value != null) {
// Stop voice playback only inside a chat, allow to play in a chat list
AudioPlayer.stop(cItem)
}
val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember != r.member_) {
CIDeleted.Moderated(Clock.System.now(), r.member_)
} else {
CIDeleted.Deleted(Clock.System.now())
}
upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted)))
}
}
}
is CR.ReceivedGroupInvitation -> {
@@ -2606,30 +2696,45 @@ object ChatController {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
}
}
is CR.LeftMember ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRole ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRoleUser ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberBlockedForAll ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.GroupDeleted -> // TODO update user member
if (active(r.user)) {
@@ -3002,6 +3107,11 @@ object ChatController {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
withChats { upsertChatItem(rh, cInfo, cItem) }
withReportsChatsIfOpen {
if (cItem.isReport) {
upsertChatItem(rh, cInfo, cItem)
}
}
}
}
@@ -3011,10 +3121,14 @@ object ChatController {
val notify = { ntfManager.notifyMessageReceived(rh, user, cInfo, cItem) }
if (!activeUser(rh, user)) {
notify()
} else if (withChats { upsertChatItem(rh, cInfo, cItem) }) {
notify()
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
notify()
} else {
val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) }
withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } }
if (createdChat) {
notify()
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
notify()
}
}
}
@@ -3059,6 +3173,11 @@ object ChatController {
chats.clear()
popChatCollector.clear()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
}
val statuses = apiGetNetworkStatuses(rhId)
if (statuses != null) {
@@ -3205,7 +3324,7 @@ sealed class 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 ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, 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()
@@ -3367,7 +3486,14 @@ sealed class CC {
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 ApiGetChat -> {
val tag = if (contentTag == null) {
""
} else {
" content=${contentTag.name.lowercase()}"
}
"/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
}
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
@@ -5540,6 +5666,7 @@ sealed class CR {
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("reactionMembers") class ReactionMembers(val user: UserRef, val memberReactions: List<MemberReaction>): CR()
@Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List<ChatItemDeletion>, val byUser: Boolean): CR()
@Serializable @SerialName("groupChatItemsDeleted") class GroupChatItemsDeleted(val user: UserRef, val groupInfo: GroupInfo, val chatItemIDs: List<Long>, val byUser: Boolean, val member_: GroupMember?): CR()
@Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List<Long>, val forwardConfirmation: ForwardConfirmation? = null): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@@ -5724,6 +5851,7 @@ sealed class CR {
is ChatItemReaction -> "chatItemReaction"
is ReactionMembers -> "reactionMembers"
is ChatItemsDeleted -> "chatItemsDeleted"
is GroupChatItemsDeleted -> "groupChatItemsDeleted"
is ForwardPlan -> "forwardPlan"
is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation"
@@ -5826,7 +5954,7 @@ sealed class CR {
is ChatRunning -> noDetails()
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}")
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)}")
@@ -5900,6 +6028,7 @@ sealed class CR {
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
is ReactionMembers -> withUser(user, "memberReactions: ${json.encodeToString(memberReactions)}")
is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser")
is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_")
is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}")
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
@@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
@@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
content: LazyListScope.() -> Unit
)
@@ -26,6 +26,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
import chat.simplex.common.views.chatlist.NavigationBarBackground
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
}
}
LazyColumnWithScrollBar (
reverseLayout = true,
state = listState,
contentPadding = PaddingValues(
top = topPaddingToContent(false),
bottom = composeViewHeight.value
),
state = listState,
reverseLayout = true,
additionalBarOffset = composeViewHeight
) {
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
@@ -36,6 +36,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage(
rhId: Long?,
chatType: ChatType,
apiId: Long,
itemId: Long
itemId: Long,
contentTag: MsgContentTag?,
): ChatItem? = coroutineScope {
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
chat.chatItems.firstOrNull()
}
@@ -24,29 +25,36 @@ suspend fun apiLoadMessages(
rhId: Long?,
chatType: ChatType,
apiId: Long,
contentTag: MsgContentTag?,
pagination: ChatPagination,
chatState: ActiveChatState,
search: String = "",
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|| !isActive) return@coroutineScope
val chatState = chatModel.chatStateForContent(contentTag)
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
val oldItems = chatModel.chatItems.value
val oldItems = chatModel.chatItemsForContent(contentTag).value
val newItems = SnapshotStateList<ChatItem>()
when (pagination) {
is ChatPagination.Initial -> {
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
withChats {
if (chatModel.getChat(chat.id) == null) {
addChat(chat)
if (contentTag == null) {
// update main chats, not content tagged
withChats {
if (getChat(chat.id) == null) {
addChat(chat)
} else {
updateChatInfo(chat.remoteHostId, chat.chatInfo)
updateChatStats(chat.remoteHostId, chat.id, chat.chatStats)
}
}
}
withChats {
chatModel.chatItemStatuses.clear()
withChats(contentTag) {
chatItemStatuses.clear()
chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
splits.value = newSplits
@@ -70,7 +78,7 @@ suspend fun apiLoadMessages(
)
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
newItems.addAll(insertAt, chat.chatItems)
withChats {
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
@@ -89,7 +97,7 @@ suspend fun apiLoadMessages(
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
val indexToAddIsLast = indexToAdd == newItems.size
newItems.addAll(indexToAdd, chat.chatItems)
withChats {
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
@@ -104,7 +112,7 @@ suspend fun apiLoadMessages(
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
// currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems)
withChats {
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits
unreadAfterItemId.value = chat.chatItems.last().id
@@ -119,7 +127,7 @@ suspend fun apiLoadMessages(
newItems.addAll(oldItems)
removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems)
withChats {
withChats(contentTag) {
chatItems.replaceAll(newItems)
unreadAfterNewestLoaded.value = 0
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
@@ -240,14 +239,13 @@ data class ActiveChatState (
}
}
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, listState: LazyListState): IntRange {
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, reversedItemsSize: Int, listState: LazyListState): IntRange {
val zero = 0 .. 0
if (listState.layoutInfo.totalItemsCount == 0) return zero
val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems
val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed()
if (newest == null || oldest == null) return zero
val size = chatModel.chatItems.value.size
val range = size - oldest .. size - newest
val range = reversedItemsSize - oldest .. reversedItemsSize - newest
if (range.first < 0 || range.last < 0) return zero
// visible items mapped to their underlying data structure which is chatModel.chatItems
@@ -31,8 +31,8 @@ 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.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.group.*
@@ -57,10 +57,17 @@ data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val dat
@Composable
// staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat
// to chat list smooth. Otherwise, chat view will become blank right before the transition starts
fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -> Unit) {
val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } }
fun ChatView(
staleChatId: State<String?>,
reportsView: Boolean,
scrollToItemId: MutableState<Long?> = remember { mutableStateOf(null) },
onComposed: suspend (chatId: String) -> Unit
) {
val showSearch = rememberSaveable { mutableStateOf(false) }
// They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..."
val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } }
val activeChatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatInfo } }
val activeChatStats = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats } }
val user = chatModel.currentUser.value
val chatInfo = activeChatInfo.value
if (chatInfo == null || user == null) {
@@ -69,6 +76,11 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
ModalManager.end.closeModals()
}
} else {
val groupReports = remember { derivedStateOf {
val reportsCount = if (activeChatInfo.value is ChatInfo.Group) activeChatStats.value?.reportsCount ?: 0 else 0
GroupReports(reportsCount, reportsView) }
}
val reversedChatItems = remember { derivedStateOf { chatModel.chatItemsForContent(groupReports.value.contentTag).value.asReversed() } }
val searchText = rememberSaveable { mutableStateOf("") }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
val composeState = rememberSaveable(saver = ComposeState.saver()) {
@@ -94,7 +106,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
.distinctUntilChanged()
.filterNotNull()
.collect { chatId ->
markUnreadChatAsRead(chatId)
if (!groupReports.value.reportsView) {
markUnreadChatAsRead(chatId)
}
showSearch.value = false
searchText.value = ""
selectedChatItems.value = null
@@ -107,11 +121,14 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
// Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember {
derivedStateOf {
chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == activeChatInfo.value?.id }?.chatStats?.unreadCount ?: 0
chatModel.chatsForContent(if (reportsView) MsgContentTag.Report else null).value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.chatStats?.unreadCount ?: 0
}
}
val clipboard = LocalClipboardManager.current
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) {
CompositionLocalProvider(
LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false),
LocalContentTag provides groupReports.value.contentTag
) {
when (chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
var groupMembersJob: Job = remember { Job() }
@@ -119,9 +136,19 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) }
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
val onSearchValueChanged: (String) -> Unit = onSearchValueChanged@{ value ->
if (searchText.value == value) return@onSearchValueChanged
val c = chatModel.getChat(chatInfo.id) ?: return@onSearchValueChanged
if (chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
withBGApi {
apiFindMessages(c, value, groupReports.value.toContentTag())
searchText.value = value
}
}
ChatLayout(
remoteHostId = remoteHostId,
chatInfo = activeChatInfo,
reversedChatItems = reversedChatItems,
unreadCount,
composeState,
composeView = {
@@ -150,7 +177,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
} else {
SelectedItemsBottomToolbar(
chatItems = remember { chatModel.chatItems }.value,
reversedChatItems = reversedChatItems,
selectedChatItems = selectedChatItems,
chatInfo = chatInfo,
deleteItems = { canDeleteForAll ->
@@ -211,6 +238,8 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
)
}
},
groupReports,
scrollToItemId,
attachmentOption,
attachmentBottomSheetState,
searchText,
@@ -266,7 +295,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
link = chatModel.controller.apiGetGroupLink(chatRh, chatInfo.groupInfo.groupId)
preloadedLink = link
}
GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, {
GroupChatInfoView(chatModel, chatRh, chatInfo.id, link?.first, link?.second, scrollToItemId, {
link = it
preloadedLink = it
}, close, { showSearch.value = true })
@@ -278,6 +307,17 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
}
},
showGroupReports = {
val info = activeChatInfo.value ?: return@ChatLayout
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
return@ChatLayout
}
hideKeyboard(view)
scope.launch {
showGroupReportsView(staleChatId, scrollToItemId, info)
}
},
showMemberInfo = { groupInfo: GroupInfo, member: GroupMember ->
hideKeyboard(view)
groupMembersJob.cancel()
@@ -293,7 +333,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
setGroupMembers(chatRh, groupInfo, chatModel)
if (!isActive) return@launch
ModalManager.end.closeModals()
if (!groupReports.value.reportsView) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(chatRh, groupInfo, mem, stats, code, chatModel, close, close)
@@ -301,16 +343,16 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
}
},
loadMessages = { chatId, pagination, chatState, visibleItemIndexes ->
loadMessages = { chatId, pagination, visibleItemIndexes ->
val c = chatModel.getChat(chatId)
if (chatModel.chatId.value != chatId) return@ChatLayout
if (c != null) {
apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, chatState, searchText.value, visibleItemIndexes)
apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, groupReports.value.toContentTag(), pagination, searchText.value, visibleItemIndexes)
}
},
deleteMessage = { itemId, mode ->
withBGApi {
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
val toDeleteItem = reversedChatItems.value.lastOrNull { it.id == itemId }
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
val groupInfo = toModerate?.first
val groupMember = toModerate?.second
@@ -341,6 +383,19 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
} else {
removeChatItem(chatRh, chatInfo, deletedChatItem)
}
val deletedItem = deleted.deletedChatItem.chatItem
if (deletedItem.isActiveReport) {
decreaseGroupReportsCounter(chatRh, chatInfo.id)
}
}
withReportsChatsIfOpen {
if (deletedChatItem.isReport) {
if (toChatItem != null) {
upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
removeChatItem(chatRh, chatInfo, deletedChatItem)
}
}
}
}
}
@@ -454,6 +509,11 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
withChats {
updateChatItem(cInfo, updatedCI)
}
withReportsChatsIfOpen {
if (cItem.isReport) {
updateChatItem(cInfo, updatedCI)
}
}
}
}
},
@@ -471,7 +531,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
groupMembersJob.cancel()
groupMembersJob = scope.launch(Dispatchers.Default) {
var initialCiInfo = loadChatItemInfo() ?: return@launch
ModalManager.end.closeModals()
if (!ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) {
ModalManager.end.closeModals()
}
ModalManager.end.showModalCloseable(endButtons = {
ShareButton {
clipboard.shareText(itemInfoShareText(chatModel, cItem, initialCiInfo, chatModel.controller.appPrefs.developerTools.get()))
@@ -506,7 +568,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
withChats {
// It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace
withContext(Dispatchers.Main) {
markChatItemsRead(chatRh, chatInfo, itemsIds)
markChatItemsRead(chatRh, chatInfo.id, itemsIds)
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.apiChatItemsRead(
@@ -516,6 +578,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
itemsIds
)
}
withReportsChatsIfOpen {
markChatItemsRead(chatRh, chatInfo.id, itemsIds)
}
}
},
markChatRead = {
@@ -523,7 +588,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
withChats {
// It's important to call it on Main thread. Otherwise, composable crash occurs from time-to-time without useful stacktrace
withContext(Dispatchers.Main) {
markChatItemsRead(chatRh, chatInfo)
markChatItemsRead(chatRh, chatInfo.id)
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
chatModel.controller.apiChatRead(
@@ -532,18 +597,13 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
chatInfo.apiId
)
}
withReportsChatsIfOpen {
markChatItemsRead(chatRh, chatInfo.id)
}
}
},
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
onSearchValueChanged = { value ->
if (searchText.value == value) return@ChatLayout
val c = chatModel.getChat(chatInfo.id) ?: return@ChatLayout
if (chatModel.chatId.value != chatInfo.id) return@ChatLayout
withBGApi {
apiFindMessages(c, value)
searchText.value = value
}
},
onSearchValueChanged = onSearchValueChanged,
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
@@ -600,9 +660,12 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
fun ChatLayout(
remoteHostId: State<Long?>,
chatInfo: State<ChatInfo?>,
reversedChatItems: State<List<ChatItem>>,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
groupReports: State<GroupReports>,
scrollToItemId: MutableState<Long?>,
attachmentOption: MutableState<AttachmentOption?>,
attachmentBottomSheetState: ModalBottomSheetState,
searchValue: State<String>,
@@ -611,8 +674,9 @@ fun ChatLayout(
selectedChatItems: MutableState<Set<Long>?>,
back: () -> Unit,
info: () -> Unit,
showGroupReports: () -> Unit,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
@@ -671,7 +735,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val composeViewHeight = remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, !groupReports.value.reportsView)) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
val oneHandUI = remember { appPrefs.oneHandUI.state }
@@ -684,8 +748,8 @@ fun ChatLayout(
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
}) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages,
remoteHostId, chatInfo, reversedChatItems, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, groupReports, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
@@ -693,29 +757,90 @@ fun ChatLayout(
}
}
}
Box(
Modifier
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
.then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier)
) {
composeView()
if (groupReports.value.reportsView) {
Column(
Modifier
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.imePadding()
) {
AnimatedVisibility(selectedChatItems.value != null) {
if (chatInfo != null) {
SelectedItemsBottomToolbar(
reversedChatItems = reversedChatItems,
selectedChatItems = selectedChatItems,
chatInfo = chatInfo,
deleteItems = { _ ->
val itemIds = selectedChatItems.value
val questionText = generalGetString(MR.strings.delete_messages_cannot_be_undone_warning)
if (itemIds != null) {
deleteMessagesAlertDialog(itemIds.sorted(), questionText = questionText, forAll = false, deleteMessages = { ids, _ ->
deleteMessages(remoteHostId, chatInfo, ids, false, moderate = false) {
selectedChatItems.value = null
}
})
}
},
moderateItems = {},
forwardItems = {}
)
}
}
if (oneHandUI.value) {
// That's placeholder to take some space for bottom app bar in oneHandUI
Box(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
}
}
} else {
Box(
Modifier
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
.then(if (oneHandUI.value && chatBottomBar.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier)
) {
composeView()
}
}
}
if (oneHandUI.value && chatBottomBar.value) {
StatusBarBackground()
if (groupReports.value.showBar) {
ReportedCountToolbar(groupReports, withStatusBar = true, showGroupReports)
} else {
StatusBarBackground()
}
} else {
NavigationBarBackground(true, oneHandUI.value, noAlpha = true)
}
Box(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
if (groupReports.value.reportsView) {
if (oneHandUI.value) {
StatusBarBackground()
}
Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Box {
if (selectedChatItems.value == null) {
GroupReportsAppBar(groupReports, { ModalManager.end.closeModal() }, onSearchValueChanged)
} else {
SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value)
}
}
}
} else {
Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Box {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, groupReports, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
}
}
if (groupReports.value.showBar && (!oneHandUI.value || !chatBottomBar.value)) {
ReportedCountToolbar(groupReports, withStatusBar = false, showGroupReports)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
}
}
@@ -726,6 +851,7 @@ fun ChatLayout(
@Composable
fun BoxScope.ChatInfoToolbar(
chatInfo: ChatInfo,
groupReports: State<GroupReports>,
back: () -> Unit,
info: () -> Unit,
startCall: (CallMediaType) -> Unit,
@@ -747,7 +873,7 @@ fun BoxScope.ChatInfoToolbar(
showSearch.value = false
}
}
if (appPlatform.isAndroid) {
if (appPlatform.isAndroid && !groupReports.value.reportsView) {
BackHandler(onBack = onBackClicked)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
@@ -941,25 +1067,65 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
}
}
@Composable
private fun ReportedCountToolbar(
groupReports: State<GroupReports>,
withStatusBar: Boolean,
showGroupReports: () -> Unit
) {
Box {
val statusBarPadding = if (withStatusBar) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else 0.dp
Row(
Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding)
.background(MaterialTheme.colors.background)
.clickable(onClick = showGroupReports)
.padding(top = statusBarPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.error)
Spacer(Modifier.width(4.dp))
val reports = groupReports.value.reportsCount
Text(
if (reports == 1) {
stringResource(MR.strings.group_reports_active_one)
} else {
stringResource(MR.strings.group_reports_active).format(reports)
},
style = MaterialTheme.typography.button
)
}
Divider(Modifier.align(Alignment.BottomStart))
}
}
@Composable
private fun ContactVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
/** Saves current scroll position when [GroupReports] are open and user opens [ChatItemInfoView], for example, and goes back */
private var reportsListState: LazyListState? = null
@Composable
fun BoxScope.ChatItemsList(
remoteHostId: Long?,
chatInfo: ChatInfo,
reversedChatItems: State<List<ChatItem>>,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeViewHeight: State<Dp>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
groupReports: State<GroupReports>,
scrollToItemId: MutableState<Long?>,
selectedChatItems: MutableState<Set<Long>?>,
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
showChatInfo: () -> Unit,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long) -> Unit,
@@ -984,10 +1150,9 @@ fun BoxScope.ChatItemsList(
showViaProxy: Boolean
) {
val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } }
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf<Long>()) }
val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatState) } }
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() })
val mergedItems = remember { derivedStateOf { MergedItems.create(reversedChatItems.value, unreadCount, revealedItems.value, chatModel.chatStateForContent(groupReports.value.contentTag)) } }
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() })
/** determines height based on window info and static height of two AppBars. It's needed because in the first graphic frame height of
* [composeViewHeight] is unknown, but we need to set scroll position for unread messages already so it will be correct before the first frame appears
* */
@@ -996,12 +1161,17 @@ fun BoxScope.ChatItemsList(
)
val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) {
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index <= 0) {
val reportsState = reportsListState
if (reportsState != null) {
reportsListState = null
reportsState
} else if (index <= 0) {
LazyListState(0, 0)
} else {
LazyListState(index + 1, -maxHeightForList.value)
}
})
SaveReportsStateOnDispose(groupReports, listState)
val maxHeight = remember { derivedStateOf { listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val loadingMoreItems = remember { mutableStateOf(false) }
val animatedScrollingInProgress = remember { mutableStateOf(false) }
@@ -1011,12 +1181,12 @@ fun BoxScope.ChatItemsList(
ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect)
}
if (!loadingMoreItems.value) {
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), reversedChatItems, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
if (loadingMoreItems.value) return@PreloadItems false
try {
loadingMoreItems.value = true
loadMessages(chatId, pagination, chatModel.chatState) {
visibleItemIndexesNonReversed(mergedItems, listState.value)
loadMessages(chatId, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
} finally {
loadingMoreItems.value = false
@@ -1029,21 +1199,33 @@ fun BoxScope.ChatItemsList(
val chatInfoUpdated = rememberUpdatedState(chatInfo)
val highlightedItems = remember { mutableStateOf(setOf<Long>()) }
val scope = rememberCoroutineScope()
val scrollToItem: (Long) -> Unit = remember { scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) }
val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem) }
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo)
SmallScrollOnNewMessage(listState, chatModel.chatItems)
val scrollToItem: (Long) -> Unit = remember {
// In group reports just set the itemId to scroll to so the main ChatView will handle scrolling
if (groupReports.value.reportsView) return@remember { scrollToItemId.value = it }
scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages)
}
val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, groupReports.value.contentTag) }
if (!groupReports.value.reportsView) {
LaunchedEffect(Unit) { snapshotFlow { scrollToItemId.value }.filterNotNull().collect {
if (appPlatform.isAndroid) {
ModalManager.end.closeModals()
}
scrollToItem(it)
scrollToItemId.value = null }
}
}
LoadLastItems(loadingMoreItems, remoteHostId, chatInfo, groupReports)
SmallScrollOnNewMessage(listState, reversedChatItems)
val finishedInitialComposition = remember { mutableStateOf(false) }
NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed)
DisposableEffectOnGone(
always = {
chatModel.chatItemsChangesListener = recalculateChatStatePositions(chatModel.chatState)
chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag)
},
whenGone = {
VideoPlayerHolder.releaseAll()
chatModel.chatItemsChangesListener = null
chatModel.setChatItemsChangeListenerForContent(recalculateChatStatePositions(chatModel.chatStateForContent(groupReports.value.contentTag)), groupReports.value.contentTag)
}
)
@@ -1065,7 +1247,7 @@ fun BoxScope.ChatItemsList(
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
) {
val provider = {
providerForGallery(chatModel.chatItems.value, cItem.id) { indexInReversed ->
providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed ->
itemScope.launch {
listState.value.scrollToItem(
min(reversedChatItems.value.lastIndex, indexInReversed + 1),
@@ -1090,7 +1272,7 @@ fun BoxScope.ChatItemsList(
highlightedItems.value = setOf()
}
}
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@@ -1168,7 +1350,7 @@ fun BoxScope.ChatItemsList(
val rangeValue = range.value
val (prevMember, memCount) =
if (rangeValue != null) {
chatModel.getPrevHiddenMember(member, rangeValue)
chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value)
} else {
null to 1
}
@@ -1276,7 +1458,7 @@ fun BoxScope.ChatItemsList(
if (selectionVisible) {
Box(Modifier.matchParentSize().clickable {
val checked = selectedChatItems.value?.contains(cItem.id) == true
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems)
selectUnselectChatItem(select = !checked, cItem, revealed, selectedChatItems, reversedChatItems)
})
}
}
@@ -1290,12 +1472,13 @@ fun BoxScope.ChatItemsList(
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
state = listState.value,
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(true),
top = topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar),
bottom = composeViewHeight.value
),
reverseLayout = true,
additionalBarOffset = composeViewHeight,
additionalTopBar = remember { derivedStateOf { groupReports.value.showBar } },
chatBottomBar = remember { appPrefs.chatBottomBar.state }
) {
val mergedItemsValue = mergedItems.value
@@ -1339,8 +1522,8 @@ fun BoxScope.ChatItemsList(
}
}
}
FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(true)).align(Alignment.TopCenter), mergedItems, listState)
FloatingButtons(loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, groupReports, markChatRead, listState)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopCenter), mergedItems, listState, groupReports)
LaunchedEffect(Unit) {
snapshotFlow { listState.value.isScrollInProgress }
@@ -1360,14 +1543,14 @@ fun BoxScope.ChatItemsList(
}
@Composable
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo, groupReports: State<GroupReports>) {
LaunchedEffect(remoteHostId, chatInfo.id) {
try {
loadingMoreItems.value = true
if (chatModel.chatState.totalAfter.value <= 0) return@LaunchedEffect
if (chatModel.chatStateForContent(groupReports.value.contentTag).totalAfter.value <= 0) return@LaunchedEffect
delay(500)
withContext(Dispatchers.Default) {
apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState)
apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, groupReports.value.toContentTag(), ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
} finally {
loadingMoreItems.value = false
@@ -1376,20 +1559,20 @@ private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, remoteHostId:
}
@Composable
private fun SmallScrollOnNewMessage(listState: State<LazyListState>, chatItems: State<List<ChatItem>>) {
private fun SmallScrollOnNewMessage(listState: State<LazyListState>, reversedChatItems: State<List<ChatItem>>) {
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
LaunchedEffect(Unit) {
var lastTotalItems = listState.value.layoutInfo.totalItemsCount
var lastItemId = chatItems.value.lastOrNull()?.id
var prevTotalItems = listState.value.layoutInfo.totalItemsCount
var newestItemId = reversedChatItems.value.firstOrNull()?.id
snapshotFlow { listState.value.layoutInfo.totalItemsCount }
.distinctUntilChanged()
.drop(1)
.collect {
val diff = listState.value.layoutInfo.totalItemsCount - lastTotalItems
val sameLastItem = lastItemId == chatItems.value.lastOrNull()?.id
lastTotalItems = listState.value.layoutInfo.totalItemsCount
lastItemId = chatItems.value.lastOrNull()?.id
if (diff < 1 || diff > 2 || sameLastItem) {
val diff = listState.value.layoutInfo.totalItemsCount - prevTotalItems
val sameNewestItem = newestItemId == reversedChatItems.value.firstOrNull()?.id
prevTotalItems = listState.value.layoutInfo.totalItemsCount
newestItemId = reversedChatItems.value.firstOrNull()?.id
if (diff < 1 || diff > 2 || sameNewestItem) {
return@collect
}
try {
@@ -1400,7 +1583,7 @@ private fun SmallScrollOnNewMessage(listState: State<LazyListState>, chatItems:
}
} catch (e: CancellationException) {
/**
* When you tap and hold a finger on a lazy column with chatItems, and then you receive a message,
* When you tap and hold a finger on a lazy column with reversedChatItems, and then you receive a message,
* this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll.
* Which breaks auto-scrolling to bottom. So just ignoring the exception
* */
@@ -1440,11 +1623,12 @@ fun BoxScope.FloatingButtons(
maxHeight: State<Int>,
composeViewHeight: State<Dp>,
searchValue: State<String>,
groupReports: State<GroupReports>,
markChatRead: () -> Unit,
listState: State<LazyListState>
) {
val scope = rememberCoroutineScope()
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() })
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() })
val bottomUnreadCount = remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
@@ -1490,7 +1674,7 @@ fun BoxScope.FloatingButtons(
val showDropDown = remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(true)).align(Alignment.TopEnd),
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar)).align(Alignment.TopEnd),
topUnreadCount,
animatedScrollingInProgress,
onClick = {
@@ -1512,7 +1696,7 @@ fun BoxScope.FloatingButtons(
DefaultDropdownMenu(
showDropDown,
modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } },
offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(true))
offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar))
) {
ItemAction(
generalGetString(MR.strings.mark_read),
@@ -1529,6 +1713,7 @@ fun BoxScope.FloatingButtons(
fun PreloadItems(
chatId: String,
ignoreLoadingRequests: MutableSet<Long>,
reversedChatItems: State<List<ChatItem>>,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int,
@@ -1539,8 +1724,8 @@ fun PreloadItems(
val chatId = rememberUpdatedState(chatId)
val loadItems = rememberUpdatedState(loadItems)
val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests)
PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, mergedItems, listState, remaining, loadItems)
PreloadItemsAfter(allowLoad, chatId, mergedItems, listState, remaining, loadItems)
PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, reversedChatItems, mergedItems, listState, remaining, loadItems)
PreloadItemsAfter(allowLoad, chatId, reversedChatItems, mergedItems, listState, remaining, loadItems)
}
@Composable
@@ -1548,6 +1733,7 @@ private fun PreloadItemsBefore(
allowLoad: State<Boolean>,
chatId: State<String>,
ignoreLoadingRequests: State<MutableSet<Long>>,
reversedChatItems: State<List<ChatItem>>,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int,
@@ -1560,9 +1746,9 @@ private fun PreloadItemsBefore(
val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
val items = chatModel.chatItems.value
val items = reversedChatItems.value
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex
lastIndexToLoadFrom = 0
}
if (allowLoad.value && lastIndexToLoadFrom != null) {
items.getOrNull(items.lastIndex - lastIndexToLoadFrom)?.id
@@ -1574,10 +1760,10 @@ private fun PreloadItemsBefore(
.filter { !ignoreLoadingRequests.value.contains(it) }
.collect { loadFromItemId ->
withBGApi {
val sizeWas = chatModel.chatItems.value.size
val firstItemIdWas = chatModel.chatItems.value.firstOrNull()?.id
val sizeWas = reversedChatItems.value.size
val oldestItemIdWas = reversedChatItems.value.lastOrNull()?.id
val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT))
if (triedToLoad && sizeWas == chatModel.chatItems.value.size && firstItemIdWas == chatModel.chatItems.value.firstOrNull()?.id) {
if (triedToLoad && sizeWas == reversedChatItems.value.size && oldestItemIdWas == reversedChatItems.value.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId)
}
}
@@ -1589,6 +1775,7 @@ private fun PreloadItemsBefore(
private fun PreloadItemsAfter(
allowLoad: MutableState<Boolean>,
chatId: State<String>,
reversedChatItems: State<List<ChatItem>>,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int,
@@ -1609,12 +1796,12 @@ private fun PreloadItemsAfter(
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val items = chatModel.chatItems.value
val items = reversedChatItems.value
val splits = mergedItems.value.splits
val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) }
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
items.getOrNull(items.lastIndex - split.indexRangeInReversed.first)?.id
items.getOrNull(split.indexRangeInReversed.first)?.id
} else {
null
}
@@ -1663,13 +1850,14 @@ private fun TopEndFloatingButton(
}
@Composable
fun topPaddingToContent(chatView: Boolean): Dp {
fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): Dp {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
val reportsPadding = if (additionalTopBar) AppBarHeight * fontSizeSqrtMultiplier else 0.dp
return if (oneHandUI.value && (!chatView || chatBottomBar.value)) {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding
} else {
AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + reportsPadding
}
}
@@ -1678,12 +1866,13 @@ private fun FloatingDate(
modifier: Modifier,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
groupReports: State<GroupReports>
) {
val isNearBottom = remember(chatModel.chatId) { mutableStateOf(listState.value.firstVisibleItemIndex == 0) }
val nearBottomIndex = remember(chatModel.chatId) { mutableStateOf(if (isNearBottom.value) -1 else 0) }
val showDate = remember(chatModel.chatId) { mutableStateOf(false) }
val density = LocalDensity.current.density
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(true).roundToPx() })
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent(chatView = !groupReports.value.reportsView, groupReports.value.showBar).roundToPx() })
val fontSizeSqrtMultiplier = fontSizeSqrtMultiplier
val lastVisibleItemDate = remember {
derivedStateOf {
@@ -1767,6 +1956,15 @@ private fun FloatingDate(
}
}
@Composable
private fun SaveReportsStateOnDispose(groupReports: State<GroupReports>, listState: State<LazyListState>) {
DisposableEffect(Unit) {
onDispose {
reportsListState = if (groupReports.value.reportsView && ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) listState.value else null
}
}
}
@Composable
private fun DownloadFilesButton(
forwardConfirmation: ForwardConfirmation.FilesNotAccepted,
@@ -1893,7 +2091,7 @@ private fun scrollToItem(
reversedChatItems: State<List<ChatItem>>,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
): (Long) -> Unit = { itemId: Long ->
withApi {
try {
@@ -1907,8 +2105,8 @@ private fun scrollToItem(
val pagination = ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2)
val oldSize = reversedChatItems.value.size
withContext(Dispatchers.Default) {
loadMessages(chatInfo.value.id, pagination, chatModel.chatState) {
visibleItemIndexesNonReversed(mergedItems, listState.value)
loadMessages(chatInfo.value.id, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
}
var repeatsLeft = 50
@@ -1939,14 +2137,18 @@ private fun findQuotedItemFromItem(
rhId: State<Long?>,
chatInfo: State<ChatInfo>,
scope: CoroutineScope,
scrollToItem: (Long) -> Unit
scrollToItem: (Long) -> Unit,
contentTag: MsgContentTag?
): (Long) -> Unit = { itemId: Long ->
scope.launch(Dispatchers.Default) {
val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId)
val item = apiLoadSingleMessage(rhId.value, chatInfo.value.chatType, chatInfo.value.apiId, itemId, contentTag)
if (item != null) {
withChats {
updateChatItem(chatInfo.value, item)
}
withReportsChatsIfOpen {
updateChatItem(chatInfo.value, item)
}
if (item.quotedItem?.itemId != null) {
scrollToItem(item.quotedItem.itemId)
} else {
@@ -2043,18 +2245,24 @@ private fun SelectedChatItem(
)
}
private fun selectUnselectChatItem(select: Boolean, ci: ChatItem, revealed: State<Boolean>, selectedChatItems: MutableState<Set<Long>?>) {
private fun selectUnselectChatItem(
select: Boolean,
ci: ChatItem,
revealed: State<Boolean>,
selectedChatItems: MutableState<Set<Long>?>,
reversedChatItems: State<List<ChatItem>>
) {
val itemIds = mutableSetOf<Long>()
if (!revealed.value) {
val currIndex = chatModel.getChatItemIndexOrNull(ci)
val currIndex = chatModel.getChatItemIndexOrNull(ci, reversedChatItems.value)
val ciCategory = ci.mergeCategory
if (currIndex != null && ciCategory != null) {
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems.value)
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val reversedChatItems = chatModel.chatItems.asReversed()
val reversed = reversedChatItems.value
for (i in range) {
itemIds.add(reversedChatItems[i].id)
itemIds.add(reversed[i].id)
}
} else {
itemIds.add(ci.id)
@@ -2098,10 +2306,28 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
for (di in deleted) {
val toChatItem = di.toChatItem?.chatItem
if (toChatItem != null) {
upsertChatItem(chatRh, chatInfo, toChatItem)
if (toChatItem.isReport) {
upsertChatItem(chatRh, chatInfo, toChatItem)
}
} else {
removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem)
}
val deletedItem = di.deletedChatItem.chatItem
if (deletedItem.isActiveReport) {
decreaseGroupReportsCounter(chatRh, chatInfo.id)
}
}
}
withReportsChatsIfOpen {
for (di in deleted) {
if (di.deletedChatItem.chatItem.isReport) {
val toChatItem = di.toChatItem?.chatItem
if (toChatItem != null) {
upsertChatItem(chatRh, chatInfo, toChatItem)
} else {
removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem)
}
}
}
}
onSuccess()
@@ -2149,15 +2375,16 @@ fun Modifier.chatViewBackgroundModifier(
colors: Colors,
wallpaper: AppWallpaper,
backgroundGraphicsLayerSize: MutableState<IntSize>?,
backgroundGraphicsLayer: GraphicsLayer?
backgroundGraphicsLayer: GraphicsLayer?,
drawWallpaper: Boolean
): Modifier {
val wallpaperImage = wallpaper.type.image
val wallpaperType = wallpaper.type
val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background)
val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background
val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
return this
.then(if (wallpaperImage != null)
.then(if (wallpaperImage != null && drawWallpaper)
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) }
else
Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } }
@@ -2303,7 +2530,7 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf
private fun forwardContent(chatItemsIds: List<Long>, chatInfo: ChatInfo) {
chatModel.chatId.value = null
chatModel.sharedContent.value = SharedContent.Forward(
chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) },
chatModel.chatItemsForContent(null).value.filter { chatItemsIds.contains(it.id) },
chatInfo
)
}
@@ -2440,9 +2667,12 @@ fun PreviewChatLayout() {
ChatLayout(
remoteHostId = remember { mutableStateOf(null) },
chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) },
reversedChatItems = remember { mutableStateOf(emptyList()) },
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
groupReports = remember { mutableStateOf(GroupReports(0, false)) },
scrollToItemId = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
searchValue,
@@ -2451,8 +2681,9 @@ fun PreviewChatLayout() {
selectedChatItems = remember { mutableStateOf(setOf()) },
back = {},
info = {},
showGroupReports = {},
showMemberInfo = { _, _ -> },
loadMessages = { _, _, _, _ -> },
loadMessages = { _, _, _ -> },
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _ -> },
@@ -2513,9 +2744,12 @@ fun PreviewGroupChatLayout() {
ChatLayout(
remoteHostId = remember { mutableStateOf(null) },
chatInfo = remember { mutableStateOf(ChatInfo.Direct.sampleData) },
reversedChatItems = remember { mutableStateOf(emptyList()) },
unreadCount = unreadCount,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
composeView = {},
groupReports = remember { mutableStateOf(GroupReports(0, false)) },
scrollToItemId = remember { mutableStateOf(null) },
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
searchValue,
@@ -2524,8 +2758,9 @@ fun PreviewGroupChatLayout() {
selectedChatItems = remember { mutableStateOf(setOf()) },
back = {},
info = {},
showGroupReports = {},
showMemberInfo = { _, _ -> },
loadMessages = { _, _, _, _ -> },
loadMessages = { _, _, _ -> },
deleteMessage = { _, _ -> },
deleteMessages = {},
receiveFile = { _ -> },
@@ -836,7 +836,7 @@ fun ComposeView(
fun editPrevMessage() {
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable }
val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
}
@@ -21,11 +21,10 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>, onTop: Boolean) {
val onBackClicked = { selectedChatItems.value = null }
BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
title = {
@@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
)
},
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = onTop,
onSearchValueChanged = {},
)
}
@@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
@Composable
fun SelectedItemsBottomToolbar(
chatInfo: ChatInfo,
chatItems: List<ChatItem>,
reversedChatItems: State<List<ChatItem>>,
selectedChatItems: MutableState<Set<Long>?>,
deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible
moderateItems: () -> Unit,
@@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar(
}
Divider(Modifier.align(Alignment.TopStart))
}
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) {
recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
}
}
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
@@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
withChats {
upsertGroupMember(rhId, groupInfo, member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, member)
}
} else {
break
}
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -45,7 +46,7 @@ import kotlinx.coroutines.*
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState<Long?>, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
BackHandler(onBack = close)
// TODO derivedStateOf?
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
@@ -70,6 +71,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
.sortedByDescending { it.memberRole },
developerTools,
groupLink,
scrollToItemId,
addMembers = {
scope.launch(Dispatchers.Default) {
setGroupMembers(rhId, groupInfo, chatModel)
@@ -198,6 +200,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
withChats {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
},
@@ -282,6 +287,7 @@ fun ModalData.GroupChatInfoLayout(
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
scrollToItemId: MutableState<Long?>,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
@@ -309,12 +315,12 @@ fun ModalData.GroupChatInfoLayout(
Box {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LazyColumnWithScrollBar(
state = listState,
contentPadding = if (oneHandUI.value) {
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
} else {
PaddingValues(top = topPaddingToContent(false))
},
state = listState
}
) {
item {
Row(
@@ -358,6 +364,13 @@ fun ModalData.GroupChatInfoLayout(
}
val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences
GroupPreferencesButton(prefsTitleId, openPreferences)
if (groupInfo.canModerate) {
GroupReportsButton {
scope.launch {
showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo)
}
}
}
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
@@ -487,6 +500,15 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit)
)
}
@Composable
private fun GroupReportsButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_flag),
stringResource(MR.strings.group_reports_member_reports),
click = onClick
)
}
@Composable
private fun SendReceiptsOption(currentUser: User, state: State<SendReceipts>, onSelected: (SendReceipts) -> Unit) {
val values = remember {
@@ -737,6 +759,7 @@ fun PreviewGroupChatInfoLayout() {
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
scrollToItemId = remember { mutableStateOf(null) },
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
)
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
@@ -65,6 +66,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -83,7 +87,7 @@ fun GroupMemberInfoView(
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = {
withBGApi {
apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (chatModel.getContactChat(it) != null) {
closeAll()
}
@@ -142,6 +146,9 @@ fun GroupMemberInfoView(
withChats {
upsertGroupMember(rhId, groupInfo, mem)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, mem)
}
}.onFailure {
newRole.value = prevValue
}
@@ -157,6 +164,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -171,6 +181,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -188,6 +201,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -203,16 +219,16 @@ fun GroupMemberInfoView(
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
withChats {
upsertGroupMember(
rhId,
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
val copy = mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
withChats {
upsertGroupMember(rhId, groupInfo, copy)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, copy)
}
r
}
@@ -246,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
withChats {
upsertGroupMember(rhId, groupInfo, removedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, removedMember)
}
}
close?.invoke()
}
@@ -753,6 +772,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem
withChats {
upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
}
}
}
}
@@ -786,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke
withChats {
upsertGroupMember(rhId, gInfo, updatedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, gInfo, updatedMember)
}
}
}
@@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
updateGroup(rhId, g)
currentPreferences = preferences
}
withChats {
updateGroup(rhId, g)
}
}
afterSave()
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.*
@@ -0,0 +1,106 @@
package chat.simplex.common.views.chat.group
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
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
import kotlinx.coroutines.flow.*
val LocalContentTag: ProvidableCompositionLocal<MsgContentTag?> = staticCompositionLocalOf { null }
data class GroupReports(
val reportsCount: Int,
val reportsView: Boolean,
) {
val showBar: Boolean = reportsCount > 0 && !reportsView
fun toContentTag(): MsgContentTag? {
if (!reportsView) return null
return MsgContentTag.Report
}
val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report
}
@Composable
private fun GroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>) {
ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {})
}
@Composable
fun GroupReportsAppBar(
groupReports: State<GroupReports>,
close: () -> Unit,
onSearchValueChanged: (String) -> Unit
) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val showSearch = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
close()
} else {
onSearchValueChanged("")
showSearch.value = false
}
}
BackHandler(onBack = onBackClicked)
DefaultAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
fixedTitleText = stringResource(MR.strings.group_reports_member_reports),
onTitleClick = null,
onTop = !oneHandUI.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
IconButton({ showSearch.value = true }) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
)
ItemsReload(groupReports)
}
@Composable
private fun ItemsReload(groupReports: State<GroupReports>) {
LaunchedEffect(Unit) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.drop(1)
.filterNotNull()
.map { chatModel.getChat(it) }
.filterNotNull()
.filter { it.chatInfo is ChatInfo.Group }
.collect { chat ->
reloadItems(chat, groupReports)
}
}
}
suspend fun showGroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>, chatInfo: ChatInfo) {
openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report)
ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close ->
ModalView({}, showAppBar = false) {
val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value
if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) {
GroupReportsView(staleChatId, scrollToItemId)
} else {
LaunchedEffect(Unit) {
close()
}
}
}
}
}
private suspend fun reloadItems(chat: Chat, groupReports: State<GroupReports>) {
val contentFilter = groupReports.value.toContentTag()
apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
}
@@ -27,6 +27,7 @@ import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatJsonLength
@@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.chat.group.LocalContentTag
@Composable
fun CIChatFeatureView(
@@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List<Feature
val m = ChatModel
val fs: ArrayList<FeatureInfo> = arrayListOf()
val icons: MutableSet<PainterBox> = mutableSetOf()
var i = getChatItemIndexOrNull(chatItem)
val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed()
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
if (i != null) {
val reversedChatItems = m.chatItems.asReversed()
while (i < reversedChatItems.size) {
val f = featureInfo(reversedChatItems[i], chatInfo) ?: break
if (!icons.contains(f.icon)) {
@@ -30,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.LocalContentTag
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -481,7 +482,7 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@@ -516,8 +517,8 @@ fun ChatItemView(
DeleteItemMenu()
}
fun mergedGroupEventText(chatItem: ChatItem): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem)
fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List<ChatItem>): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems)
val members = when {
ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
@@ -536,9 +537,9 @@ fun ChatItemView(
}
}
fun eventItemViewText(): AnnotatedString {
fun eventItemViewText(reversedChatItems: List<ChatItem>): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
val t = mergedGroupEventText(cItem)
val t = mergedGroupEventText(cItem, reversedChatItems)
return if (!revealed.value && t != null) {
chatEventText(t, cItem.timestampText)
} else if (memberDisplayName != null) {
@@ -552,12 +553,13 @@ fun ChatItemView(
}
@Composable fun EventItemView() {
CIEventView(eventItemViewText())
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
CIEventView(eventItemViewText(reversedChatItems))
}
@Composable
fun DeletedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
@@ -746,20 +748,21 @@ fun DeleteItemAction(
deleteMessages: (List<Long>) -> Unit,
buttonText: String = stringResource(MR.strings.delete_verb),
) {
val contentTag = LocalContentTag.current
ItemAction(
buttonText,
painterResource(MR.images.ic_delete),
onClick = {
showMenu.value = false
if (!revealed.value) {
val currIndex = chatModel.getChatItemIndexOrNull(cItem)
val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed()
val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems)
val ciCategory = cItem.mergeCategory
if (currIndex != null && ciCategory != null) {
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems)
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf()
val reversedChatItems = chatModel.chatItems.asReversed()
for (i in range) {
itemIds.add(reversedChatItems[i].id)
}
@@ -128,17 +128,6 @@ fun FramedItemView(
Modifier
.background(if (sent) sentColor else receivedColor)
.fillMaxWidth()
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {
if (qi.itemId != null) {
scrollToItem(qi.itemId)
} else {
scrollToQuotedItemFromItem(ci.id)
}
}
)
.onRightClick { showMenu.value = true }
) {
when (qi.content) {
is MsgContent.MCImage -> {
@@ -216,39 +205,66 @@ fun FramedItemView(
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.isReport) {
if (ci.meta.itemDeleted == null) {
FramedItemHeader(
stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
true,
painterResource(MR.images.ic_flag),
iconColor = Color.Red
)
} else {
FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag))
@Composable
fun Header() {
if (ci.isReport) {
if (ci.meta.itemDeleted == null) {
FramedItemHeader(
stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
true,
painterResource(MR.images.ic_flag),
iconColor = Color.Red
)
} else {
val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName)
} else {
stringResource(MR.strings.report_item_archived)
}
FramedItemHeader(text, true, painterResource(MR.images.ic_flag))
}
} else if (ci.meta.itemDeleted != null) {
when (ci.meta.itemDeleted) {
is CIDeleted.Moderated -> {
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
}
is CIDeleted.Blocked -> {
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.BlockedByAdmin -> {
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.Deleted -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
}
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)
}
} else if (ci.meta.itemDeleted != null) {
when (ci.meta.itemDeleted) {
is CIDeleted.Moderated -> {
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
}
is CIDeleted.Blocked -> {
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.BlockedByAdmin -> {
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.Deleted -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
}
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)
}
if (ci.quotedItem != null) {
ciQuoteView(ci.quotedItem)
} else if (ci.meta.itemForwarded != null) {
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
Column(
Modifier
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {
if (ci.quotedItem.itemId != null) {
scrollToItem(ci.quotedItem.itemId)
} else {
scrollToQuotedItemFromItem(ci.id)
}
}
)
.onRightClick { showMenu.value = true }
) {
Header()
ciQuoteView(ci.quotedItem)
}
} else {
Header()
if (ci.meta.itemForwarded != null) {
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
}
}
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
@@ -12,15 +12,17 @@ import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.group.LocalContentTag
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
@@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f, false)) {
MergedMarkedDeletedText(ci, revealed)
MergedMarkedDeletedText(ci, chatInfo, revealed)
}
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
}
@Composable
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State<Boolean>) {
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
val ciCategory = chatItem.mergeCategory
val text = if (!revealed.value && ciCategory != null && i != null) {
val reversedChatItems = ChatModel.chatItems.asReversed()
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
@@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
}
val total = moderated + blocked + blockedByAdmin + deleted
if (total <= 1)
markedDeletedText(chatItem)
markedDeletedText(chatItem, chatInfo)
else if (total == moderated)
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
else if (total == blockedByAdmin)
@@ -77,7 +79,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
else
stringResource(MR.strings.marked_deleted_items_description).format(total)
} else {
markedDeletedText(chatItem)
markedDeletedText(chatItem, chatInfo)
}
Text(
@@ -91,8 +93,14 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
)
}
fun markedDeletedText(cItem: ChatItem): String =
if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived)
fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String =
if (cItem.meta.itemDeleted != null && cItem.isReport) {
if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName)
} else {
generalGetString(MR.strings.report_item_archived)
}
}
else when (cItem.meta.itemDeleted) {
is CIDeleted.Moderated ->
String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName)
@@ -10,24 +10,17 @@ 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.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.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
@@ -209,27 +202,27 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat
suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId)
suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId)
suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag)
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId)
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag)
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) =
apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) =
apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
suspend fun openLoadedChat(chat: Chat) {
withChats {
chatModel.chatItemStatuses.clear()
suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) {
withChats(contentTag) {
chatItemStatuses.clear()
chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
chatModel.chatState.clear()
chatModel.chatStateForContent(contentTag).clear()
}
}
suspend fun apiFindMessages(ch: Chat, search: String) {
withChats {
suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) {
withChats(contentTag) {
chatItems.clearAndNotify()
}
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search)
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
@@ -255,7 +248,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (contact.activeConn != null) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -295,7 +288,7 @@ fun GroupMenuItems(
}
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -316,7 +309,7 @@ fun GroupMenuItems(
@Composable
fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -324,12 +317,12 @@ fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState<Boolean>, showMarkRea
}
@Composable
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun MarkReadChatAction(chat: Chat, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markChatRead(chat, chatModel)
markChatRead(chat)
ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
@@ -566,12 +559,15 @@ private fun InvalidDataView() {
}
}
fun markChatRead(c: Chat, chatModel: ChatModel) {
fun markChatRead(c: Chat) {
var chat = c
withApi {
if (chat.chatStats.unreadCount > 0) {
withChats {
markChatItemsRead(chat.remoteHostId, chat.chatInfo)
markChatItemsRead(chat.remoteHostId, chat.chatInfo.id)
}
withReportsChatsIfOpen {
markChatItemsRead(chat.remoteHostId, chat.chatInfo.id)
}
chatModel.controller.apiChatRead(
chat.remoteHostId,
@@ -612,7 +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)
updateChatTagReadNoContentTag(chat, wasUnread)
}
}
}
@@ -874,7 +870,9 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
}
val updatedChat = chatModel.getChat(chatInfo.id)
if (updatedChat != null) {
chatModel.updateChatTagRead(updatedChat, wasUnread)
withChats {
updateChatTagReadNoContentTag(updatedChat, wasUnread)
}
}
val current = currentState?.value
if (current != null) {
@@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES }
enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES }
sealed class ActiveFilter {
data class PresetTag(val tag: PresetTagKind) : ActiveFilter()
@@ -152,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
if (chatModel.chatId.value != null && !ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
@@ -928,6 +928,8 @@ private val TAG_MIN_HEIGHT = 35.dp
private fun TagsView(searchText: MutableState<TextFieldValue>) {
val userTags = remember { chatModel.userTags }
val presetTags = remember { chatModel.presetTags }
val collapsiblePresetTags = presetTags.filter { presetCanBeCollapsed(it.key) && it.value > 0 }
val alwaysShownPresetTags = presetTags.filter { !presetCanBeCollapsed(it.key) && it.value > 0 }
val activeFilter = remember { chatModel.activeChatTagFilter }
val unreadTags = remember { chatModel.unreadTags }
val rhId = chatModel.remoteHostId()
@@ -935,13 +937,16 @@ private fun TagsView(searchText: MutableState<TextFieldValue>) {
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
TagsRow {
if (presetTags.size > 1) {
if (presetTags.size + userTags.value.size <= 3) {
if (collapsiblePresetTags.size > 1) {
if (collapsiblePresetTags.size + alwaysShownPresetTags.size + userTags.value.size <= 3) {
PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag ->
ExpandedTagFilterView(tag)
}
} else {
CollapsedTagsFilterView(searchText)
alwaysShownPresetTags.forEach { tag ->
ExpandedTagFilterView(tag.key)
}
}
}
@@ -1106,7 +1111,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState<TextFieldValue>) {
val showMenu = remember { mutableStateOf(false) }
val selectedPresetTag = when (val af = activeFilter.value) {
is ActiveFilter.PresetTag -> af.tag
is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null
else -> null
}
@@ -1152,7 +1157,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState<TextFieldValue>) {
)
}
PresetTagKind.entries.forEach { tag ->
if ((presetTags[tag] ?: 0) > 0) {
if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) {
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction)
}
}
@@ -1214,14 +1219,15 @@ fun filteredChats(
private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean =
when (activeFilter) {
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo)
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats)
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 =
fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat.ChatStats): Boolean =
when (tag) {
PresetTagKind.GROUP_REPORTS -> chatStats.reportsCount > 0
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
@@ -1246,6 +1252,7 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean =
private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResource, StringResource> =
when (tag) {
PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports
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
@@ -1253,6 +1260,11 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResou
PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes
}
private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) {
PresetTagKind.GROUP_REPORTS -> false
else -> true
}
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
}
@@ -175,7 +175,7 @@ fun ChatPreviewView(
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci) to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
@@ -322,6 +322,8 @@ fun ChatPreviewView(
} else if (cInfo is ChatInfo.Group) {
if (progressByTimeout) {
progressView()
} else if (chat.chatStats.reportsCount > 0) {
GroupReportsIcon()
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
@@ -469,6 +471,18 @@ fun IncognitoIcon(incognito: Boolean) {
}
}
@Composable
fun GroupReportsIcon() {
Icon(
painterResource(MR.images.ic_flag),
contentDescription = null,
tint = MaterialTheme.colors.error,
modifier = Modifier
.size(21.sp.toDp())
.offset(x = 2.sp.toDp())
)
}
@Composable
private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
return if (groupInfo.membership.memberIncognito)
@@ -32,6 +32,7 @@ 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.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
@@ -72,11 +73,11 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode:
LazyColumnWithScrollBar(
modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier,
state = listState,
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() {
@@ -21,6 +21,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -534,6 +535,11 @@ fun deleteChatDatabaseFilesAndState() {
chats.clear()
popChatCollector.clear()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
}
chatModel.users.clear()
ntfManager.cancelAllNotifications()
@@ -35,7 +35,8 @@ fun DefaultAppBar(
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
val modifier = if (!showSearch) {
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
} else Modifier.imePadding()
} else if (!onTop) Modifier.imePadding()
else Modifier
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
@@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) {
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
}
enum class ModalViewId {
GROUP_REPORTS
}
class ModalManager(private val placement: ModalPlacement? = null) {
private val modalViews = arrayListOf<Triple<Boolean, ModalData, (@Composable ModalData.(close: () -> Unit) -> Unit)>>()
data class ModalViewHolder(
val id: ModalViewId?,
val animated: Boolean,
val data: ModalData,
val modal: @Composable ModalData.(close: () -> Unit) -> Unit
)
private val modalViews = arrayListOf<ModalViewHolder>()
private val _modalCount = mutableStateOf(0)
val modalCount: State<Int> = _modalCount
private val toRemove = mutableSetOf<Int>()
@@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) {
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal { close ->
fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id }
fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id
fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal(id = id) { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal { close ->
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal(id = id) { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
}
}
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showCustomModal")
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
@@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
// to prevent unneeded animation on different situations
val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START)
modalViews.add(Triple(anim, data, modal))
modalViews.add(ModalViewHolder(id, anim, data, modal))
_modalCount.value = modalViews.size - toRemove.size
if (placement == ModalPlacement.CENTER) {
@@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex)
else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) }
}
_modalCount.value = modalViews.size - toRemove.size
@@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) {
@Composable
fun showInView() {
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) {
modalViews.lastOrNull()?.let {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
it.modal(it.data, ::closeModal)
}
}
return
@@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
) {
modalViews.getOrNull(it - 1)?.let {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
it.modal(it.data, ::closeModal)
}
}
// This is needed because if we delete from modalViews immediately on request, animation will be bad
@@ -45,7 +45,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
withChats {
updateGroup(rhId = rhId, groupInfo)
chatItems.clearAndNotify()
chatModel.chatItemStatuses.clear()
chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
}
setGroupMembers(rhId, groupInfo, chatModel)
@@ -40,6 +40,7 @@
<string name="report_item_visibility_submitter">Only you and moderators see it</string>
<string name="report_item_visibility_moderators">Only sender and moderators see it</string>
<string name="report_item_archived">archived report</string>
<string name="report_item_archived_by">archived report by %s</string>
<string name="blocked_item_description">blocked</string>
<string name="blocked_by_admin_item_description">blocked by admin</string>
<string name="blocked_items_description">%d messages blocked</string>
@@ -440,8 +441,13 @@
<string name="chat_list_groups">Groups</string>
<string name="chat_list_businesses">Businesses</string>
<string name="chat_list_notes">Notes</string>
<string name="chat_list_group_reports">Reports</string>
<string name="chat_list_all">All</string>
<string name="chat_list_add_list">Add list</string>
<string name="group_reports_active_one">1 report</string>
<string name="group_reports_active">%d reports</string>
<string name="group_reports_member_reports">Member reports</string>
<string name="group_reports_archived_member_reports">Archived member reports</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</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="M204-123.5v-672h337l19.13 85.5H796v363H545.5l-19-84.5h-265v308H204Z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme
@@ -61,6 +62,9 @@ fun showApp() {
chatModel.chatId.value = null
chatItems.clearAndNotify()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
}
}
}
chatModel.activeCall.value?.let {
@@ -36,6 +36,7 @@ actual fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
@@ -93,7 +94,7 @@ actual fun LazyColumnWithScrollBar(
val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier) else modifier
Box(Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).nestedScroll(connection)) {
LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar)
}
}
@@ -108,6 +109,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
content: LazyListScope.() -> Unit
) {
@@ -135,7 +137,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
val scrollBarDraggingState = remember { mutableStateOf(false) }
Box {
LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar)
}
}
@@ -147,11 +149,13 @@ private fun ScrollBar(
scrollJob: MutableState<Job>,
scrollBarDraggingState: MutableState<Boolean>,
additionalBarHeight: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val topBarPadding = if (additionalTopBar.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp
val padding = if (additionalBarHeight != null) {
PaddingValues(top = if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value)
PaddingValues(top = topBarPadding + if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value)
} else if (reverseLayout) {
PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier)
} else {