From fefddb3b5a6240df853a2bdecf8b767bfbc4ad91 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 28 Feb 2025 21:45:24 +0700 Subject: [PATCH 1/5] ios: go to forwarded item or search result (#5679) * ios: go to forwarded item or search result * react on touch * changes --- apps/ios/Shared/Model/ChatModel.swift | 17 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 24 ++- .../Shared/Views/Chat/ChatScrollHelpers.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 182 +++++++++++++----- .../Chat/ComposeMessage/ComposeView.swift | 4 + .../ComposeMessage/NativeTextEditor.swift | 9 +- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- .../Shared/Views/Chat/EndlessScrollView.swift | 24 ++- apps/ios/Shared/Views/TerminalView.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 14 ++ 11 files changed, 228 insertions(+), 64 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 9171bf5073..a1c5a55c3b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -97,6 +97,22 @@ class ItemsModel: ObservableObject { } } } + + func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { + navigationTimeoutTask?.cancel() + loadChatTask?.cancel() + loadChatTask = Task { + // try? await Task.sleep(nanoseconds: 1000_000000) + await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + if !Task.isCancelled { + await MainActor.run { + if openAroundItemId == nil { + ChatModel.shared.chatId = chatId + } + } + } + } + } } class ChatTagsModel: ObservableObject { @@ -259,6 +275,7 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var openAroundItemId: ChatItem.ID? = nil var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0ef2a87aa7..354f8243fc 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async { +func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { let m = ChatModel.shared let im = ItemsModel.shared m.chatItemStatuses = [:] @@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn ItemsModel.shared.chatItemsChangesListener.cleared() } } - await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 }) + await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) } func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 4253a4f1e4..add28cd7f9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -16,6 +16,7 @@ func apiLoadMessages( _ pagination: ChatPagination, _ chatState: ActiveChatState, _ search: String = "", + _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } ) async { let chat: Chat @@ -32,7 +33,8 @@ func apiLoadMessages( // 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 let paginationIsInitial = switch pagination { case .initial: true; default: false } let paginationIsLast = switch pagination { case .last: true; default: false } - if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled { + // When openAroundItemId is provided, chatId can be different too + if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled { return } @@ -102,8 +104,13 @@ func apiLoadMessages( } } case .around: - newItems.append(contentsOf: oldItems) - let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + let newSplits: [Int64] + if openAroundItemId == nil { + newItems.append(contentsOf: oldItems) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + } else { + newSplits = [] + } // currently, items will always be added on top, which is index 0 newItems.insert(contentsOf: chat.chatItems, at: 0) let newReversed: [ChatItem] = newItems.reversed() @@ -114,8 +121,15 @@ func apiLoadMessages( chatState.totalAfter = navInfo.afterTotal chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadAfter = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if let openAroundItemId { + chatState.unreadAfterNewestLoaded = navInfo.afterUnread + ChatModel.shared.openAroundItemId = openAroundItemId + ChatModel.shared.chatId = chatId + } else { + // no need to set it, count will be wrong + // chatState.unreadAfterNewestLoaded = navInfo.afterUnread + } } case .last: newItems.append(contentsOf: oldItems) diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index 094c6e13e1..873f24d5c3 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -46,7 +46,7 @@ func preloadIfNeeded( loadItems: @escaping (Bool, ChatPagination) async -> Bool ) { let state = PreloadState.shared - guard !listState.isScrolling, + guard !listState.isScrolling && !listState.isAnimatedScrolling, state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count, !state.preloading, listState.totalItemsCount > 0 diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index f3bfa294b4..90c9629352 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -29,6 +29,7 @@ struct ChatView: View { @State private var composeState = ComposeState() @State private var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -37,7 +38,7 @@ struct ChatView: View { @State private var requestedTopScroll = false @State private var loadingBottomItems = false @State private var requestedBottomScroll = false - @State private var searchMode = false + @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @@ -54,11 +55,8 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false - @State private var updateMergedItemsTask: Task? = nil @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() - private let useItemsUpdateTask = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -101,6 +99,7 @@ struct ChatView: View { chat: chat, composeState: $composeState, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange ) .disabled(!cInfo.sendMsgEnabled) @@ -131,7 +130,7 @@ struct ChatView: View { } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { - if searchMode { searchToolbar() } + if showSearch { searchToolbar() } Divider() } .background(ToolbarMaterial.material(toolbarMaterial)) @@ -232,15 +231,45 @@ struct ChatView: View { scrollView.listState.onUpdateListener = onChatItemsUpdated initChatView() theme = buildTheme() - if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + + if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { scrollView.scrollToBottom() } + if chatModel.openAroundItemId != nil { + chatModel.openAroundItemId = nil + } } else { dismiss() } } + .onChange(of: chatModel.openAroundItemId) { openAround in + if let openAround { + closeSearch() + mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) + chatModel.openAroundItemId = nil + + if let index = mergedItems.boxedValue.indexInParentItems[openAround] { + scrollView.scrollToItem(index) + } + + // this may already being loading because of changed chat id (see .onChange(of: chat.id) + if !loadingBottomItems { + loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + allowLoadMoreItems = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + allowLoadMoreItems = true + } + } + } + } .onDisappear { VideoPlayerView.players.removeAll() stopAudioPlayer() @@ -429,9 +458,13 @@ struct ChatView: View { index = mergedItems.boxedValue.indexInParentItems[itemId] } if let index { - await MainActor.run { animatedScrollingInProgress = true } - await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) - await MainActor.run { animatedScrollingInProgress = false } + closeKeyboardAndRun { + Task { + await MainActor.run { animatedScrollingInProgress = true } + await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await MainActor.run { animatedScrollingInProgress = false } + } + } } } catch { logger.error("Error scrolling to item: \(error)") @@ -460,9 +493,7 @@ struct ChatView: View { .cornerRadius(10.0) Button ("Cancel") { - searchText = "" - searchMode = false - searchFocussed = false + closeSearch() Task { await loadChat(chat: chat) } } } @@ -517,7 +548,9 @@ struct ChatView: View { showChatInfoSheet: $showChatInfoSheet, revealedItems: $revealedItems, selectedChatItems: $selectedChatItems, - forwardedChatItems: $forwardedChatItems + forwardedChatItems: $forwardedChatItems, + searchText: $searchText, + closeKeyboardAndRun: closeKeyboardAndRun ) // crashes on Cell size calculation without this line .environmentObject(ChatModel.shared) @@ -535,25 +568,10 @@ struct ChatView: View { } } .onChange(of: im.reversedChatItems) { items in - updateMergedItemsTask?.cancel() - if useItemsUpdateTask { - updateMergedItemsTask = Task { - let items = MergedItems.create(items, revealedItems, im.chatState) - if Task.isCancelled { - return - } - await MainActor.run { - mergedItems.boxedValue = items - scrollView.updateItems(mergedItems.boxedValue.items) - } - } - } else { - mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) - scrollView.updateItems(mergedItems.boxedValue.items) - } + mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) + scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: revealedItems) { revealed in - updateMergedItemsTask?.cancel() mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) scrollView.updateItems(mergedItems.boxedValue.items) } @@ -567,6 +585,7 @@ struct ChatView: View { .padding(.vertical, -100) .onTapGesture { hideKeyboard() } .onChange(of: searchText) { s in + guard showSearch else { return } Task { await loadChat(chat: chat, search: s) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) @@ -880,11 +899,29 @@ struct ChatView: View { } private func focusSearch() { - searchMode = true + showSearch = true searchFocussed = true searchText = "" } + private func closeSearch() { + showSearch = false + searchText = "" + searchFocussed = false + } + + private func closeKeyboardAndRun(_ action: @escaping () -> Void) { + var delay: TimeInterval = 0 + if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch { + delay = 0.5 + closeSearch() + hideKeyboard() + } + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + action() + } + } + private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { @@ -1079,6 +1116,7 @@ struct ChatView: View { pagination, im.chatState, searchText, + nil, { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } ) return true @@ -1136,10 +1174,14 @@ struct ChatView: View { @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @State private var msgWidth: CGFloat = 0 + @State private var touchInProgress: Bool = false @Binding var selectedChatItems: Set? @Binding var forwardedChatItems: [ChatItem] + @Binding var searchText: String + var closeKeyboardAndRun: (@escaping () -> Void) -> Void + @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @@ -1257,6 +1299,16 @@ struct ChatView: View { markedRead = false } .actionSheet(item: $actionSheet) { $0.actionSheet } + // skip updating struct on touch if no need to show GoTo button + .if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) { + // long press listener steals taps from top-level listener, so repeating it's logic here as well + $0.onTapGesture { + hideKeyboard() + } + .onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in + touchInProgress = pressing + }) + } } private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { @@ -1290,6 +1342,11 @@ struct ChatView: View { } } + private var searchIsNotBlank: Bool { + get { + searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } @available(iOS 16.0, *) struct MemberLayout: Layout { @@ -1459,18 +1516,26 @@ struct ChatView: View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView( - chat: chat, - chatItem: ci, - scrollToItemId: scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + chatItem: ci, + scrollToItemId: scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } + } if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { chatItemReactions(ci) .padding(.bottom, 4) @@ -2133,6 +2198,37 @@ struct ChatView: View { } } + func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { + Button { + onClick() + } label: { + Image(systemName: image) + .resizable() + .frame(width: 13, height: 13) + .padding([alignStart ? .trailing : .leading], 10) + .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) + } + } + + @ViewBuilder + func goToItemButton(_ alignStart: Bool) -> some View { + let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId + if searchIsNotBlank { + goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id) + } + } + } else if let chatTypeApiIdMsgId { + goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) { + closeKeyboardAndRun { + let (chatType, apiId, msgId) = chatTypeApiIdMsgId + ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } + } + } + } + private struct SelectedChatItem: View { @EnvironmentObject var theme: AppTheme var ciId: Int64 diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 80c5973211..6ded9cae72 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -325,6 +325,7 @@ struct ComposeView: View { @ObservedObject var chat: Chat @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date @Binding var selectedRange: NSRange @State var linkUrl: URL? = nil @@ -434,6 +435,7 @@ struct ComposeView: View { timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate, sendButtonColor: chat.chatInfo.incognito ? .indigo.opacity(colorScheme == .dark ? 1 : 0.7) : theme.colors.primary @@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) @@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider { chat: chat, composeState: $composeState, keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now), selectedRange: $selectedRange ) .environmentObject(ChatModel()) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 3eeb7ba7f5..d809fd7b76 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool + @Binding var lastUnfocusedDate: Date @Binding var placeholder: String? @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void @@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable { onImagesAdded(images) } } - field.setOnFocusChangedListener { focused = $0 } + field.setOnFocusChangedListener { + focused = $0 + if !focused { + lastUnfocusedDate = .now + } + } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.setPlaceholderView() @@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), + lastUnfocusedDate: Binding.constant(.now), placeholder: Binding.constant("Placeholder"), selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 9013bb6a88..30767d66ec 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,6 +32,7 @@ struct SendMessageView: View { @State private var holdingVMR = false @Namespace var namespace @Binding var keyboardVisible: Bool + @Binding var keyboardHiddenDate: Date var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = NativeTextEditor.minHeight @State private var teFont: Font = .body @@ -61,6 +62,7 @@ struct SendMessageView: View { disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, + lastUnfocusedDate: $keyboardHiddenDate, placeholder: Binding(get: { composeState.placeholder }, set: { _ in }), selectedRange: $selectedRange, onImagesAdded: onMediaAdded @@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRange, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } VStack { @@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider { selectedRange: $selectedRangeEditing, sendMessage: { _ in }, onMediaAdded: { _ in }, - keyboardVisible: Binding.constant(true) + keyboardVisible: Binding.constant(true), + keyboardHiddenDate: Binding.constant(Date.now) ) } } diff --git a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift index 81345e4a03..3d4fb381a6 100644 --- a/apps/ios/Shared/Views/Chat/EndlessScrollView.swift +++ b/apps/ios/Shared/Views/Chat/EndlessScrollView.swift @@ -171,8 +171,10 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu visibleItems.last?.index ?? 0 } - /// Whether there is scroll to item in progress or not + /// Whether there is a non-animated scroll to item in progress or not var isScrolling: Bool = false + /// Whether there is an animated scroll to item in progress or not + var isAnimatedScrolling: Bool = false override init() { super.init() @@ -349,11 +351,11 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu if // there is auto scroll in progress and the first item has a higher offset than bottom part // of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to // re-make the first visible item - (listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || + (listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) || // the fist visible item previously is hidden now, remove it and move on !isVisible(vis.view) { let newIndex: Int - if listState.isScrolling { + if listState.isAnimatedScrolling { // skip many items to make the scrolling take less time var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0 // if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled @@ -471,7 +473,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu } func scrollToItem(_ index: Int, top: Bool = true) { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } if bounds.height == 0 { @@ -498,7 +500,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 @@ -522,18 +524,22 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu break } contentOffset = CGPointMake(contentOffset.x, adjustedOffset) + adaptItems(listState.items, false) + snapToContent(animated: false) i += 1 } + adaptItems(listState.items, false) + snapToContent(animated: false) estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true) } func scrollToItemAnimated(_ index: Int, top: Bool = true) async { - if index >= listState.items.count || listState.isScrolling { + if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling { return } - listState.isScrolling = true + listState.isAnimatedScrolling = true defer { - listState.isScrolling = false + listState.isAnimatedScrolling = false } var adjustedOffset = self.contentOffset.y var i = 0 @@ -543,7 +549,7 @@ class EndlessScrollView: UIScrollView, UIScrollViewDelegate, UIGestu //let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier) var stepSlowdownMultiplier: CGFloat = 1 - while true { + while i < 200 { let up = index > listState.firstVisibleItemIndex if upPrev != up { stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5 diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 9885811051..2b58abef65 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -20,6 +20,7 @@ struct TerminalView: View { @State var composeState: ComposeState = ComposeState() @State var selectedRange = NSRange() @State private var keyboardVisible = false + @State private var keyboardHiddenDate = Date.now @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) @State private var terminalItem: TerminalItem? @State private var scrolled = false @@ -101,7 +102,8 @@ struct TerminalView: View { sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + keyboardHiddenDate: $keyboardHiddenDate ) .padding(.horizontal, 12) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 169a7c21de..a601e60d5f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3261,6 +3261,20 @@ public enum CIForwardedFrom: Decodable, Hashable { } } + public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? { + switch self { + case .unknown: nil + case let .contact(_, _, contactId, msgId): + if let contactId { + (ChatType.direct, contactId, msgId) + } else { nil } + case let .group(_, _, groupId, msgId): + if let groupId { + (ChatType.group, groupId, msgId) + } else { nil } + } + } + public func text(_ chatType: ChatType) -> LocalizedStringKey { chatType == .local ? (chatName == "" ? "saved" : "saved from \(chatName)") From dce85021650dcaddf156a10f81a91e44f0e86805 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:57:41 +0700 Subject: [PATCH 2/5] android: allow to enter passphrase in case of error reading it (#5683) * android: allow to enter passphrase in case of error reading it * change * refactor * strings --------- Co-authored-by: Evgeny Poberezkin --- .../common/platform/Cryptor.android.kt | 29 ++++++++++++++++--- .../commonMain/resources/MR/base/strings.xml | 5 +++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index dc6c53ecbc..d9a5fb59e3 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -30,10 +30,31 @@ internal class Cryptor: CryptorInterface { } return null } - val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull() + + try { + val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) + return String(cipher.doFinal(data)) + } catch (e: Throwable) { + Log.e(TAG, "cipher.init: ${e.stackTraceToString()}") + val randomPassphrase = appPreferences.initialRandomDBPassphrase.get() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_reading_passphrase), + text = generalGetString(if (randomPassphrase) { + MR.strings.restore_passphrase_can_not_be_read_desc + } else { + MR.strings.restore_passphrase_can_not_be_read_enter_manually_desc + } + ) + .plus("\n\n").plus(e.stackTraceToString()) + ) + if (randomPassphrase) { + // do not allow to override initial random passphrase in case of such error + throw e + } + return null + } } override fun encryptText(text: String, alias: String): Pair { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index aa0f49ee1d..83f085fe3a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1471,6 +1471,7 @@ Wrong database passphrase + Error reading database passphrase Encrypted database Database error Keychain error @@ -1492,7 +1493,9 @@ Please enter the previous password after restoring database backup. This action can not be undone. Restore Restore database error - Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please, contact developers. + Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app\'s data using a backup tool. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. + Passphrase in Keystore can\'t be read, please enter it manually. This may have happened after system update incompatible with the app. If it\'s not the case, please contact developers. Database upgrade Database downgrade Incompatible database version From dcea008fb959972ff9bdf5d555133b8c141fd9ea Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 28 Feb 2025 22:43:39 +0400 Subject: [PATCH 3/5] core: batch apis - remove, block, change role of members (#5674) * core: core: batch remove members * order * foldr * list * style * batch block * change role * test * if --- src/Simplex/Chat/Controller.hs | 12 +- src/Simplex/Chat/Library/Commands.hs | 247 ++++++++++++------ src/Simplex/Chat/Library/Internal.hs | 13 +- src/Simplex/Chat/Library/Subscriber.hs | 14 +- src/Simplex/Chat/Messages/CIContent/Events.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 7 +- src/Simplex/Chat/View.hs | 26 +- tests/Bots/DirectoryTests.hs | 22 +- tests/ChatTests/Groups.hs | 135 +++++++--- tests/ChatTests/Profiles.hs | 2 +- 10 files changed, 320 insertions(+), 162 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e64553af98..71b57c72b2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -366,9 +366,9 @@ data ChatCommand | ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId} | APIAddMember GroupId ContactId GroupMemberRole | APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter} - | APIMemberRole GroupId GroupMemberId GroupMemberRole - | APIBlockMemberForAll GroupId GroupMemberId Bool - | APIRemoveMember GroupId GroupMemberId + | APIMembersRole GroupId (NonEmpty GroupMemberId) GroupMemberRole + | APIBlockMembersForAll GroupId (NonEmpty GroupMemberId) Bool + | APIRemoveMembers GroupId (NonEmpty GroupMemberId) | APILeaveGroup GroupId | APIListMembers GroupId | APIUpdateGroupProfile GroupId GroupProfile @@ -673,7 +673,7 @@ data ChatResponse | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} - | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus @@ -758,9 +758,9 @@ data ChatResponse | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMemberBlockedForAll {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, blocked :: Bool} - | CRMemberBlockedForAllUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, blocked :: Bool} + | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, memberContact :: Maybe Contact} | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 624f6a6dc1..f44dca9026 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2023,75 +2023,170 @@ processChatCommand' vr = \case updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` (toView . CRChatError (Just user)) pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct - APIMemberRole groupId memberId memRole -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - if memberId == groupMemberId' membership - then changeMemberRole user gInfo members membership $ SGEUserRole memRole - else case find ((== memberId) . groupMemberId') members of - Just m -> changeMemberRole user gInfo members m $ SGEMemberRole memberId (fromLocalProfile $ memberProfile m) memRole - _ -> throwChatError CEGroupMemberNotFound + APIMembersRole groupId memberIds newRole -> withUser $ \user -> + withGroupLock "memberRole" groupId . procCmd $ do + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwChatError $ CECommandError "can't change role for self" + let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin) = selectMembers members + when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ + throwChatError $ CECommandError "can't change role of multiple members when admins selected, or new role is admin" + assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) + (errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems + (errs2, changed2, acis) <- changeRoleCurrentMems user g currentMems + unless (null acis) $ toView $ CRNewChatItems user acis + let errs = errs1 <> errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole} -- same order is not guaranteed where - changeMemberRole user gInfo members m gEvent = do - let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m - assertUserGroupRole gInfo $ maximum ([GRAdmin, mRole, memRole] :: [GroupMemberRole]) - withGroupLock "memberRole" groupId . procCmd $ do - unless (mRole == memRole) $ do - withFastStore' $ \db -> updateGroupMemberRole db user m memRole - case mStatus of - GSMemInvited -> do - withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user $ groupMemberId' m)) >>= \case - (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq - _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} - APIBlockMemberForAll groupId memberId blocked -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId - when (memberId == groupMemberId' membership) $ throwChatError $ CECommandError "can't block/unblock self" - case splitMember memberId members of - Nothing -> throwChatError $ CEException "expected to find a single blocked member" - Just (bm, remainingMembers) -> do - let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm - -- TODO GRModerator when most users migrate - assertUserGroupRole gInfo $ max GRAdmin bmRole - when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" - withGroupLock "blockForAll" groupId . procCmd $ do - let mrs = if blocked then MRSBlocked else MRSUnrestricted - event = XGrpMemRestrict bmMemberId MemberRestrictions {restriction = mrs} - msg <- sendGroupMessage' user gInfo remainingMembers event - let ciContent = CISndGroupEvent $ SGEMemberBlocked memberId (fromLocalProfile bmp) blocked - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - bm' <- withFastStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId memberId mrs - getGroupMember db vr user groupId memberId - toggleNtf user bm' (not blocked) - pure CRMemberBlockedForAllUser {user, groupInfo = gInfo, member = bm', blocked} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in + if + | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin') + | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin') + | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin') + | otherwise = (invited, current, unchanged, maxRole, anyAdmin) + changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) + changeRoleInvitedMems user gInfo memsToChange = do + -- not batched, as we need to send different invitations to different connections anyway + mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left) + pure $ partitionEithers mems_ + where + changeRole :: GroupMember -> CM GroupMember + changeRole m@GroupMember {groupMemberId, memberContactId, localDisplayName = cName} = do + withFastStore (\db -> (,) <$> mapM (getContact db vr user) memberContactId <*> liftIO (getMemberInvitation db user groupMemberId)) >>= \case + (Just ct, Just cReq) -> do + sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = newRole} cReq + withFastStore' $ \db -> updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName + changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of + Nothing -> pure ([], [], []) + Just memsToChange' -> do + let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange' + (msgs_, _gsr) <- sendGroupMessages user gInfo members events + let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False + when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" + (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + pure (errs, changed, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberRole groupMemberId (fromLocalProfile memberProfile) newRole + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + updMember db m = do + updateGroupMemberRole db user m newRole + pure (m :: GroupMember) {memberRole = newRole} + APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> + withGroupLock "blockForAll" groupId . procCmd $ do + Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId + when (selfSelected gInfo) $ throwChatError $ CECommandError "can't block/unblock self" + let (blockMems, remainingMems, maxRole, anyAdmin) = selectMembers members + when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't block/unblock multiple members when admins selected" + assertUserGroupRole gInfo $ max GRModerator maxRole + blockMembers user gInfo blockMems remainingMems where - splitMember mId ms = case break ((== mId) . groupMemberId') ms of - (_, []) -> Nothing - (ms1, bm : ms2) -> Just (bm, ms1 <> ms2) - APIRemoveMember groupId memberId -> withUser $ \user -> do - Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId - case find ((== memberId) . groupMemberId') members of - Nothing -> throwChatError CEGroupMemberNotFound - Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do - assertUserGroupRole gInfo $ max GRAdmin mRole - withGroupLock "removeMember" groupId . procCmd $ do - case mStatus of - GSMemInvited -> do - deleteMemberConnection user m - withFastStore' $ \db -> deleteGroupMember db user m - _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId - ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) - toView $ CRNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci] - deleteMemberConnection' user m True - -- undeleted "member connected" chat item will prevent deletion of member record - deleteOrUpdateMemberRecord user m - pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} + selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberRole} (block, remaining, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in (m : block, remaining, maxRole', anyAdmin') + | otherwise = (block, m : remaining, maxRole, anyAdmin) + blockMembers :: User -> GroupInfo -> [GroupMember] -> [GroupMember] -> CM ChatResponse + blockMembers user gInfo blockMems remainingMems = case L.nonEmpty blockMems of + Nothing -> throwChatError $ CECommandError "no members to block/unblock" + Just blockMems' -> do + let mrs = if blockFlag then MRSBlocked else MRSUnrestricted + events = L.map (\GroupMember {memberId} -> XGrpMemRestrict memberId MemberRestrictions {restriction = mrs}) blockMems' + (msgs_, _gsr) <- sendGroupMessages user gInfo remainingMems events + let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False + when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + unless (null acis) $ toView $ CRNewChatItems user acis + (errs, blocked) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updateGroupMemberBlocked db user gInfo mrs) blockMems) + unless (null errs) $ toView $ CRChatErrors (Just user) errs + -- TODO not batched - requires agent batch api + forM_ blocked $ \m -> toggleNtf user m (not blockFlag) + pure CRMembersBlockedForAllUser {user, groupInfo = gInfo, members = blocked, blocked = blockFlag} + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + APIRemoveMembers groupId memberIds -> withUser $ \user -> + withGroupLock "removeMembers" groupId . procCmd $ do + g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + let (invitedMems, currentMems, maxRole, anyAdmin) = selectMembers members + when (length invitedMems + length currentMems /= length memberIds) $ throwChatError CEGroupMemberNotFound + when (length memberIds > 1 && anyAdmin) $ throwChatError $ CECommandError "can't remove multiple members when admins selected" + assertUserGroupRole gInfo $ max GRAdmin maxRole + (errs1, deleted1) <- deleteInvitedMems user invitedMems + (errs2, deleted2, acis) <- deleteCurrentMems user g currentMems + unless (null acis) $ toView $ CRNewChatItems user acis + let errs = errs1 <> errs2 + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure $ CRUserDeletedMembers user gInfo (deleted1 <> deleted2) -- same order is not guaranteed + where + selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], GroupMemberRole, Bool) + selectMembers = foldr' addMember ([], [], GRObserver, False) + where + addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, maxRole, anyAdmin) + | groupMemberId `elem` memberIds = + let maxRole' = max maxRole memberRole + anyAdmin' = anyAdmin || memberRole >= GRAdmin + in + if memberStatus == GSMemInvited + then (m : invited, current, maxRole', anyAdmin') + else (invited, m : current, maxRole', anyAdmin') + | otherwise = (invited, current, maxRole, anyAdmin) + deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember]) + deleteInvitedMems user memsToDelete = do + deleteMembersConnections user memsToDelete + lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) + where + delMember db m = do + deleteGroupMember db user m + pure m {memberStatus = GSMemRemoved} + deleteCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem]) + deleteCurrentMems user (Group gInfo members) memsToDelete = case L.nonEmpty memsToDelete of + Nothing -> pure ([], [], []) + Just memsToDelete' -> do + let events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId) memsToDelete' + (msgs_, _gsr) <- sendGroupMessages user gInfo members events + let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData Nothing False + when (length cis_ /= length memsToDelete) $ logError "deleteCurrentMems: memsToDelete and cis_ length mismatch" + deleteMembersConnections' user memsToDelete True + (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) + let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo)) $ rights cis_ + pure (errs, deleted, acis) + where + sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c + sndItemData GroupMember {groupMemberId, memberProfile} msg = + let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + ts = ciContentTexts content + in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + delMember db m = do + deleteOrUpdateMemberRecordIO db user m + pure m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo @@ -2114,18 +2209,14 @@ processChatCommand' vr = \case JoinGroup gName enableNtfs -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIJoinGroup groupId enableNtfs - MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMemberRole gId gMemberId memRole - BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMemberForAll gId gMemberId blocked + MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole + BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked RemoveMembers gName gMemberNames -> withUser $ \user -> do (gId, gMemberIds) <- withStore $ \db -> do gId <- getGroupIdByName db user gName gMemberIds <- forM gMemberNames $ getGroupMemberIdByName db user gId pure (gId, gMemberIds) - rs <- forM (L.zip (L.fromList [1..]) gMemberIds) $ \(i, memId) -> do - r <- processChatCommand (APIRemoveMember gId memId) - when (i < length gMemberIds) $ toView r - pure r - pure $ L.last rs + processChatCommand $ APIRemoveMembers gId gMemberIds LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APILeaveGroup groupId @@ -3090,7 +3181,7 @@ processChatCommand' vr = \case (msgs_, gsr) <- sendGroupMessages user gInfo ms chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) cis_ <- saveSndChatItems user (CDGroupSnd gInfo) itemsData timed_ live - when (length itemsData /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" + when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ processSendErrs user r @@ -3795,9 +3886,9 @@ chatCommandP = "/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP), "/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole), "/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI - "/_member role #" *> (APIMemberRole <$> A.decimal <* A.space <*> A.decimal <*> memberRole), - "/_block #" *> (APIBlockMemberForAll <$> A.decimal <* A.space <*> A.decimal <* A.space <* "blocked=" <*> onOffP), - "/_remove #" *> (APIRemoveMember <$> A.decimal <* A.space <*> A.decimal), + "/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole), + "/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* A.space <* "blocked=" <*> onOffP), + "/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP), "/_leave #" *> (APILeaveGroup <$> A.decimal), "/_members #" *> (APIListMembers <$> A.decimal), "/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 4a62c4ccb6..eba1bf169f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1251,11 +1251,14 @@ deleteMemberConnection' user GroupMember {activeConn} waitDelivery = do withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: User -> GroupMember -> CM () -deleteOrUpdateMemberRecord user@User {userId} member = - withStore' $ \db -> - checkGroupMemberHasItems db user member >>= \case - Just _ -> updateGroupMemberStatus db userId member GSMemRemoved - Nothing -> deleteGroupMember db user member +deleteOrUpdateMemberRecord user member = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user member + +deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupMember -> IO () +deleteOrUpdateMemberRecordIO db user@User {userId} member = + checkGroupMemberHasItems db user member >>= \case + Just _ -> updateGroupMemberStatus db userId member GSMemRemoved + Nothing -> deleteGroupMember db user member sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages user ct events = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 44ad4ccc85..087d49e49a 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2608,7 +2608,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM () xGrpMemRestrict - gInfo@GroupInfo {groupId, membership = GroupMember {memberId = membershipMemId}} + gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}} m@GroupMember {memberRole = senderRole} memId MemberRestrictions {restriction} @@ -2619,10 +2619,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = messageError "x.grp.mem.restrict: admin blocks you" | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right bm@GroupMember {groupMemberId = bmId, memberRole, memberProfile = bmp} + Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} + | blockedByAdmin == mrsBlocked restriction -> pure () | senderRole < GRModerator || senderRole < memberRole -> messageError "x.grp.mem.restrict with insufficient member permissions" | otherwise -> do - bm' <- setMemberBlocked bmId + bm' <- setMemberBlocked bm toggleNtf user bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo m) msg brokerTs ciContent @@ -2630,14 +2631,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView CRMemberBlockedForAll {user, groupInfo = gInfo, byMember = m, member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId - bm' <- setMemberBlocked $ groupMemberId' bm + bm' <- setMemberBlocked bm toView $ CRUnknownMemberBlocked user gInfo m bm' Left e -> throwError $ ChatErrorStore e where - setMemberBlocked bmId = - withStore $ \db -> do - liftIO $ updateGroupMemberBlocked db user groupId bmId restriction - getGroupMember db vr user groupId bmId + setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 74f7d94399..054530e06f 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -33,9 +33,9 @@ data RcvGroupEvent data SndGroupEvent = SGEMemberRole {groupMemberId :: GroupMemberId, profile :: Profile, role :: GroupMemberRole} - | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMemberBlockedForAllUser + | SGEMemberBlocked {groupMemberId :: GroupMemberId, profile :: Profile, blocked :: Bool} -- CRMembersBlockedForAllUser | SGEUserRole {role :: GroupMemberRole} - | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMember + | SGEMemberDeleted {groupMemberId :: GroupMemberId, profile :: Profile} -- CRUserDeletedMembers | SGEUserLeft -- CRLeftMemberUser | SGEGroupUpdated {groupProfile :: GroupProfile} -- CRGroupUpdated deriving (Show) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 67722ebd0f..a1ce3ab269 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2062,8 +2062,8 @@ updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {sh |] (BI showMessages, currentTs, userId, gId, gMemberId) -updateGroupMemberBlocked :: DB.Connection -> User -> GroupId -> GroupMemberId -> MemberRestrictionStatus -> IO () -updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do +updateGroupMemberBlocked :: DB.Connection -> User -> GroupInfo -> MemberRestrictionStatus -> GroupMember -> IO GroupMember +updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime DB.execute db @@ -2072,7 +2072,8 @@ updateGroupMemberBlocked db User {userId} gId gMemberId memberBlocked = do SET member_restriction = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND group_member_id = ? |] - (memberBlocked, currentTs, userId, gId, gMemberId) + (mrs, currentTs, userId, groupId, groupMemberId) + pure m {blockedByAdmin = mrsBlocked mrs} getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a8c2d215a8..7a20cb2fb0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -220,7 +220,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRUserDeletedMembers u g members -> case members of + [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] @@ -301,9 +303,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] CRConnectedToGroupMember u g m _ -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' - CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r' + CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMemberBlockedForAll u g by m blocked -> ttyUser u $ viewMemberBlockedForAll g by m blocked - CRMemberBlockedForAllUser u g m blocked -> ttyUser u $ viewMemberBlockedForAllUser g m blocked + CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] @@ -1109,21 +1111,19 @@ viewMemberRoleChanged g@GroupInfo {membership} by m r r' memId = groupMemberId' m view s = [ttyGroup' g <> ": " <> ttyMember by <> " changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] -viewMemberRoleUserChanged :: GroupInfo -> GroupMember -> GroupMemberRole -> GroupMemberRole -> [StyledString] -viewMemberRoleUserChanged g@GroupInfo {membership} m r r' - | r == r' = [ttyGroup' g <> ": member role did not change"] - | groupMemberId' membership == groupMemberId' m = view "your role" - | otherwise = view $ "the role of " <> ttyMember m - where - view s = [ttyGroup' g <> ": you changed " <> s <> " from " <> showRole r <> " to " <> showRole r'] +viewMemberRoleUserChanged :: GroupInfo -> [GroupMember] -> GroupMemberRole -> [StyledString] +viewMemberRoleUserChanged g members r = case members of + [m] -> [ttyGroup' g <> ": you changed the role of " <> ttyMember m <> " to " <> showRole r] + mems' -> [ttyGroup' g <> ": you changed the role of " <> sShow (length mems') <> " members to " <> showRole r] viewMemberBlockedForAll :: GroupInfo -> GroupMember -> GroupMember -> Bool -> [StyledString] viewMemberBlockedForAll g by m blocked = [ttyGroup' g <> ": " <> ttyMember by <> " " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] -viewMemberBlockedForAllUser :: GroupInfo -> GroupMember -> Bool -> [StyledString] -viewMemberBlockedForAllUser g m blocked = - [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] +viewMembersBlockedForAllUser :: GroupInfo -> [GroupMember] -> Bool -> [StyledString] +viewMembersBlockedForAllUser g members blocked = case members of + [m] -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> ttyMember m] + mems' -> [ttyGroup' g <> ": you " <> (if blocked then "blocked" else "unblocked") <> " " <> sShow (length mems') <> " members"] showRole :: GroupMemberRole -> StyledString showRole = plain . strEncode diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 5bdc379cbf..2a9ad30dd2 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -122,7 +122,7 @@ testDirectoryService ps = bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr PSA SimpleX-Directory admin" -- putStrLn "*** discover service joins group and creates the link for profile" - bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin" + bob <## "#PSA: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> Joining the group PSA…" bob <## "#PSA: SimpleX-Directory joined the group" bob <# "SimpleX-Directory> Joined the group PSA, creating the link…" @@ -579,7 +579,7 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if service role changed bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member." bob <## "" @@ -588,7 +588,7 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if service role changed back without profile changes cath ##> "/mr privacy SimpleX-Directory admin" - cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + cath <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -597,7 +597,7 @@ testDelistedRoleChanges ps = groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" - cath <## "#privacy: you changed the role of bob from owner to admin" + cath <## "#privacy: you changed the role of bob to admin" bob <## "#privacy: cath changed your role from owner to admin" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin." bob <## "" @@ -606,7 +606,7 @@ testDelistedRoleChanges ps = groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" - cath <## "#privacy: you changed the role of bob from admin to owner" + cath <## "#privacy: you changed the role of bob to owner" bob <## "#privacy: cath changed your role from admin to owner" bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner." bob <## "" @@ -627,7 +627,7 @@ testNotDelistedMemberRoleChanged ps = cath <## "use @SimpleX-Directory to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" - bob <## "#privacy: you changed the role of cath from owner to member" + bob <## "#privacy: you changed the role of cath to member" cath <## "#privacy: bob changed your role from owner to member" groupFoundN 3 cath "privacy" @@ -641,11 +641,11 @@ testNotSentApprovalBadRoles ps = submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" updateProfileWithLink bob "privacy" welcomeWithLink 1 bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -666,14 +666,14 @@ testNotApprovedBadRoles ps = updateProfileWithLink bob "privacy" welcomeWithLink 1 notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + bob <## "#privacy: you changed the role of SimpleX-Directory to member" let approve = "/approve 1:privacy 1" superUser #> ("@SimpleX-Directory " <> approve) superUser <# ("SimpleX-Directory> > " <> approve) superUser <## " Group is not approved: SimpleX-Directory is not an admin." groupNotFound cath "privacy" bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: you changed the role of SimpleX-Directory to admin" bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." @@ -940,7 +940,7 @@ testListUserGroups ps = -- with de-listed group groupFound cath "anonymity" cath ##> "/mr anonymity SimpleX-Directory member" - cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member" + cath <## "#anonymity: you changed the role of SimpleX-Directory to member" cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member." cath <## "" cath <## "The group is no longer listed in the directory." diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6625fb8094..14539ac219 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -173,7 +173,8 @@ chatGroupTests = do it "messages are fully deleted" testBlockForAllFullDelete it "another admin can unblock" testBlockForAllAnotherAdminUnblocks it "member was blocked before joining group" testBlockForAllBeforeJoining - it "can't repeat block, unblock" testBlockForAllCantRepeat + it "repeat block, unblock" testBlockForAllRepeat + it "block multiple members" testBlockForAllMultipleMembers describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do @@ -265,7 +266,7 @@ testGroupShared alice bob cath checkMessages = do -- test observer role alice ##> "/mr team bob observer" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from admin to observer", + [ alice <## "#team: you changed the role of bob to observer", bob <## "#team: alice changed your role from admin to observer", cath <## "#team: alice changed the role of bob from admin to observer" ] @@ -280,7 +281,7 @@ testGroupShared alice bob cath checkMessages = do ] alice ##> "/mr team bob admin" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from observer to admin", + [ alice <## "#team: you changed the role of bob to admin", bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin" ] @@ -1460,7 +1461,7 @@ testUpdateMemberRole = alice <## "to add members use /a team or /create link #team" addMember "team" alice bob GRAdmin alice ##> "/mr team bob member" - alice <## "#team: you changed the role of bob from admin to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice invites you to join the group as member" bob <## "use /j team to accept" bob ##> "/j team" @@ -1472,7 +1473,7 @@ testUpdateMemberRole = bob <## "#team: you have insufficient permissions for this action, the required role is admin" alice ##> "/mr team bob admin" concurrently_ - (alice <## "#team: you changed the role of bob from member to admin") + (alice <## "#team: you changed the role of bob to admin") (bob <## "#team: alice changed your role from member to admin") bob ##> "/a team cath owner" bob <## "#team: you have insufficient permissions for this action, the required role is owner" @@ -1488,13 +1489,7 @@ testUpdateMemberRole = alice <## "#team: new member cath is connected" ] alice ##> "/mr team alice admin" - concurrentlyN_ - [ alice <## "#team: you changed your role from owner to admin", - bob <## "#team: alice changed the role from owner to admin", - cath <## "#team: alice changed the role from owner to admin" - ] - alice ##> "/d #team" - alice <## "#team: you have insufficient permissions for this action, the required role is owner" + alice <## "bad chat command: can't change role for self" testGroupDescription :: HasCallStack => TestParams -> IO () testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do @@ -1579,7 +1574,7 @@ testGroupModerate = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -1662,7 +1657,7 @@ testGroupModerateFullDelete = -- disableFullDeletion3 "team" alice bob cath alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -2691,7 +2686,7 @@ testGroupLinkMemberRole = bob <## "#team: you don't have permission to send messages" alice ##> "/mr #team bob member" - alice <## "#team: you changed the role of bob from observer to member" + alice <## "#team: you changed the role of bob to member" bob <## "#team: alice changed your role from observer to member" bob #> "#team hey now" @@ -2721,7 +2716,7 @@ testGroupLinkMemberRole = cath <## "#team: you don't have permission to send messages" alice ##> "/mr #team cath admin" - alice <## "#team: you changed the role of cath from observer to admin" + alice <## "#team: you changed the role of cath to admin" cath <## "#team: alice changed your role from observer to admin" bob <## "#team: alice changed the role of cath from observer to admin" @@ -2730,7 +2725,7 @@ testGroupLinkMemberRole = bob <# "#team cath> hey" cath ##> "/mr #team bob admin" - cath <## "#team: you changed the role of bob from member to admin" + cath <## "#team: you changed the role of bob to admin" bob <## "#team: cath changed your role from member to admin" alice <## "#team: cath changed the role of bob from member to admin" @@ -4132,14 +4127,14 @@ testGroupMsgForwardReport = alice ##> "/mr team bob moderator" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from admin to moderator", + [ alice <## "#team: you changed the role of bob to moderator", bob <## "#team: alice changed your role from admin to moderator", cath <## "#team: alice changed the role of bob from admin to moderator" ] alice ##> "/mr team cath member" concurrentlyN_ - [ alice <## "#team: you changed the role of cath from admin to member", + [ alice <## "#team: you changed the role of cath to member", bob <## "#team: alice changed the role of cath from admin to member", cath <## "#team: alice changed your role from admin to member" ] @@ -4157,7 +4152,7 @@ testGroupMsgForwardReport = alice ##> "/mr team bob member" concurrentlyN_ - [ alice <## "#team: you changed the role of bob from moderator to member", + [ alice <## "#team: you changed the role of bob to member", bob <## "#team: alice changed your role from moderator to member", cath <## "#team: alice changed the role of bob from moderator to member" ] @@ -4315,7 +4310,7 @@ testGroupMsgForwardChangeRole = setupGroupForwarding3 "team" alice bob cath cath ##> "/mr #team bob member" - cath <## "#team: you changed the role of bob from admin to member" + cath <## "#team: you changed the role of bob to member" alice <## "#team: cath changed the role of bob from admin to member" bob <## "#team: cath changed your role from admin to member" -- TODO show as forwarded @@ -5942,19 +5937,13 @@ testBlockForAllBeforeJoining = cc <## "#team: alice added dan (Daniel) to the group (connecting...)" cc <## "#team: new member dan is connected" -testBlockForAllCantRepeat :: HasCallStack => TestParams -> IO () -testBlockForAllCantRepeat = +testBlockForAllRepeat :: HasCallStack => TestParams -> IO () +testBlockForAllRepeat = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath -- disableFullDeletion3 "team" alice bob cath - alice ##> "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" - - cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" - bob #> "#team 1" [alice, cath] *<# "#team bob> 1" @@ -5964,10 +5953,10 @@ testBlockForAllCantRepeat = bob "/block for all #team bob" - alice <## "bad chat command: already blocked" + alice <## "#team: you blocked bob" cath ##> "/block for all #team bob" - cath <## "bad chat command: already blocked" + cath <## "#team: you blocked bob" bob #> "#team 2" alice <# "#team bob> 2 [blocked by admin] " @@ -5979,16 +5968,92 @@ testBlockForAllCantRepeat = bob "/unblock for all #team bob" - alice <## "bad chat command: already unblocked" + alice <## "#team: you unblocked bob" cath ##> "/unblock for all #team bob" - cath <## "bad chat command: already unblocked" + cath <## "#team: you unblocked bob" bob #> "#team 3" [alice, cath] *<# "#team bob> 3" bob #$> ("/_get chat #1 count=3", chat, [(1, "1"), (1, "2"), (1, "3")]) +testBlockForAllMultipleMembers :: HasCallStack => TestParams -> IO () +testBlockForAllMultipleMembers = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + do + bob <## "#team: alice added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected", + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + + -- lower roles to for batch block to be allowed (can't batch block if admins are selected) + alice ##> "/mr team bob member" + concurrentlyN_ + [ alice <## "#team: you changed the role of bob to member", + bob <## "#team: alice changed your role from admin to member", + cath <## "#team: alice changed the role of bob from admin to member", + dan <## "#team: alice changed the role of bob from admin to member" + ] + alice ##> "/mr team cath member" + concurrentlyN_ + [ alice <## "#team: you changed the role of cath to member", + bob <## "#team: alice changed the role of cath from admin to member", + cath <## "#team: alice changed your role from admin to member", + dan <## "#team: alice changed the role of cath from admin to member" + ] + + bob #> "#team 1" + [alice, cath, dan] *<# "#team bob> 1" + + cath #> "#team 2" + [alice, bob, dan] *<# "#team cath> 2" + + alice ##> "/_block #1 2,3 blocked=on" + alice <## "#team: you blocked 2 members" + dan <## "#team: alice blocked bob" + dan <## "#team: alice blocked cath" + bob "#team 3" + [alice, dan] *<# "#team bob> 3 [blocked by admin] " + cath <# "#team bob> 3" + + cath #> "#team 4" + [alice, dan] *<# "#team cath> 4 [blocked by admin] " + bob <# "#team cath> 4" + + alice ##> "/_block #1 2,3 blocked=off" + alice <## "#team: you unblocked 2 members" + dan <## "#team: alice unblocked bob" + dan <## "#team: alice unblocked cath" + bob "#team 5" + [alice, cath, dan] *<# "#team bob> 5" + + cath #> "#team 6" + [alice, bob, dan] *<# "#team cath> 6" + testGroupMemberInactive :: HasCallStack => TestParams -> IO () testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do @@ -6067,13 +6132,13 @@ testGroupMemberReports = -- disableFullDeletion3 "jokes" alice bob cath alice ##> "/mr jokes bob moderator" concurrentlyN_ - [ alice <## "#jokes: you changed the role of bob from admin to moderator", + [ alice <## "#jokes: you changed the role of bob to moderator", bob <## "#jokes: alice changed your role from admin to moderator", cath <## "#jokes: alice changed the role of bob from admin to moderator" ] alice ##> "/mr jokes cath member" concurrentlyN_ - [ alice <## "#jokes: you changed the role of cath from admin to member", + [ alice <## "#jokes: you changed the role of cath to member", bob <## "#jokes: alice changed the role of cath from admin to member", cath <## "#jokes: alice changed your role from admin to member" ] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 4ab5e8fbd3..5d2b9f5ba8 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -773,7 +773,7 @@ testBusinessUpdateProfiles = withTestOutput $ testChat4 businessProfile alicePro biz <# "#alisa alisa_1> hello again" -- customer can invite members too, if business allows biz ##> "/mr alisa alisa_1 admin" - biz <## "#alisa: you changed the role of alisa_1 from member to admin" + biz <## "#alisa: you changed the role of alisa_1 to admin" alice <## "#biz: biz_1 changed your role from member to admin" connectUsers alice bob alice ##> "/a #biz bob" From 1b757911fa0bcb605a90e45643b9538626023d84 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 1 Mar 2025 01:55:17 +0700 Subject: [PATCH 4/5] ui: batch apis for members (#5681) * ui: batch apis for members * ios --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/SimpleXAPI.swift | 18 ++--- .../Views/Chat/Group/GroupChatInfoView.swift | 8 ++- .../Chat/Group/GroupMemberInfoView.swift | 24 ++++--- apps/ios/SimpleXChat/APITypes.swift | 36 +++++----- .../chat/simplex/common/model/SimpleXAPI.kt | 70 ++++++++++--------- .../views/chat/group/GroupChatInfoView.kt | 12 ++-- .../views/chat/group/GroupMemberInfoView.kt | 32 ++++++--- 7 files changed, 114 insertions(+), 86 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 354f8243fc..545e90763f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1588,21 +1588,21 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { - let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, _, member) = r { return member } +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64]) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds), bgTask: false) + if case let .userDeletedMembers(_, _, members) = r { return members } throw r } -func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { - let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, _, member, _, _) = r { return member } +func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false) + if case let .membersRoleUser(_, _, members, _) = r { return members } throw r } -func apiBlockMemberForAll(_ groupId: Int64, _ memberId: Int64, _ blocked: Bool) async throws -> GroupMember { - let r = await chatSendCmd(.apiBlockMemberForAll(groupId: groupId, memberId: memberId, blocked: blocked), bgTask: false) - if case let .memberBlockedForAllUser(_, _, member, _) = r { return member } +func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] { + let r = await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false) + if case let .membersBlockedForAllUser(_, _, members, _) = r { return members } throw r } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 2a827de195..56d994b397 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -681,12 +681,14 @@ struct GroupChatInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } } } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") + logger.error("apiRemoveMembers error: \(responseError(error))") let a = getErrorAlert(error, "Error removing member") alert = .error(title: a.title, error: a.message) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index f985356289..4a931ed23f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -610,13 +610,15 @@ struct GroupMemberInfoView: View { primaryButton: .destructive(Text("Remove")) { Task { do { - let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } dismiss() } } catch let error { - logger.error("apiRemoveMember error: \(responseError(error))") + logger.error("apiRemoveMembers error: \(responseError(error))") let a = getErrorAlert(error, "Error removing member") alert = .error(title: a.title, error: a.message) } @@ -641,14 +643,16 @@ struct GroupMemberInfoView: View { primaryButton: .default(Text("Change")) { Task { do { - let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) + let updatedMembers = try await apiMembersRole(groupInfo.groupId, [mem.groupMemberId], newRole) await MainActor.run { - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } } } catch let error { newRole = mem.memberRole - logger.error("apiMemberRole error: \(responseError(error))") + logger.error("apiMembersRole error: \(responseError(error))") let a = getErrorAlert(error, "Error changing role") alert = .error(title: a.title, error: a.message) } @@ -800,12 +804,14 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) { Task { do { - let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked) + let updatedMembers = try await apiBlockMembersForAll(gInfo.groupId, [member.groupMemberId], blocked) await MainActor.run { - _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember) + } } } catch let error { - logger.error("apiBlockMemberForAll error: \(responseError(error))") + logger.error("apiBlockMembersForAll error: \(responseError(error))") } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index f331f98a67..20c8785b3a 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -71,9 +71,9 @@ public enum ChatCommand { case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) - case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) - case apiBlockMemberForAll(groupId: Int64, memberId: Int64, blocked: Bool) - case apiRemoveMember(groupId: Int64, memberId: Int64) + case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) + case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) + case apiRemoveMembers(groupId: Int64, memberIds: [Int64]) case apiLeaveGroup(groupId: Int64) case apiListMembers(groupId: Int64) case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile) @@ -250,9 +250,9 @@ public enum ChatCommand { case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" - case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" - case let .apiBlockMemberForAll(groupId, memberId, blocked): return "/_block #\(groupId) \(memberId) blocked=\(onOff(blocked))" - case let .apiRemoveMember(groupId, memberId): return "/_remove #\(groupId) \(memberId)" + case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" + case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" + case let .apiRemoveMembers(groupId, memberIds): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ","))" case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)" case let .apiListMembers(groupId): return "/_members #\(groupId)" case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))" @@ -424,9 +424,9 @@ public enum ChatCommand { case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" - case .apiMemberRole: return "apiMemberRole" - case .apiBlockMemberForAll: return "apiBlockMemberForAll" - case .apiRemoveMember: return "apiRemoveMember" + case .apiMembersRole: return "apiMembersRole" + case .apiBlockMembersForAll: return "apiBlockMembersForAll" + case .apiRemoveMembers: return "apiRemoveMembers" case .apiLeaveGroup: return "apiLeaveGroup" case .apiListMembers: return "apiListMembers" case .apiUpdateGroupProfile: return "apiUpdateGroupProfile" @@ -681,16 +681,16 @@ public enum ChatResponse: Decodable, Error { case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) - case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember]) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) - case memberBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember, blocked: Bool) + case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case deletedMemberUser(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) @@ -861,16 +861,16 @@ public enum ChatResponse: Decodable, Error { case .userAcceptedGroupSent: return "userAcceptedGroupSent" case .groupLinkConnecting: return "groupLinkConnecting" case .businessLinkConnecting: return "businessLinkConnecting" - case .userDeletedMember: return "userDeletedMember" + case .userDeletedMembers: return "userDeletedMembers" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" case .receivedGroupInvitation: return "receivedGroupInvitation" case .groupDeletedUser: return "groupDeletedUser" case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" case .memberRole: return "memberRole" - case .memberRoleUser: return "memberRoleUser" + case .membersRoleUser: return "membersRoleUser" case .memberBlockedForAll: return "memberBlockedForAll" - case .memberBlockedForAllUser: return "memberBlockedForAllUser" + case .membersBlockedForAllUser: return "membersBlockedForAllUser" case .deletedMemberUser: return "deletedMemberUser" case .deletedMember: return "deletedMember" case .leftMember: return "leftMember" @@ -1048,16 +1048,16 @@ public enum ChatResponse: Decodable, Error { case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") - case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .userDeletedMembers(u, groupInfo, members): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") - case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") - case let .memberBlockedForAllUser(u, groupInfo, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nblocked: \(blocked)") + case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 208a5557a5..c221e3c15e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1995,34 +1995,34 @@ object ChatController { } } - suspend fun apiRemoveMember(rh: Long?, groupId: Long, memberId: Long): GroupMember? = - when (val r = sendCmd(rh, CC.ApiRemoveMember(groupId, memberId))) { - is CR.UserDeletedMember -> r.member + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List): List? = + when (val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds))) { + is CR.UserDeletedMembers -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiRemoveMember", generalGetString(MR.strings.error_removing_member), r) + apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r) } null } } - suspend fun apiMemberRole(rh: Long?, groupId: Long, memberId: Long, memberRole: GroupMemberRole): GroupMember = - when (val r = sendCmd(rh, CC.ApiMemberRole(groupId, memberId, memberRole))) { - is CR.MemberRoleUser -> r.member + suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List = + when (val r = sendCmd(rh, CC.ApiMembersRole(groupId, memberIds, memberRole))) { + is CR.MembersRoleUser -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiMemberRole", generalGetString(MR.strings.error_changing_role), r) + apiErrorAlert("apiMembersRole", generalGetString(MR.strings.error_changing_role), r) } throw Exception("failed to change member role: ${r.responseType} ${r.details}") } } - suspend fun apiBlockMemberForAll(rh: Long?, groupId: Long, memberId: Long, blocked: Boolean): GroupMember = - when (val r = sendCmd(rh, CC.ApiBlockMemberForAll(groupId, memberId, blocked))) { - is CR.MemberBlockedForAllUser -> r.member + suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List = + when (val r = sendCmd(rh, CC.ApiBlockMembersForAll(groupId, memberIds, blocked))) { + is CR.MembersBlockedForAllUser -> r.members else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiBlockMemberForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) + apiErrorAlert("apiBlockMembersForAll", generalGetString(MR.strings.error_blocking_member_for_all), r) } throw Exception("failed to block member for all: ${r.responseType} ${r.details}") } @@ -2723,13 +2723,17 @@ object ChatController { upsertGroupMember(rhId, r.groupInfo, r.member) } } - is CR.MemberRoleUser -> + is CR.MembersRoleUser -> if (active(r.user)) { withChats { - upsertGroupMember(rhId, r.groupInfo, r.member) + r.members.forEach { member -> + upsertGroupMember(rhId, r.groupInfo, member) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, r.groupInfo, r.member) + r.members.forEach { member -> + upsertGroupMember(rhId, r.groupInfo, member) + } } } is CR.MemberBlockedForAll -> @@ -3406,9 +3410,9 @@ sealed class CC { class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() - class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() - class ApiBlockMemberForAll(val groupId: Long, val memberId: Long, val blocked: Boolean): CC() - class ApiRemoveMember(val groupId: Long, val memberId: Long): CC() + class ApiMembersRole(val groupId: Long, val memberIds: List, val memberRole: GroupMemberRole): CC() + class ApiBlockMembersForAll(val groupId: Long, val memberIds: List, val blocked: Boolean): CC() + class ApiRemoveMembers(val groupId: Long, val memberIds: List): CC() class ApiLeaveGroup(val groupId: Long): CC() class ApiListMembers(val groupId: Long): CC() class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC() @@ -3591,9 +3595,9 @@ sealed class CC { is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" - is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" - is ApiBlockMemberForAll -> "/_block #$groupId $memberId blocked=${onOff(blocked)}" - is ApiRemoveMember -> "/_remove #$groupId $memberId" + is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}" + is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}" + is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")}" is ApiLeaveGroup -> "/_leave #$groupId" is ApiListMembers -> "/_members #$groupId" is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}" @@ -3754,9 +3758,9 @@ sealed class CC { is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" - is ApiMemberRole -> "apiMemberRole" - is ApiBlockMemberForAll -> "apiBlockMemberForAll" - is ApiRemoveMember -> "apiRemoveMember" + is ApiMembersRole -> "apiMembersRole" + is ApiBlockMembersForAll -> "apiBlockMembersForAll" + is ApiRemoveMembers -> "apiRemoveMembers" is ApiLeaveGroup -> "apiLeaveGroup" is ApiListMembers -> "apiListMembers" is ApiUpdateGroupProfile -> "apiUpdateGroupProfile" @@ -5801,16 +5805,16 @@ sealed class CR { @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("businessLinkConnecting") class BusinessLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val fromContact: Contact): CR() - @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("userDeletedMembers") class UserDeletedMembers(val user: UserRef, val groupInfo: GroupInfo, val members: List): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("membersRoleUser") class MembersRoleUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val toRole: GroupMemberRole): CR() @Serializable @SerialName("memberBlockedForAll") class MemberBlockedForAll(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val blocked: Boolean): CR() - @Serializable @SerialName("memberBlockedForAllUser") class MemberBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val blocked: Boolean): CR() + @Serializable @SerialName("membersBlockedForAllUser") class MembersBlockedForAllUser(val user: UserRef, val groupInfo: GroupInfo, val members: List, val blocked: Boolean): CR() @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -5986,16 +5990,16 @@ sealed class CR { is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" is BusinessLinkConnecting -> "businessLinkConnecting" - is UserDeletedMember -> "userDeletedMember" + is UserDeletedMembers -> "userDeletedMembers" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberRole -> "memberRole" - is MemberRoleUser -> "memberRoleUser" + is MembersRoleUser -> "membersRoleUser" is MemberBlockedForAll -> "memberBlockedForAll" - is MemberBlockedForAllUser -> "memberBlockedForAllUser" + is MembersBlockedForAllUser -> "membersBlockedForAllUser" is DeletedMemberUser -> "deletedMemberUser" is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" @@ -6164,16 +6168,16 @@ sealed class CR { is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is BusinessLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nfromContact: $fromContact") - is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is UserDeletedMembers -> withUser(user, "groupInfo: $groupInfo\nmembers: $members") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") - is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is MembersRoleUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\ntoRole: $toRole") is MemberBlockedForAll -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nblocked: $blocked") - is MemberBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nblocked: $blocked") + is MembersBlockedForAllUser -> withUser(user, "groupInfo: $groupInfo\nmembers: $members\nblocked: $blocked") is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index b3dc333ace..441fe313d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -213,13 +213,17 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withBGApi { - val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) - if (updatedMember != null) { + val updatedMembers = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, listOf(mem.groupMemberId)) + if (updatedMembers != null) { withChats { - upsertGroupMember(rhId, groupInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ef1c69a5bb..41e4df8792 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -142,12 +142,16 @@ fun GroupMemberInfoView( }) { withBGApi { kotlin.runCatching { - val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) + val members = chatModel.controller.apiMembersRole(rhId, groupInfo.groupId, listOf(member.groupMemberId), it) withChats { - upsertGroupMember(rhId, groupInfo, mem) + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, mem) + members.forEach { member -> + upsertGroupMember(rhId, groupInfo, member) + } } }.onFailure { newRole.value = prevValue @@ -257,13 +261,17 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c confirmText = generalGetString(MR.strings.remove_member_confirmation), onConfirm = { withBGApi { - val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) - if (removedMember != null) { + val removedMembers = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + if (removedMembers != null) { withChats { - upsertGroupMember(rhId, groupInfo, removedMember) + removedMembers.forEach { removedMember -> + upsertGroupMember(rhId, groupInfo, removedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, groupInfo, removedMember) + removedMembers.forEach { removedMember -> + upsertGroupMember(rhId, groupInfo, removedMember) + } } } close?.invoke() @@ -804,12 +812,16 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) { withBGApi { - val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked) + val updatedMembers = ChatController.apiBlockMembersForAll(rhId, gInfo.groupId, listOf(member.groupMemberId), blocked) withChats { - upsertGroupMember(rhId, gInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, gInfo, updatedMember) + } } withReportsChatsIfOpen { - upsertGroupMember(rhId, gInfo, updatedMember) + updatedMembers.forEach { updatedMember -> + upsertGroupMember(rhId, gInfo, updatedMember) + } } } } From 50232fd1799f05c9db4f2fce917f797635e595d4 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 1 Mar 2025 01:55:49 +0700 Subject: [PATCH 5/5] android, desktop: go to forwarded item or search result (#5666) * android, desktop: go to forwarded item or search result * changes * reactions back * button appearance * indentation * change * rename variable * rename function * rename variable * rename variable * fix scroll position --- .../chat/simplex/common/model/ChatModel.kt | 8 + .../simplex/common/views/chat/ChatInfoView.kt | 3 +- .../common/views/chat/ChatItemsLoader.kt | 29 +- .../simplex/common/views/chat/ChatView.kt | 62 +- .../common/views/chat/item/ChatItemView.kt | 913 ++++++++++-------- .../common/views/chat/item/FramedItemView.kt | 2 +- .../views/chatlist/ChatListNavLinkView.kt | 22 +- 7 files changed, 591 insertions(+), 448 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7ef8123d2f..f784dcb9ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -65,6 +65,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) + val openAroundItemId: MutableState = mutableStateOf(null) 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 @@ -3111,6 +3112,13 @@ sealed class CIForwardedFrom { is Group -> chatName } + val chatTypeApiIdMsgId: Triple? + get() = when (this) { + Unknown -> null + is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null + is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + } + fun text(chatType: ChatType): String = if (chatType == ChatType.Local) { if (chatName.isEmpty()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 23ce191557..a730bd1b71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -1398,7 +1398,8 @@ private suspend fun afterSetChatTTL(rhId: Long?, chatInfo: ChatInfo, progressInd chat, navInfo, contentTag = null, - pagination = pagination + pagination = pagination, + openAroundItemId = null ) } catch (e: Exception) { Log.e(TAG, "apiGetChat error: ${e.stackTraceToString()}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 6a8c277ae1..9050804db4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -28,13 +28,15 @@ suspend fun apiLoadMessages( contentTag: MsgContentTag?, pagination: ChatPagination, search: String = "", + openAroundItemId: Long? = null, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) = 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) + /** When [openAroundItemId] is provided, chatId can be different too */ + if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null) || !isActive) return@coroutineScope - processLoadedChat(chat, navInfo, contentTag, pagination, visibleItemIndexesNonReversed) + processLoadedChat(chat, navInfo, contentTag, pagination, openAroundItemId, visibleItemIndexesNonReversed) } suspend fun processLoadedChat( @@ -42,6 +44,7 @@ suspend fun processLoadedChat( navInfo: NavigationInfo, contentTag: MsgContentTag?, pagination: ChatPagination, + openAroundItemId: Long?, visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 } ) { val chatState = chatModel.chatStateForContent(contentTag) @@ -67,7 +70,7 @@ suspend fun processLoadedChat( withChats(contentTag) { chatItemStatuses.clear() chatItems.replaceAll(chat.chatItems) - chatModel.chatId.value = chat.chatInfo.id + chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { unreadAfterItemId.value = chat.chatItems.last().id @@ -119,10 +122,15 @@ suspend fun processLoadedChat( } } is ChatPagination.Around -> { - newItems.addAll(oldItems) - val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) + val newSplits = if (openAroundItemId == null) { + newItems.addAll(oldItems) + removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) + } else { + emptyList() + } // currently, items will always be added on top, which is index 0 newItems.addAll(0, chat.chatItems) + withChats(contentTag) { chatItems.replaceAll(newItems) splits.value = listOf(chat.chatItems.last().id) + newSplits @@ -130,8 +138,15 @@ suspend fun processLoadedChat( totalAfter.value = navInfo.afterTotal unreadTotal.value = chat.chatStats.unreadCount unreadAfter.value = navInfo.afterUnread - // no need to set it, count will be wrong - // unreadAfterNewestLoaded.value = navInfo.afterUnread + + if (openAroundItemId != null) { + unreadAfterNewestLoaded.value = navInfo.afterUnread + chatModel.openAroundItemId.value = openAroundItemId + chatModel.chatId.value = chat.id + } else { + // no need to set it, count will be wrong + // unreadAfterNewestLoaded.value = navInfo.afterUnread + } } } is ChatPagination.Last -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 8b94f36d75..f9a7f25f48 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -133,9 +133,12 @@ fun ChatView( 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 + val sameText = searchText.value == value + // showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it + // (required on Android to have this check to prevent call to search with old text) + val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && contentTag == null + val c = chatModel.getChat(chatInfo.id) + if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged withBGApi { apiFindMessages(c, value, contentTag) searchText.value = value @@ -344,7 +347,7 @@ fun ChatView( val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout if (c != null) { - apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, visibleItemIndexes) + apiLoadMessages(c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, contentTag, pagination, searchText.value, null, visibleItemIndexes) } }, deleteMessage = { itemId, mode -> @@ -602,6 +605,10 @@ fun ChatView( }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, + closeSearch = { + showSearch.value = false + searchText.value = "" + }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), @@ -699,6 +706,7 @@ fun ChatLayout( markChatRead: () -> Unit, changeNtfsState: (MsgFilter, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean, @@ -751,7 +759,7 @@ fun ChatLayout( useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markItemsRead, markChatRead, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, ) } if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { @@ -1160,11 +1168,13 @@ fun BoxScope.ChatItemsList( showItemDetails: (ChatInfo, ChatItem) -> Unit, markItemsRead: (List) -> Unit, markChatRead: () -> Unit, + closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean ) { val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } val contentTag = LocalContentTag.current // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state @@ -1177,15 +1187,29 @@ fun BoxScope.ChatItemsList( val reportsCount = reportsCount(chatInfo.id) val topPaddingToContent = topPaddingToContent(chatView = contentTag == null, contentTag == null && reportsCount > 0) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) + val numberOfBottomAppBars = numberOfBottomAppBars() /** 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 * */ val maxHeightForList = rememberUpdatedState( - with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * 2).roundToPx() } + with(LocalDensity.current) { LocalWindowHeight().roundToPx() - topPaddingToContentPx.value - (AppBarHeight * fontSizeSqrtMultiplier * numberOfBottomAppBars).roundToPx() } ) - val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, saver = LazyListState.Saver) { - val index = mergedItems.value.items.indexOfLast { it.hasUnread() } + val resetListState = remember { mutableStateOf(false) } + remember(chatModel.openAroundItemId.value) { + if (chatModel.openAroundItemId.value != null) { + closeSearch() + resetListState.value = !resetListState.value + } + } + val highlightedItems = remember { mutableStateOf(setOf()) } + val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { + val openAroundItemId = chatModel.openAroundItemId.value + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } val reportsState = reportsListState + if (openAroundItemId != null) { + highlightedItems.value += openAroundItemId + chatModel.openAroundItemId.value = null + } if (reportsState != null) { reportsListState = null reportsState @@ -1221,7 +1245,6 @@ fun BoxScope.ChatItemsList( val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) val chatInfoUpdated = rememberUpdatedState(chatInfo) - val highlightedItems = remember { mutableStateOf(setOf()) } val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { // In group reports just set the itemId to scroll to so the main ChatView will handle scrolling @@ -1238,7 +1261,7 @@ fun BoxScope.ChatItemsList( scrollToItemId.value = null } } } - LoadLastItems(loadingMoreItems, remoteHostId, chatInfo) + LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo) SmallScrollOnNewMessage(listState, reversedChatItems) val finishedInitialComposition = remember { mutableStateOf(false) } NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) @@ -1296,7 +1319,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, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, 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, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, 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) } } @@ -1567,9 +1590,9 @@ fun BoxScope.ChatItemsList( } @Composable -private fun LoadLastItems(loadingMoreItems: MutableState, remoteHostId: Long?, chatInfo: ChatInfo) { +private fun LoadLastItems(loadingMoreItems: MutableState, resetListState: State, remoteHostId: Long?, chatInfo: ChatInfo) { val contentTag = LocalContentTag.current - LaunchedEffect(remoteHostId, chatInfo.id) { + LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) { try { loadingMoreItems.value = true if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect @@ -1888,6 +1911,17 @@ fun topPaddingToContent(chatView: Boolean, additionalTopBar: Boolean = false): D } } +@Composable +private fun numberOfBottomAppBars(): Int { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val chatBottomBar = remember { appPrefs.chatBottomBar.state } + return if (oneHandUI.value && chatBottomBar.value) { + 2 + } else { + 1 + } +} + @Composable private fun FloatingDate( modifier: Modifier, @@ -2798,6 +2832,7 @@ fun PreviewChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = false, @@ -2874,6 +2909,7 @@ fun PreviewGroupChatLayout() { markChatRead = {}, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, + closeSearch = {}, onComposed = {}, developerTools = false, showViaProxy = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 8c053ad1d2..e4bcc965c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -3,14 +3,14 @@ package chat.simplex.common.views.chat.item import SectionItemView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.HoverInteraction -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -31,8 +31,10 @@ 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.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock import kotlin.math.* @@ -73,6 +75,7 @@ fun ChatItemView( highlighted: State, range: State, selectedChatItems: MutableState?>, + searchIsNotBlank: State, fillMaxWidth: Boolean = true, selectChatItem: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -112,8 +115,16 @@ fun ChatItemView( val onLinkLongClick = { _: String -> showMenu.value = true } val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value + val bubbleInteractionSource = remember { MutableInteractionSource() } + val lineInteractionSource = remember { MutableInteractionSource() } + val bubblePressed = bubbleInteractionSource.collectIsPressedAsState() + val linePressed = lineInteractionSource.collectIsPressedAsState() + val lineHovered = lineInteractionSource.collectIsHoveredAsState() + val lineActivated = remember { derivedStateOf { lineHovered.value || linePressed.value || bubblePressed.value } } Box( - modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, + modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier) + .hoverable(lineInteractionSource) + .clickable(onClick = {}, interactionSource = lineInteractionSource, indication = null), contentAlignment = alignment, ) { val info = cItem.meta.itemStatus.statusInto @@ -228,197 +239,289 @@ fun ChatItemView( } } - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - val interactionSource = remember { MutableInteractionSource() } - val enterInteraction = remember { HoverInteraction.Enter() } - KeyChangeEffect(highlighted.value) { - if (highlighted.value) { - interactionSource.emit(enterInteraction) - } else { - interactionSource.emit(HoverInteraction.Exit(enterInteraction)) + @Composable + fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, parentActivated: State, onClick: () -> Unit) { + val buttonInteractionSource = remember { MutableInteractionSource() } + val buttonHovered = buttonInteractionSource.collectIsHoveredAsState() + val buttonPressed = buttonInteractionSource.collectIsPressedAsState() + val buttonActivated = remember { derivedStateOf { buttonHovered.value || buttonPressed.value } } + IconButton( + onClick, + Modifier + .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) + .size(22.dp) + .alpha(if (parentActivated.value || buttonActivated.value) 1f else 0.4f), + interactionSource = buttonInteractionSource + ) { + Icon(painterResource(icon), null, Modifier.size(22.dp), tint = MaterialTheme.colors.secondary) + } + } + + @Composable + fun GoToItemButton(alignStart: Boolean, parentActivated: State) { + val chatTypeApiIdMsgId = cItem.meta.itemForwarded?.chatTypeApiIdMsgId + if (searchIsNotBlank.value) { + GoToItemInnerButton(alignStart, MR.images.ic_search, parentActivated) { + withBGApi { + openChat(rhId, cInfo.chatType, cInfo.apiId, null, cItem.id) + closeReportsIfNeeded() + } + } + } else if (chatTypeApiIdMsgId != null) { + GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, parentActivated) { + val (chatType, apiId, msgId) = chatTypeApiIdMsgId + withBGApi { + openChat(rhId, chatType, apiId, null, msgId) + closeReportsIfNeeded() + } } } - Column( - Modifier - .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = interactionSource, indication = LocalIndication.current) - .onRightClick { showMenu.value = true }, - ) { - @Composable - fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) - } + } - fun deleteMessageQuestionText(): String { - return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { - generalGetString(MR.strings.delete_message_cannot_be_undone_warning) - } else { - generalGetString(MR.strings.delete_message_mark_deleted_warning) - } + Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (cItem.chatDir.sent) { + GoToItemButton(true, lineActivated) } - - @Composable - fun MsgReactionsMenu() { - val rs = MsgReaction.old.mapNotNull { r -> - if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { - r + Column(Modifier.weight(1f, fill = false)) { + val enterInteraction = remember { HoverInteraction.Enter() } + KeyChangeEffect(highlighted.value) { + if (highlighted.value) { + bubbleInteractionSource.emit(enterInteraction) } else { - null + bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) } } - if (rs.isNotEmpty()) { - Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { - rs.forEach() { r -> - Box( - Modifier.size(36.dp).clickable { - setReaction(cInfo, cItem, true, r) - showMenu.value = false - }, - contentAlignment = Alignment.Center - ) { - ReactionIcon(r.text, 12.sp) - } + Column( + Modifier + .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + .onRightClick { showMenu.value = true }, + ) { + @Composable + fun framedItemView() { + FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToQuotedItemFromItem) + } + + fun deleteMessageQuestionText(): String { + return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { + generalGetString(MR.strings.delete_message_cannot_be_undone_warning) + } else { + generalGetString(MR.strings.delete_message_mark_deleted_warning) } } - } - } - @Composable - fun DeleteItemMenu() { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun MsgContentItemDropdownMenu() { - val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) - when { - // cItem.id check is a special case for live message chat item which has negative ID while not sent yet - cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { - DefaultDropdownMenu(showMenu) { - if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) + @Composable + fun MsgReactionsMenu() { + val rs = MsgReaction.old.mapNotNull { r -> + if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { + r + } else { + null } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) - Divider() - SelectItemAction(showMenu, selectChatItem) } - } - cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { - DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + if (rs.isNotEmpty()) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { + rs.forEach() { r -> + Box( + Modifier.size(36.dp).clickable { + setReaction(cInfo, cItem, true, r) + showMenu.value = false + }, + contentAlignment = Alignment.Center + ) { + ReactionIcon(r.text, 12.sp) } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - - val copyAndShareAllowed = when { - cItem.content.text.isNotEmpty() -> true - cItem.file?.forwardingAllowed() == true -> true - else -> false - } - - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - var fileSource = getLoadedFileSource(cItem.file) - val shareIfExists = { - when (val f = fileSource) { - null -> clipboard.shareText(cItem.content.text) - else -> shareFile(cItem.text, f) - } - showMenu.value = false - } - if (chatModel.connectedToRemote() && fileSource == null) { - withLongRunningApi(slow = 600_000) { - cItem.file?.loadRemoteFile(true) - fileSource = getLoadedFileSource(cItem.file) - shareIfExists() - } - } else shareIfExists() - }) - } - if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - } - if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { - ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { - withBGApi { - Log.d(TAG, "ChatItemView downloadFileAction") - val user = chatModel.currentUser.value - if (user != null) { - controller.receiveFile(rhId, user, cItem.file.fileId) - } - } - showMenu.value = false - }) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) - showMenu.value = false - }) - } - if (cItem.meta.itemDeleted == null && - (cItem.file == null || cItem.file.forwardingAllowed()) && - !cItem.isLiveDummy && !live - ) { - ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { - forwardItem(cInfo, cItem) - showMenu.value = false - }) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) - } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - } - if (cItem.chatDir !is CIDirection.GroupSnd) { - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) - } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { - ReportItemAction(cItem, composeState, showMenu) } } + } + } + + @Composable + fun DeleteItemMenu() { + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) } } } - cItem.meta.itemDeleted != null -> { + + @Composable + fun MsgContentItemDropdownMenu() { + val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) + when { + // cItem.id check is a special case for live message chat item which has negative ID while not sent yet + cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> { + DefaultDropdownMenu(showMenu) { + if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live && !cItem.localNote) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + cItem.file?.forwardingAllowed() == true -> true + else -> false + } + + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + var fileSource = getLoadedFileSource(cItem.file) + val shareIfExists = { + when (val f = fileSource) { + null -> clipboard.shareText(cItem.content.text) + else -> shareFile(cItem.text, f) + } + showMenu.value = false + } + if (chatModel.connectedToRemote() && fileSource == null) { + withLongRunningApi(slow = 600_000) { + cItem.file?.loadRemoteFile(true) + fileSource = getLoadedFileSource(cItem.file) + shareIfExists() + } + } else shareIfExists() + }) + } + if (copyAndShareAllowed) { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + } + if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { + withBGApi { + Log.d(TAG, "ChatItemView downloadFileAction") + val user = chatModel.currentUser.value + if (user != null) { + controller.receiveFile(rhId, user, cItem.file.fileId) + } + } + showMenu.value = false + }) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + if (cItem.meta.itemDeleted == null && + (cItem.file == null || cItem.file.forwardingAllowed()) && + !cItem.isLiveDummy && !live + ) { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + forwardItem(cInfo, cItem) + showMenu.value = false + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive) && !preview) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + if (cItem.chatDir !is CIDirection.GroupSnd) { + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage) + } else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.groupFeatureEnabled(GroupFeature.Reports) && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) { + ReportItemAction(cItem, composeState, showMenu) + } + } + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.meta.itemDeleted != null -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + HideItemAction(revealed, showMenu, reveal) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu, reveal) + } else if (range.value != null) { + ExpandItemAction(revealed, showMenu, reveal) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.isDeletedContent -> { + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { + DefaultDropdownMenu(showMenu) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu, reveal) + } else { + ExpandItemAction(revealed, showMenu, reveal) + } + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + else -> { + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (selectedChatItems.value == null) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + } + } + + @Composable + fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { - if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) - } else if (!cItem.isDeletedContent) { + if (!cItem.isDeletedContent) { RevealItemAction(revealed, showMenu, reveal) - } else if (range.value != null) { - ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -428,268 +531,222 @@ fun ChatItemView( } } } - cItem.isDeletedContent -> { - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { - DefaultDropdownMenu(showMenu) { - if (revealed.value) { - ShrinkItemAction(revealed, showMenu, reveal) - } else { - ExpandItemAction(revealed, showMenu, reveal) - } - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - else -> { - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (selectedChatItems.value == null) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - } - } - @Composable - fun MarkedDeletedItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun ContentItem() { - val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - 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) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + @Composable + fun ContentItem() { + val mc = cItem.content.msgContent + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemDropdownMenu() } else { - framedItemView() + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + } else { + framedItemView() + } + } else { + framedItemView() + } + MsgContentItemDropdownMenu() } - } else { - framedItemView() } - MsgContentItemDropdownMenu() - } - } - @Composable fun LegacyDeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - DefaultDropdownMenu(showMenu) { - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) + @Composable fun LegacyDeletedItem() { + DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + DefaultDropdownMenu(showMenu) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable fun CallItem(status: CICallStatus, duration: Int) { + CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) + DeleteItemMenu() + } + + fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): 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]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + + fun eventItemViewText(reversedChatItems: List): AnnotatedString { + val memberDisplayName = cItem.memberDisplayName + val t = mergedGroupEventText(cItem, reversedChatItems) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { + buildAnnotatedString { + withStyle(chatEventStyle) { append(memberDisplayName) } + append(" ") + }.plus(chatEventText(cItem)) + } else { + chatEventText(cItem) + } + } + + @Composable fun EventItemView() { + val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() + CIEventView(eventItemViewText(reversedChatItems)) + } + + @Composable + fun DeletedItem() { + 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) + if (cItem.canBeDeletedForSelf) { + Divider() + SelectItemAction(showMenu, selectChatItem) + } + } + } + + @Composable + fun E2EEInfoNoPQText() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + + @Composable + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + if (e2EEInfo.pqEnabled) { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } else { + E2EEInfoNoPQText() + } + } + + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} + } + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() + } } } } - - @Composable fun CallItem(status: CICallStatus, duration: Int) { - CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL) - DeleteItemMenu() - } - - fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List): 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]) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - else -> "" - } - return if (count <= 1) { - null - } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) - } else if (count > ns.size) { - members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) - } else { - members - } - } - - fun eventItemViewText(reversedChatItems: List): AnnotatedString { - val memberDisplayName = cItem.memberDisplayName - val t = mergedGroupEventText(cItem, reversedChatItems) - return if (!revealed.value && t != null) { - chatEventText(t, cItem.timestampText) - } else if (memberDisplayName != null) { - buildAnnotatedString { - withStyle(chatEventStyle) { append(memberDisplayName) } - append(" ") - }.plus(chatEventText(cItem)) - } else { - chatEventText(cItem) - } - } - - @Composable fun EventItemView() { - val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed() - CIEventView(eventItemViewText(reversedChatItems)) - } - - @Composable - fun DeletedItem() { - 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) - if (cItem.canBeDeletedForSelf) { - Divider() - SelectItemAction(showMenu, selectChatItem) - } - } - } - - @Composable - fun E2EEInfoNoPQText() { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } - - @Composable - fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { - if (e2EEInfo.pqEnabled) { - Text( - buildAnnotatedString { - withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } - }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) - ) - } else { - E2EEInfoNoPQText() - } - } - - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() - } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndChatFeature -> { - CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupFeature -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() - } + if (!cItem.chatDir.sent) { + GoToItemButton(false, lineActivated) } } - if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() } @@ -1109,7 +1166,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ -> val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) } - val width = if (sent && tailVisible) size.width - msgTailWidth else size.width + val width = size.width val height = size.height val rxMax = min(msgBubbleMaxRadius, width / 2) val ryMax = min(msgBubbleMaxRadius, height / 2) @@ -1224,6 +1281,12 @@ fun shapeStyle(chatItem: ChatItem? = null, tailEnabled: Boolean, tailVisible: Bo } } +private fun closeReportsIfNeeded() { + if (appPlatform.isAndroid && ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) { + ModalManager.end.closeModals() + } +} + fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { AlertManager.shared.showAlertDialog( title = generalGetString(cancelAction.alert.titleId), @@ -1338,6 +1401,7 @@ fun PreviewChatItemView( highlighted = remember { mutableStateOf(false) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, @@ -1385,6 +1449,7 @@ fun PreviewChatItemViewDeletedContent() { highlighted = remember { mutableStateOf(false) }, range = remember { mutableStateOf(0..1) }, selectedChatItems = remember { mutableStateOf(setOf()) }, + searchIsNotBlank = remember { mutableStateOf(false) }, selectChatItem = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 1fee43f80e..689bf195f5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -202,7 +202,7 @@ fun FramedItemView( Column( Modifier .width(IntrinsicSize.Max) - .padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) + .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) ) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 4cdd4b5c05..89dc84a1fc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -210,8 +210,26 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? 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, contentTag: MsgContentTag? = null) = - apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT)) +suspend fun openChat( + rhId: Long?, + chatType: ChatType, + apiId: Long, + contentTag: MsgContentTag? = null, + openAroundItemId: Long? = null +) = + apiLoadMessages( + rhId, + chatType, + apiId, + contentTag, + if (openAroundItemId != null) { + ChatPagination.Around(openAroundItemId, ChatPagination.INITIAL_COUNT) + } else { + ChatPagination.Initial(ChatPagination.INITIAL_COUNT) + }, + "", + openAroundItemId + ) suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) { withChats(contentTag) {