diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c9772090e3..51c9f102b7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -871,24 +871,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } - func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { - if case let .group(groupInfo) = self.chatInfo { - let p = groupInfo.fullGroupPreferences - return switch feature { - case .timedMessages: p.timedMessages.on - case .directMessages: p.directMessages.on(for: groupInfo.membership) - case .fullDelete: p.fullDelete.on - case .reactions: p.reactions.on - case .voice: p.voice.on(for: groupInfo.membership) - case .files: p.files.on(for: groupInfo.membership) - case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) - case .history: p.history.on - } - } else { - return true - } - } - public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 0b7de32a88..7c405b6346 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -21,7 +21,6 @@ struct ChatItemForwardingView: View { @State private var searchText: String = "" @FocusState private var searchFocused @State private var alert: SomeAlert? - @State private var hasSimplexLink_: Bool? private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) var body: some View { @@ -67,34 +66,6 @@ struct ChatItemForwardingView: View { } } - private func prohibitedByPref(_ chat: Chat) -> Bool { - // preference checks should match checks in compose view - let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files) - let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice) - return switch chat.chatInfo { - case .direct: voiceProhibited - case .group: simplexLinkProhibited || fileProhibited || voiceProhibited - case .local: false - case .contactRequest: false - case .contactConnection: false - case .invalidJSON: false - } - } - - private var hasSimplexLink: Bool { - if let hasSimplexLink_ { return hasSimplexLink_ } - let r = - if let mcText = ci.content.msgContent?.text, - let parsedMsg = parseSimpleXMarkdown(mcText) { - parsedMsgHasSimplexLink(parsedMsg) - } else { - false - } - hasSimplexLink_ = r - return r - } - private func emptyList() -> some View { Text("No filtered chats") .foregroundColor(theme.colors.secondary) @@ -102,7 +73,11 @@ struct ChatItemForwardingView: View { } @ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View { - let prohibited = prohibitedByPref(chat) + let prohibited = chat.prohibitedByPref( + hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text), + isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false, + isVoice: ci.content.msgContent?.isVoice ?? false + ) Button { if prohibited { alert = SomeAlert( diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5e2825d30c..aa033203dc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -43,6 +43,9 @@ struct ChatView: View { @State private var showGroupLinkSheet: Bool = false @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member + @State private var selectedChatItems: Set? = nil + @State private var showDeleteSelectedMessages: Bool = false + @State private var allowToDeleteSelectedMessagesForAll: Bool = false var body: some View { if #available(iOS 16.0, *) { @@ -80,25 +83,58 @@ struct ChatView: View { floatingButtons(counts: floatingButtonModel.unreadChatItemCounts) } connectingText() - ComposeView( - chat: chat, - composeState: $composeState, - keyboardVisible: $keyboardVisible - ) - .disabled(!cInfo.sendMsgEnabled) + if selectedChatItems == nil { + ComposeView( + chat: chat, + composeState: $composeState, + keyboardVisible: $keyboardVisible + ) + .disabled(!cInfo.sendMsgEnabled) + } else { + SelectedItemsBottomToolbar( + chatItems: ItemsModel.shared.reversedChatItems, + selectedChatItems: $selectedChatItems, + chatInfo: chat.chatInfo, + deleteItems: { forAll in + allowToDeleteSelectedMessagesForAll = forAll + showDeleteSelectedMessages = true + }, + moderateItems: { + if case let .group(groupInfo) = chat.chatInfo { + showModerateSelectedMessagesAlert(groupInfo) + } + } + ) + } } .navigationTitle(cInfo.chatViewName) .background(theme.colors.background) .navigationBarTitleDisplayMode(.inline) .environmentObject(theme) + .confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + if let selected = selectedChatItems { + deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) } + } + if allowToDeleteSelectedMessagesForAll { + Button(broadcastDeleteButtonText(chat), role: .destructive) { + if let selected = selectedChatItems { + allowToDeleteSelectedMessagesForAll = false + deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages) + } + } + } + } .onAppear { loadChat(chat: chat) initChatView() + selectedChatItems = nil } .onChange(of: chatModel.chatId) { cId in showChatInfoSheet = false stopAudioPlayer() if let cId { + selectedChatItems = nil if let c = chatModel.getChat(cId) { chat = c } @@ -138,7 +174,9 @@ struct ChatView: View { } .toolbar { ToolbarItem(placement: .principal) { - if case let .direct(contact) = cInfo { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { Button { Task { do { @@ -192,66 +230,76 @@ struct ChatView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") + } label: { + Text("Cancel") + } + } else { + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) } - .disabled(!contact.ready || !contact.active) } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } label: { - Image(systemName: "ellipsis") - } - } - case let .group(groupInfo): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - .appSheet(isPresented: $showAddMembersSheet) { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") } } - Menu { - searchButton() - ToggleNtfsButton(chat: chat) - } label: { - Image(systemName: "ellipsis") + case let .group(groupInfo): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + .appSheet(isPresented: $showAddMembersSheet) { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) + } + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } } + case .local: + searchButton() + default: + EmptyView() } - case .local: - searchButton() - default: - EmptyView() } } } @@ -553,6 +601,33 @@ struct ChatView: View { } } + private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) { + guard let count = selectedChatItems?.count, count > 0 else { return } + + AlertManager.shared.showAlert(Alert( + title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"), + message: Text( + groupInfo.fullGroupPreferences.fullDelete.on + ? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.") + : (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.") + ), + primaryButton: .destructive(Text("Delete")) { + if let selected = selectedChatItems { + deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages) + } + }, + secondaryButton: .cancel() + )) + } + + private func deletedSelectedMessages() async { + await MainActor.run { + withAnimation { + selectedChatItems = nil + } + } + } + private func loadChatItems(_ cInfo: ChatInfo) { Task { if loadingItems || firstPage { return } @@ -604,7 +679,8 @@ struct ChatView: View { maxWidth: maxWidth, composeState: $composeState, selectedMember: $selectedMember, - revealedChatItem: $revealedChatItem + revealedChatItem: $revealedChatItem, + selectedChatItems: $selectedChatItems ) } @@ -626,6 +702,8 @@ struct ChatView: View { @State private var chatItemInfo: ChatItemInfo? @State private var showForwardingSheet: Bool = false + @Binding var selectedChatItems: Set? + @State private var allowMenu: Bool = true var revealed: Bool { chatItem == revealedChatItem } @@ -642,9 +720,29 @@ struct ChatView: View { ForEach(items, id: \.1.viewId) { (i, ci) in let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] chatItemView(ci, nil, prev) + .overlay { + if let selected = selectedChatItems, ci.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(ci.id) + selectUnselectChatItem(select: !checked, ci) + } + } + } } } else { chatItemView(chatItem, range, prevItem) + .overlay { + if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(chatItem.id) + selectUnselectChatItem(select: !checked, chatItem) + } + } + } } } .onAppear { @@ -689,11 +787,11 @@ struct ChatView: View { if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo) = chat.chatInfo { let (prevMember, memCount): (GroupMember?, Int) = - if let range = range { - m.getPrevHiddenMember(member, range) - } else { - (nil, 1) - } + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { @@ -706,41 +804,64 @@ struct ChatView: View { .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) - .padding(.leading, memberImageSize + 14) + .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) .padding(.top, 7) } - HStack(alignment: .top, spacing: 8) { - ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background) - .onTapGesture { - if m.membersLoaded { - selectedMember = m.getGroupMember(member.groupMemberId) - } else { - Task { - await m.loadGroupMembers(groupInfo) { - selectedMember = m.getGroupMember(member.groupMemberId) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.trailing, 12) + } + HStack(alignment: .top, spacing: 8) { + ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background) + .onTapGesture { + if m.membersLoaded { + selectedMember = m.getGroupMember(member.groupMemberId) + } else { + Task { + await m.loadGroupMembers(groupInfo) { + selectedMember = m.getGroupMember(member.groupMemberId) + } } } } - } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) - } - chatItemWithMenu(ci, range, maxWidth) + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) + } + chatItemWithMenu(ci, range, maxWidth) + } } } .padding(.bottom, 5) .padding(.trailing) .padding(.leading, 12) } else { - chatItemWithMenu(ci, range, maxWidth) - .padding(.bottom, 5) - .padding(.trailing) - .padding(.leading, memberImageSize + 8 + 12) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } + chatItemWithMenu(ci, range, maxWidth) + .padding(.trailing) + .padding(.leading, memberImageSize + 8 + 12) + } + .padding(.bottom, 5) } } else { - chatItemWithMenu(ci, range, maxWidth) - .padding(.horizontal) - .padding(.bottom, 5) + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + if chat.chatInfo.chatType == .group { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } else { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading) + } + } + chatItemWithMenu(ci, range, maxWidth) + .padding(.horizontal) + } + .padding(.bottom, 5) } } @@ -775,17 +896,17 @@ struct ChatView: View { } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { - deleteMessage(.cidmInternal) + deleteMessage(.cidmInternal, moderate: false) } if let di = deletingItem, di.meta.deletable && !di.localNote { - Button(broadcastDeleteButtonText, role: .destructive) { - deleteMessage(.cidmBroadcast) + Button(broadcastDeleteButtonText(chat), role: .destructive) { + deleteMessage(.cidmBroadcast, moderate: false) } } } .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { - deleteMessages() + deleteMessages(chat, deletingItems, moderate: false) } } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) @@ -894,7 +1015,7 @@ struct ChatView: View { if !live || !ci.meta.isLive { deleteButton(ci) } - if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { + if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd { moderateButton(ci, groupInfo) } } else if ci.meta.itemDeleted != nil { @@ -918,6 +1039,10 @@ struct ChatView: View { } else { EmptyView() } + if selectedChatItems == nil && ci.canBeDeletedForSelf { + Divider() + selectButton(ci) + } } var replyButton: Button { @@ -1090,6 +1215,21 @@ struct ChatView: View { } } + private func selectButton(_ ci: ChatItem) -> Button { + Button { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + selectUnselectChatItem(select: true, ci) + } + } + } label: { + Label( + NSLocalizedString("Select", comment: "chat item action"), + systemImage: "checkmark.circle" + ) + } + } + private func viewInfoButton(_ ci: ChatItem) -> Button { Button { Task { @@ -1200,7 +1340,7 @@ struct ChatView: View { ), primaryButton: .destructive(Text("Delete")) { deletingItem = ci - deleteMessage(.cidmBroadcast) + deleteMessage(.cidmBroadcast, moderate: true) }, secondaryButton: .cancel() )) @@ -1251,47 +1391,46 @@ struct ChatView: View { } } - private var broadcastDeleteButtonText: LocalizedStringKey { - chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" - } - var deleteMessagesTitle: LocalizedStringKey { let n = deletingItems.count return n == 1 ? "Delete message?" : "Delete \(n) messages?" } - private func deleteMessages() { - let itemIds = deletingItems - if itemIds.count > 0 { - let chatInfo = chat.chatInfo - Task { - do { - let deletedItems = try await apiDeleteChatItems( - type: chatInfo.chatType, - id: chatInfo.apiId, - itemIds: itemIds, - mode: .cidmInternal - ) - await MainActor.run { - for di in deletedItems { - m.removeChatItem(chatInfo, di.deletedChatItem.chatItem) - } - } - } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) { + selectedChatItems = selectedChatItems ?? [] + var itemIds: [Int64] = [] + if !revealed, + let currIndex = m.getChatItemIndex(ci), + let ciCategory = ci.mergeCategory { + let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) + if let range = itemsRange(currIndex, prevHidden) { + for i in range { + itemIds.append(ItemsModel.shared.reversedChatItems[i].id) } + } else { + itemIds.append(ci.id) } + } else { + itemIds.append(ci.id) + } + if select { + if let sel = selectedChatItems { + selectedChatItems = sel.union(itemIds) + } + } else { + itemIds.forEach { selectedChatItems?.remove($0) } } } - private func deleteMessage(_ mode: CIDeleteMode) { + private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) { logger.debug("ChatView deleteMessage") Task { logger.debug("ChatView deleteMessage: in Task") do { if let di = deletingItem { let r = if case .cidmBroadcast = mode, - let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { + moderate, + let (groupInfo, _) = di.memberToModerate(chat.chatInfo) { try await apiDeleteMemberChatItems( groupId: groupInfo.apiId, itemIds: [di.id] @@ -1320,6 +1459,68 @@ struct ChatView: View { } } } + + private struct SelectedChatItem: View { + @EnvironmentObject var theme: AppTheme + var ciId: Int64 + @Binding var selectedChatItems: Set? + @State var checked: Bool = false + var body: some View { + Image(systemName: checked ? "checkmark.circle.fill" : "circle") + .resizable() + .foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel)) + .frame(width: 24, height: 24) + .onAppear { + checked = selectedChatItems?.contains(ciId) == true + } + .onChange(of: selectedChatItems) { selected in + checked = selected?.contains(ciId) == true + } + } + } + } +} + +private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { + chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" +} + +private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { + let itemIds = deletingItems + if itemIds.count > 0 { + let chatInfo = chat.chatInfo + Task { + do { + let deletedItems = if case .cidmBroadcast = mode, + moderate, + case .group = chat.chatInfo { + try await apiDeleteMemberChatItems( + groupId: chatInfo.apiId, + itemIds: itemIds + ) + } else { + try await apiDeleteChatItems( + type: chatInfo.chatType, + id: chatInfo.apiId, + itemIds: itemIds, + mode: mode + ) + } + + await MainActor.run { + for di in deletedItems { + if let toItem = di.toChatItem { + _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem) + } else { + ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) + } + } + } + await onSuccess() + } catch { + logger.error("ChatView.deleteMessages error: \(error.localizedDescription)") + } + } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index e19672c883..1364958172 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1120,10 +1120,6 @@ struct ComposeView: View { } } -func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool { - parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false }) -} - struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift new file mode 100644 index 0000000000..497a1bf5b5 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -0,0 +1,130 @@ +// +// SelectableChatItemToolbars.swift +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 30.07.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SelectedItemsTopToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @Binding var selectedChatItems: Set? + + var body: some View { + let count = selectedChatItems?.count ?? 0 + return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline) + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } +} + +struct SelectedItemsBottomToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + let chatItems: [ChatItem] + @Binding var selectedChatItems: Set? + var chatInfo: ChatInfo + // Bool - delete for everyone is possible + var deleteItems: (Bool) -> Void + var moderateItems: () -> Void + //var shareItems: () -> Void + @State var deleteEnabled: Bool = false + @State var deleteForEveryoneEnabled: Bool = false + + @State var canModerate: Bool = false + @State var moderateEnabled: Bool = false + + @State var allButtonsDisabled = false + + var body: some View { + VStack(spacing: 0) { + Divider() + + HStack(alignment: .center) { + Button { + deleteItems(deleteForEveryoneEnabled) + } label: { + Image(systemName: "trash") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red) + } + .disabled(!deleteEnabled || allButtonsDisabled) + + Spacer() + Button { + moderateItems() + } label: { + Image(systemName: "flag") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red) + } + .disabled(!moderateEnabled || allButtonsDisabled) + .opacity(canModerate ? 1 : 0) + + + Spacer() + Button { + //shareItems() + } label: { + Image(systemName: "square.and.arrow.up") + .resizable() + .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary) + } + .disabled(allButtonsDisabled) + .opacity(0) + } + .frame(maxHeight: .infinity) + .padding([.leading, .trailing], 12) + } + .onAppear { + recheckItems(chatInfo, chatItems, selectedChatItems) + } + .onChange(of: chatInfo) { info in + recheckItems(info, chatItems, selectedChatItems) + } + .onChange(of: chatItems) { items in + recheckItems(chatInfo, items, selectedChatItems) + } + .onChange(of: selectedChatItems) { selected in + recheckItems(chatInfo, chatItems, selected) + } + .frame(height: 55.5) + .background(.thinMaterial) + } + + private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set?) { + let count = selectedItems?.count ?? 0 + allButtonsDisabled = count == 0 || count > 20 + canModerate = possibleToModerate(chatInfo) + if let selected = selectedItems { + (deleteEnabled, deleteForEveryoneEnabled, moderateEnabled, _, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in + if selected.contains(ci.id) { + var (de, dee, me, onlyOwnGroupItems, sel) = r + de = de && ci.canBeDeletedForSelf + dee = dee && ci.meta.deletable && !ci.localNote + onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd + me = me && !onlyOwnGroupItems && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil + sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list + return (de, dee, me, onlyOwnGroupItems, sel) + } else { + return r + } + } + } + } + + private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { + return switch chatInfo { + case let .group(groupInfo): + groupInfo.membership.memberRole >= .admin + default: false + } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index e7145711af..4e6d63ffe0 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -9,8 +9,6 @@ import SwiftUI import SimpleXChat -let defaultProfileImageCorner = 22.5 - struct ProfileImage: View { @EnvironmentObject var theme: AppTheme var imageStr: String? = nil @@ -34,26 +32,6 @@ struct ProfileImage: View { } } -private let squareToCircleRatio = 0.935 - -private let radiusFactor = (1 - squareToCircleRatio) / 50 - -@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View { - let v = img.resizable() - if radius >= 50 { - v.frame(width: size, height: size).clipShape(Circle()) - } else if radius <= 0 { - let sz = size * squareToCircleRatio - v.frame(width: sz, height: sz).padding((size - sz) / 2) - } else { - let sz = size * (squareToCircleRatio + radius * radiusFactor) - v.frame(width: sz, height: sz) - .clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous)) - .padding((size - sz) / 2) - } -} - - extension Color { func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color { return self diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index ac81c42b2e..ab5da53a9c 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -76,7 +76,7 @@ extension AppSettings { c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!) c.iosCallKitEnabled = callKitEnabledGroupDefault.get() c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) - c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) + c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) c.uiColorScheme = currentThemeDefault.get() c.uiDarkColorScheme = systemDarkThemeDefault.get() c.uiCurrentThemeIds = currentThemeIdsDefault.get() diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 18cf5fa199..5d667655e3 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -143,7 +143,8 @@ struct AppearanceSettings: View { Text("Themes") .foregroundColor(theme.colors.secondary) } - .onChange(of: profileImageCornerRadius) { _ in + .onChange(of: profileImageCornerRadius) { cornerRadius in + profileImageCornerRadiusGroupDefault.set(cornerRadius) saveThemeToDatabase(nil) } .onChange(of: colorMode) { mode in diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 6b1a619c18..8bd9072f7f 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -70,6 +70,9 @@ struct PrivacySettings: View { Section { settingsRow("network", color: theme.colors.secondary) { Toggle("Send link previews", isOn: $useLinkPreviews) + .onChange(of: useLinkPreviews) { linkPreviews in + privacyLinkPreviewsGroupDefault.set(linkPreviews) + } } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) @@ -365,6 +368,7 @@ struct SimplexLockView: View { @State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT) @State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT) @AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = "" + @AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false @State private var performLAToggleReset = false @State private var performLAModeReset = false @State private var performLASelfDestructReset = false @@ -436,6 +440,12 @@ struct SimplexLockView: View { } } + if performLA { + Section("Share to SimpleX") { + Toggle("Allow sharing", isOn: $allowShareExtension) + } + } + if performLA && laMode == .passcode { Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) { Toggle(isOn: $selfDestruct) { @@ -460,6 +470,7 @@ struct SimplexLockView: View { } } .onChange(of: performLA) { performLAToggle in + performLAGroupDefault.set(performLAToggle) prefLANoticeShown = true if performLAToggleReset { performLAToggleReset = false diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 0a83db1e5c..fcbef61888 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice" let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown" -let DEFAULT_PERFORM_LA = "performLocalAuthentication" +let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group let DEFAULT_LA_MODE = "localAuthenticationMode" let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay" let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct" @@ -28,7 +28,7 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead -let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" +let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" @@ -165,6 +165,9 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) + performLAGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)) + privacyLinkPreviewsGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)) + profileImageCornerRadiusGroupDefault.set(UserDefaults.standard.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)) } public class StringDefault { diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 2edca32fc5..6f1ed52623 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -772,6 +772,10 @@ Разреши изпращането на изчезващи съобщения. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Позволи необратимо изтриване на изпратените съобщения. (24 часа) @@ -1857,6 +1861,10 @@ This is your own one-time link! Изтрий chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Изтриване на %lld съобщения? @@ -4304,6 +4312,10 @@ This is your link for group %@! Несъвместим! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Известия @@ -5441,6 +5453,10 @@ Enable in *Network & servers* settings. Select Избери + chat item action + + + Selected %lld No comment provided by engineer. @@ -5790,6 +5806,10 @@ Enable in *Network & servers* settings. Сподели този еднократен линк за връзка No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Сподели с контактите @@ -6243,6 +6263,14 @@ It can happen because of some bug or when the connection is compromised.Съобщението ще бъде маркирано като модерирано за всички членове. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Ново поколение поверителни съобщения @@ -8426,10 +8454,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8442,6 +8482,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8450,6 +8494,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8458,10 +8506,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8518,10 +8562,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8530,10 +8582,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8546,6 +8594,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index cf88244f88..15d80de7b5 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -749,6 +749,10 @@ Povolit odesílání mizících zpráv. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Povolit nevratné smazání odeslaných zpráv. (24 hodin) @@ -1787,6 +1791,10 @@ This is your own one-time link! Smazat chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? No comment provided by engineer. @@ -4143,6 +4151,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Oznámení @@ -5240,6 +5252,10 @@ Enable in *Network & servers* settings. Select Vybrat + chat item action + + + Selected %lld No comment provided by engineer. @@ -5584,6 +5600,10 @@ Enable in *Network & servers* settings. Share this 1-time invite link No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Sdílet s kontakty @@ -6026,6 +6046,14 @@ Může se to stát kvůli nějaké chybě, nebo pokud je spojení kompromitován Zpráva bude pro všechny členy označena jako moderovaná. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Nová generace soukromých zpráv @@ -8123,10 +8151,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8139,6 +8179,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8147,6 +8191,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8155,10 +8203,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8215,10 +8259,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8227,10 +8279,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8243,6 +8291,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index b495e510f9..95df9edf6d 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -783,6 +783,10 @@ Das Senden von verschwindenden Nachrichten erlauben. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Unwiederbringliches löschen von gesendeten Nachrichten erlauben. (24 Stunden) @@ -1894,6 +1898,10 @@ Das ist Ihr eigener Einmal-Link! Löschen chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? %lld Nachrichten löschen? @@ -4387,6 +4395,10 @@ Das ist Ihr Link für die Gruppe %@! Nicht kompatibel! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Benachrichtigungen @@ -5560,6 +5572,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Select Auswählen + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Teilen Sie diesen Einmal-Einladungslink No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Mit Kontakten teilen @@ -6392,6 +6412,14 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Die nächste Generation von privatem Messaging @@ -8609,10 +8637,22 @@ Zuletzt empfangene Nachricht: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ Zuletzt empfangene Nachricht: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ Zuletzt empfangene Nachricht: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ Zuletzt empfangene Nachricht: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ Zuletzt empfangene Nachricht: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ Zuletzt empfangene Nachricht: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ Zuletzt empfangene Nachricht: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index ca5c2670d7..3fc3e6037b 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -783,6 +783,11 @@ Allow sending disappearing messages. No comment provided by engineer. + + Allow sharing + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Allow to irreversibly delete sent messages. (24 hours) @@ -1896,6 +1901,11 @@ This is your own one-time link! Delete chat item action + + Delete %lld messages of members? + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Delete %lld messages? @@ -4398,6 +4408,11 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Nothing selected + Nothing selected + No comment provided by engineer. + Notifications Notifications @@ -5571,6 +5586,11 @@ Enable in *Network & servers* settings. Select Select + chat item action + + + Selected %lld + Selected %lld No comment provided by engineer. @@ -5938,6 +5958,11 @@ Enable in *Network & servers* settings. Share this 1-time invite link No comment provided by engineer. + + Share to SimpleX + Share to SimpleX + No comment provided by engineer. + Share with contacts Share with contacts @@ -6405,6 +6430,16 @@ It can happen because of some bug or when the connection is compromised.The message will be marked as moderated for all members. No comment provided by engineer. + + The messages will be deleted for all members. + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging The next generation of private messaging @@ -8626,11 +8661,26 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + App is locked! + No comment provided by engineer. + + + Cancel + Cancel + No comment provided by engineer. + Cannot access keychain to save database password Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + Cannot forward message + No comment provided by engineer. + Comment Comment @@ -8646,6 +8696,11 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + Database encrypted! + No comment provided by engineer. + Database error Database error @@ -8656,6 +8711,11 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required Database upgrade required @@ -8666,11 +8726,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - Encrypted database - No comment provided by engineer. - Error preparing file Error preparing file @@ -8741,11 +8796,21 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File Sending File @@ -8756,11 +8821,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ Unknown database error: %@ @@ -8776,6 +8836,11 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 074d561640..3e33bb5d39 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -783,6 +783,10 @@ Permites el envío de mensajes temporales. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Se permite la eliminación irreversible de mensajes. (24 horas) @@ -1894,6 +1898,10 @@ This is your own one-time link! Eliminar chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? ¿Elimina %lld mensajes? @@ -4387,6 +4395,10 @@ This is your link for group %@! ¡No compatible! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Notificaciones @@ -5560,6 +5572,10 @@ Actívalo en ajustes de *Servidores y Redes*. Select Seleccionar + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Actívalo en ajustes de *Servidores y Redes*. Comparte este enlace de un solo uso No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Compartir con contactos @@ -6392,6 +6412,14 @@ Puede ocurrir por algún bug o cuando la conexión está comprometida. El mensaje será marcado como moderado para todos los miembros. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging La nueva generación de mensajería privada @@ -8609,10 +8637,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 99ac470f53..5b6a1fab80 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -744,6 +744,10 @@ Salli katoavien viestien lähettäminen. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Salli lähetettyjen viestien peruuttamaton poistaminen. (24 tuntia) @@ -1780,6 +1784,10 @@ This is your own one-time link! Poista chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? No comment provided by engineer. @@ -4132,6 +4140,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Ilmoitukset @@ -5228,6 +5240,10 @@ Enable in *Network & servers* settings. Select Valitse + chat item action + + + Selected %lld No comment provided by engineer. @@ -5571,6 +5587,10 @@ Enable in *Network & servers* settings. Share this 1-time invite link No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Jaa kontaktien kanssa @@ -6012,6 +6032,14 @@ Tämä voi johtua jostain virheestä tai siitä, että yhteys on vaarantunut.Viesti merkitään moderoiduksi kaikille jäsenille. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Seuraavan sukupolven yksityisviestit @@ -8107,10 +8135,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8123,6 +8163,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8131,6 +8175,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8139,10 +8187,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8199,10 +8243,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8211,10 +8263,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8227,6 +8275,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 3591ab15ab..e3c45aeebf 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -783,6 +783,10 @@ Autorise l’envoi de messages éphémères. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Autoriser la suppression irréversible de messages envoyés. (24 heures) @@ -1894,6 +1898,10 @@ Il s'agit de votre propre lien unique ! Supprimer chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Supprimer %lld messages ? @@ -4387,6 +4395,10 @@ Voici votre lien pour le groupe %@ ! Non compatible ! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Notifications @@ -5560,6 +5572,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Select Choisir + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Activez-le dans les paramètres *Réseau et serveurs*. Partager ce lien d'invitation unique No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Partager avec vos contacts @@ -6391,6 +6411,14 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. Le message sera marqué comme modéré pour tous les membres. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging La nouvelle génération de messagerie privée @@ -8608,10 +8636,22 @@ dernier message reçu : %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8624,6 +8664,10 @@ dernier message reçu : %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8632,6 +8676,10 @@ dernier message reçu : %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8640,10 +8688,6 @@ dernier message reçu : %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8700,10 +8744,18 @@ dernier message reçu : %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8712,10 +8764,6 @@ dernier message reçu : %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8728,6 +8776,10 @@ dernier message reçu : %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index b8a8086d17..d6ac48d974 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -783,6 +783,10 @@ Az eltűnő üzenetek küldése engedélyezve van. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Elküldött üzenetek végleges törlésének engedélyezése. (24 óra) @@ -1894,6 +1898,10 @@ Ez az egyszer használatos hivatkozása! Törlés chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Töröl %lld üzenetet? @@ -4387,6 +4395,10 @@ Ez az ön hivatkozása a(z) %@ csoporthoz! Nem kompatibilis! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Értesítések @@ -5560,6 +5572,10 @@ Engedélyezze a beállításokban a *Hálózat és kiszolgálók* menüpontban.< Select Választás + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Engedélyezze a beállításokban a *Hálózat és kiszolgálók* menüpontban.< Egyszer használatos meghívó hivatkozás megosztása No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Megosztás ismerősökkel @@ -6392,6 +6412,14 @@ Ez valamilyen hiba, vagy sérült kapcsolat esetén fordulhat elő. Az üzenet minden tag számára moderáltként lesz megjelölve. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging A privát üzenetküldés következő generációja @@ -8609,10 +8637,22 @@ utoljára fogadott üzenet: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ utoljára fogadott üzenet: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ utoljára fogadott üzenet: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ utoljára fogadott üzenet: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ utoljára fogadott üzenet: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ utoljára fogadott üzenet: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ utoljára fogadott üzenet: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index dfdbabf84f..e196830ac4 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -783,6 +783,10 @@ Permetti l'invio di messaggi a tempo. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Permetti di eliminare irreversibilmente i messaggi inviati. (24 ore) @@ -1894,6 +1898,10 @@ Questo è il tuo link una tantum! Elimina chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Eliminare %lld messaggi? @@ -4387,6 +4395,10 @@ Questo è il tuo link per il gruppo %@! Non compatibile! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Notifiche @@ -5560,6 +5572,10 @@ Attivalo nelle impostazioni *Rete e server*. Select Seleziona + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Attivalo nelle impostazioni *Rete e server*. Condividi questo link di invito una tantum No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Condividi con i contatti @@ -6392,6 +6412,14 @@ Può accadere a causa di qualche bug o quando la connessione è compromessa.Il messaggio sarà segnato come moderato per tutti i membri. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging La nuova generazione di messaggistica privata @@ -8609,10 +8637,22 @@ ultimo msg ricevuto: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ ultimo msg ricevuto: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ ultimo msg ricevuto: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ ultimo msg ricevuto: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ ultimo msg ricevuto: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ ultimo msg ricevuto: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ ultimo msg ricevuto: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index f9127d93c6..46ef837f9f 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -762,6 +762,10 @@ 消えるメッセージの送信を許可する。 No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) 送信済みメッセージの永久削除を許可する。(24時間) @@ -1804,6 +1808,10 @@ This is your own one-time link! 削除 chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? No comment provided by engineer. @@ -4157,6 +4165,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications 通知 @@ -5253,6 +5265,10 @@ Enable in *Network & servers* settings. Select 選択 + chat item action + + + Selected %lld No comment provided by engineer. @@ -5589,6 +5605,10 @@ Enable in *Network & servers* settings. Share this 1-time invite link No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts 連絡先と共有する @@ -6031,6 +6051,14 @@ It can happen because of some bug or when the connection is compromised.メッセージは、すべてのメンバーに対してモデレートされたものとして表示されます。 No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging 次世代のプライバシー・メッセンジャー @@ -8125,10 +8153,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8141,6 +8181,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8149,6 +8193,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8157,10 +8205,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8217,10 +8261,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8229,10 +8281,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8245,6 +8293,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index c885e32c11..6a5eb34a47 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -783,6 +783,10 @@ Toestaan dat verdwijnende berichten worden verzonden. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Sta toe om verzonden berichten onomkeerbaar te verwijderen. (24 uur) @@ -1894,6 +1898,10 @@ Dit is uw eigen eenmalige link! Verwijderen chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? %lld berichten verwijderen? @@ -4387,6 +4395,10 @@ Dit is jouw link voor groep %@! Niet compatibel! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Meldingen @@ -5560,6 +5572,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Select Selecteer + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Schakel dit in in *Netwerk en servers*-instellingen. Deel deze eenmalige uitnodigingslink No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Delen met contacten @@ -6392,6 +6412,14 @@ Het kan gebeuren vanwege een bug of wanneer de verbinding is aangetast. Het bericht wordt gemarkeerd als gemodereerd voor alle leden. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging De volgende generatie privéberichten @@ -8609,10 +8637,22 @@ laatst ontvangen bericht: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ laatst ontvangen bericht: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ laatst ontvangen bericht: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ laatst ontvangen bericht: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ laatst ontvangen bericht: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ laatst ontvangen bericht: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ laatst ontvangen bericht: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 040eef3d3d..a9d342d549 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -783,6 +783,10 @@ Zezwól na wysyłanie znikających wiadomości. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Zezwól na nieodwracalne usunięcie wysłanych wiadomości. (24 godziny) @@ -1894,6 +1898,10 @@ To jest twój jednorazowy link! Usuń chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Usunąć %lld wiadomości? @@ -4387,6 +4395,10 @@ To jest twój link do grupy %@! Nie kompatybilny! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Powiadomienia @@ -5560,6 +5572,10 @@ Włącz w ustawianiach *Sieć i serwery* . Select Wybierz + chat item action + + + Selected %lld No comment provided by engineer. @@ -5927,6 +5943,10 @@ Włącz w ustawianiach *Sieć i serwery* . Udostępnij ten jednorazowy link No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Udostępnij kontaktom @@ -6392,6 +6412,14 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom Wiadomość zostanie oznaczona jako moderowana dla wszystkich członków. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Następna generacja prywatnych wiadomości @@ -8609,10 +8637,22 @@ ostatnia otrzymana wiadomość: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8625,6 +8665,10 @@ ostatnia otrzymana wiadomość: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8633,6 +8677,10 @@ ostatnia otrzymana wiadomość: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8641,10 +8689,6 @@ ostatnia otrzymana wiadomość: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8701,10 +8745,18 @@ ostatnia otrzymana wiadomość: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8713,10 +8765,6 @@ ostatnia otrzymana wiadomość: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8729,6 +8777,10 @@ ostatnia otrzymana wiadomość: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index cae52da21a..7789f9ddcf 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -773,6 +773,10 @@ Разрешить посылать исчезающие сообщения. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Разрешить необратимо удалять отправленные сообщения. (24 часа) @@ -1861,6 +1865,10 @@ This is your own one-time link! Удалить chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Удалить %lld сообщений? @@ -4320,6 +4328,10 @@ This is your link for group %@! Несовместимая версия! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Уведомления @@ -5464,6 +5476,10 @@ Enable in *Network & servers* settings. Select Выбрать + chat item action + + + Selected %lld No comment provided by engineer. @@ -5817,6 +5833,10 @@ Enable in *Network & servers* settings. Поделиться одноразовой ссылкой-приглашением No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Поделиться с контактами @@ -6273,6 +6293,14 @@ It can happen because of some bug or when the connection is compromised.Сообщение будет помечено как удаленное для всех членов группы. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Новое поколение приватных сообщений @@ -8466,10 +8494,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8482,6 +8522,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8490,6 +8534,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8498,10 +8546,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8558,10 +8602,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8571,10 +8623,6 @@ last received msg: %2$@ Поделиться No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8587,6 +8635,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 9a7052c24c..0735f1b288 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -736,6 +736,10 @@ อนุญาตให้ส่งข้อความที่จะหายไปหลังปิดแชท (disappearing message) No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) อนุญาตให้ลบข้อความที่ส่งไปแล้วอย่างถาวร @@ -1769,6 +1773,10 @@ This is your own one-time link! ลบ chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? No comment provided by engineer. @@ -4113,6 +4121,10 @@ This is your link for group %@! Not compatible! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications การแจ้งเตือน @@ -5207,6 +5219,10 @@ Enable in *Network & servers* settings. Select เลือก + chat item action + + + Selected %lld No comment provided by engineer. @@ -5548,6 +5564,10 @@ Enable in *Network & servers* settings. Share this 1-time invite link No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts แชร์กับผู้ติดต่อ @@ -5988,6 +6008,14 @@ It can happen because of some bug or when the connection is compromised.ข้อความจะถูกทำเครื่องหมายว่ากลั่นกรองสำหรับสมาชิกทุกคน No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging การส่งข้อความส่วนตัวรุ่นต่อไป @@ -8075,10 +8103,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8091,6 +8131,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8099,6 +8143,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8107,10 +8155,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8167,10 +8211,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8179,10 +8231,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8195,6 +8243,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 89da2dde22..2781891aa6 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -773,6 +773,10 @@ Kendiliğinden yok olan mesajlar göndermeye izin ver. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Gönderilen mesajların kalıcı olarak silinmesine izin ver. (24 saat içinde) @@ -1862,6 +1866,10 @@ Bu senin kendi tek kullanımlık bağlantın! Sil chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? %lld mesaj silinsin mi? @@ -4322,6 +4330,10 @@ Bu senin grup için bağlantın %@! Uyumlu değil! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Bildirimler @@ -5466,6 +5478,10 @@ Enable in *Network & servers* settings. Select Seç + chat item action + + + Selected %lld No comment provided by engineer. @@ -5819,6 +5835,10 @@ Enable in *Network & servers* settings. Bu tek kullanımlık bağlantı davetini paylaş No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Kişilerle paylaş @@ -6275,6 +6295,14 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. Mesaj tüm üyeler için yönetilmiş olarak işaretlenecektir. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Gizli mesajlaşmanın yeni nesli @@ -8471,10 +8499,22 @@ son alınan msj: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8487,6 +8527,10 @@ son alınan msj: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8495,6 +8539,10 @@ son alınan msj: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8503,10 +8551,6 @@ son alınan msj: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8563,10 +8607,18 @@ son alınan msj: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8575,10 +8627,6 @@ son alınan msj: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8591,6 +8639,10 @@ son alınan msj: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 5e46e93fc3..3d95496a67 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -773,6 +773,10 @@ Дозволити надсилання зникаючих повідомлень. No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) Дозволяє безповоротно видаляти надіслані повідомлення. (24 години) @@ -1862,6 +1866,10 @@ This is your own one-time link! Видалити chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? Видалити %lld повідомлень? @@ -4322,6 +4330,10 @@ This is your link for group %@! Не сумісні! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications Сповіщення @@ -5466,6 +5478,10 @@ Enable in *Network & servers* settings. Select Виберіть + chat item action + + + Selected %lld No comment provided by engineer. @@ -5819,6 +5835,10 @@ Enable in *Network & servers* settings. Поділіться цим одноразовим посиланням-запрошенням No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts Поділіться з контактами @@ -6275,6 +6295,14 @@ It can happen because of some bug or when the connection is compromised.Повідомлення буде позначено як модероване для всіх учасників. No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging Наступне покоління приватних повідомлень @@ -8471,10 +8499,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8487,6 +8527,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8495,6 +8539,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8503,10 +8551,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8563,10 +8607,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8575,10 +8627,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8591,6 +8639,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 2d0559e1c9..097de15cb3 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -759,6 +759,10 @@ 允许发送限时消息。 No comment provided by engineer. + + Allow sharing + No comment provided by engineer. + Allow to irreversibly delete sent messages. (24 hours) 允许不可撤回地删除已发送消息。 @@ -1835,6 +1839,10 @@ This is your own one-time link! 删除 chat item action + + Delete %lld messages of members? + No comment provided by engineer. + Delete %lld messages? No comment provided by engineer. @@ -4263,6 +4271,10 @@ This is your link for group %@! 不兼容! No comment provided by engineer. + + Nothing selected + No comment provided by engineer. + Notifications 通知 @@ -5391,6 +5403,10 @@ Enable in *Network & servers* settings. Select 选择 + chat item action + + + Selected %lld No comment provided by engineer. @@ -5740,6 +5756,10 @@ Enable in *Network & servers* settings. 分享此一次性邀请链接 No comment provided by engineer. + + Share to SimpleX + No comment provided by engineer. + Share with contacts 与联系人分享 @@ -6193,6 +6213,14 @@ It can happen because of some bug or when the connection is compromised.该消息将对所有成员标记为已被管理员移除。 No comment provided by engineer. + + The messages will be deleted for all members. + No comment provided by engineer. + + + The messages will be marked as moderated for all members. + No comment provided by engineer. + The next generation of private messaging 下一代私密通讯软件 @@ -8353,10 +8381,22 @@ last received msg: %2$@ %@ No comment provided by engineer. + + App is locked! + No comment provided by engineer. + + + Cancel + No comment provided by engineer. + Cannot access keychain to save database password No comment provided by engineer. + + Cannot forward message + No comment provided by engineer. + Comment No comment provided by engineer. @@ -8369,6 +8409,10 @@ last received msg: %2$@ Database downgrade required No comment provided by engineer. + + Database encrypted! + No comment provided by engineer. + Database error No comment provided by engineer. @@ -8377,6 +8421,10 @@ last received msg: %2$@ Database passphrase is different from saved in the keychain. No comment provided by engineer. + + Database passphrase is required to open chat. + No comment provided by engineer. + Database upgrade required No comment provided by engineer. @@ -8385,10 +8433,6 @@ last received msg: %2$@ Dismiss Sheet No comment provided by engineer. - - Encrypted database - No comment provided by engineer. - Error preparing file No comment provided by engineer. @@ -8445,10 +8489,18 @@ last received msg: %2$@ Open the app to upgrade the database. No comment provided by engineer. + + Passphrase + No comment provided by engineer. + Please create a profile in the SimpleX app No comment provided by engineer. + + Selected chat preferences prohibit this message. + No comment provided by engineer. + Sending File No comment provided by engineer. @@ -8457,10 +8509,6 @@ last received msg: %2$@ Share No comment provided by engineer. - - Sharing is not supported when passphrase is not stored in KeyChain. - No comment provided by engineer. - Unknown database error: %@ No comment provided by engineer. @@ -8473,6 +8521,10 @@ last received msg: %2$@ Wrong database passphrase No comment provided by engineer. + + You can allow sharing in Privacy & Security / SimpleX Lock settings. + No comment provided by engineer. + diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 35d26dea35..0297aa6557 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -19,14 +19,16 @@ private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000 class ShareModel: ObservableObject { @Published var sharedContent: SharedContent? - @Published var chats = Array() - @Published var profileImages = Dictionary() - @Published var search = String() - @Published var comment = String() + @Published var chats: [ChatData] = [] + @Published var profileImages: [ChatInfo.ID: UIImage] = [:] + @Published var search = "" + @Published var comment = "" @Published var selected: ChatData? @Published var isLoaded = false @Published var bottomBar: BottomBar = .loadingSpinner @Published var errorAlert: ErrorAlert? + @Published var hasSimplexLink = false + @Published var alertRequiresPassword = false enum BottomBar { case sendButton @@ -48,9 +50,22 @@ class ShareModel: ObservableObject { private var itemProvider: NSItemProvider? - var isSendDisbled: Bool { sharedContent == nil || selected == nil } + var isSendDisbled: Bool { sharedContent == nil || selected == nil || isProhibited(selected) } + + var isLinkPreview: Bool { + switch sharedContent { + case .url: true + default: false + } + } - var filteredChats: Array { + func isProhibited(_ chat: ChatData?) -> Bool { + if let chat, let sharedContent { + sharedContent.prohibited(in: chat, hasSimplexLink: hasSimplexLink) + } else { false } + } + + var filteredChats: [ChatData] { search.isEmpty ? filterChatsToForwardTo(chats: chats) : filterChatsToForwardTo(chats: chats) @@ -58,6 +73,10 @@ class ShareModel: ObservableObject { } func setup(context: NSExtensionContext) { + if performLAGroupDefault.get() && !allowShareExtensionGroupDefault.get() { + errorAlert = ErrorAlert(title: "App is locked!", message: "You can allow sharing in Privacy & Security / SimpleX Lock settings.") + return + } if let item = context.inputItems.first as? NSExtensionItem, let itemProvider = item.attachments?.first { self.itemProvider = itemProvider @@ -67,43 +86,47 @@ class ShareModel: ObservableObject { apiSuspendChat(expired: $0) } } - // Init Chat - Task { - if let e = initChat() { - await MainActor.run { errorAlert = e } - } else { - // Load Chats - Task { - switch fetchChats() { - case let .success(chats): - // Decode base64 images on background thread - let profileImages = chats.reduce(into: Dictionary()) { dict, chatData in - if let profileImage = chatData.chatInfo.image, - let uiImage = UIImage(base64Encoded: profileImage) { - dict[chatData.id] = uiImage - } + setup() + } + } + + func setup(with dbKey: String? = nil) { + // Init Chat + Task { + if let e = initChat(with: dbKey) { + await MainActor.run { errorAlert = e } + } else { + // Load Chats + Task { + switch fetchChats() { + case let .success(chats): + // Decode base64 images on background thread + let profileImages = chats.reduce(into: Dictionary()) { dict, chatData in + if let profileImage = chatData.chatInfo.image, + let uiImage = UIImage(base64Encoded: profileImage) { + dict[chatData.id] = uiImage } - await MainActor.run { - self.chats = chats - self.profileImages = profileImages - withAnimation { isLoaded = true } - } - case let .failure(error): - await MainActor.run { errorAlert = error } } + await MainActor.run { + self.chats = chats + self.profileImages = profileImages + withAnimation { isLoaded = true } + } + case let .failure(error): + await MainActor.run { errorAlert = error } } - // Process Attachment - Task { - switch await self.itemProvider!.sharedContent() { - case let .success(chatItemContent): - await MainActor.run { - self.sharedContent = chatItemContent - self.bottomBar = .sendButton - if case let .text(string) = chatItemContent { comment = string } - } - case let .failure(errorAlert): - await MainActor.run { self.errorAlert = errorAlert } + } + // Process Attachment + Task { + switch await getSharedContent(self.itemProvider!) { + case let .success(chatItemContent): + await MainActor.run { + self.sharedContent = chatItemContent + self.bottomBar = .sendButton + if case let .text(string) = chatItemContent { comment = string } } + case let .failure(errorAlert): + await MainActor.run { self.errorAlert = errorAlert } } } } @@ -145,14 +168,15 @@ class ShareModel: ObservableObject { } } - private func initChat() -> ErrorAlert? { + private func initChat(with dbKey: String? = nil) -> ErrorAlert? { do { - if hasChatCtrl() { + if hasChatCtrl() && dbKey == nil { try apiActivateChat() } else { + resetChatCtrl() // Clears retained migration result registerGroupDefaults() haskell_init_se() - let (_, result) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) + let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation()) if let e = migrationError(result) { return e } try apiSetAppFilePaths( filesFolder: getAppFilesDirectory().path, @@ -171,8 +195,9 @@ class ShareModel: ObservableObject { private func migrationError(_ r: DBMigrationResult) -> ErrorAlert? { let useKeychain = storeDBPassphraseGroupDefault.get() let storedDBKey = kcDatabasePassword.get() - // This switch duplicates DatabaseErrorView. - // TODO allow entering passphrase and make messages the same as in DatabaseErrorView. + if case .errorNotADatabase = r { + Task { await MainActor.run { self.alertRequiresPassword = true } } + } return switch r { case .errorNotADatabase: if useKeychain && storedDBKey != nil && storedDBKey != "" { @@ -182,8 +207,8 @@ class ShareModel: ObservableObject { ) } else { ErrorAlert( - title: "Encrypted database", - message: "Sharing is not supported when passphrase is not stored in KeyChain." + title: "Database encrypted!", + message: "Database passphrase is required to open chat." ) } case let .errorMigration(_, migrationError): @@ -363,100 +388,104 @@ enum SharedContent { switch self { case let .image(preview, _): .image(text: comment, image: preview) case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration) - case let .url(preview): .link(text: comment, preview: preview) + case let .url(preview): .link(text: preview.uri.absoluteString + (comment == "" ? "" : "\n" + comment), preview: preview) case .text: .text(comment) case .data: .file(comment) } } + + func prohibited(in chatData: ChatData, hasSimplexLink: Bool) -> Bool { + chatData.prohibitedByPref( + hasSimplexLink: hasSimplexLink, + isMediaOrFileAttachment: cryptoFile != nil, + isVoice: false + ) + } } -extension NSItemProvider { - fileprivate func sharedContent() async -> Result { - if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) { - switch type { - // Prepare Image message - case .image: - - // Animated - return if hasItemConformingToTypeIdentifier(UTType.gif.identifier) { - if let url = try? await inPlaceUrl(type: type), - let data = try? Data(contentsOf: url), - let image = UIImage(data: data), - let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()), - let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { - .success(.image(preview: preview, cryptoFile: cryptoFile)) - } else { .failure(ErrorAlert("Error preparing message")) } - - // Static - } else { - if let url = try? await inPlaceUrl(type: type), - let image = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE), - let cryptoFile = saveImage(image), - let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { - .success(.image(preview: preview, cryptoFile: cryptoFile)) - } else { .failure(ErrorAlert("Error preparing message")) } - } - - // Prepare Movie message - case .movie: +fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result { + if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) { + switch type { + // Prepare Image message + case .image: + // Animated + return if ip.hasItemConformingToTypeIdentifier(UTType.gif.identifier) { if let url = try? await inPlaceUrl(type: type), - let trancodedUrl = await transcodeVideo(from: url), - let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(), - let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE), - let cryptoFile = moveTempFileFromURL(trancodedUrl) { - try? FileManager.default.removeItem(at: trancodedUrl) - return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile)) - } else { return .failure(ErrorAlert("Error preparing message")) } - - // Prepare Data message - case .fileURL: - if let url = try? await inPlaceUrl(type: .data) { - if isFileTooLarge(for: url) { - let sizeString = ByteCountFormatter.string( - fromByteCount: Int64(getMaxFileSize(.xftp)), - countStyle: .binary - ) - return .failure( - ErrorAlert( - title: "Large file!", - message: "Currently maximum supported file size is \(sizeString)." - ) - ) - } - if let file = saveFileFromURL(url) { - return .success(.data(cryptoFile: file)) - } - } - return .failure(ErrorAlert("Error preparing file")) - - // Prepare Link message - case .url: - if let url = try? await loadItem(forTypeIdentifier: type.identifier) as? URL { - let content: SharedContent = -// Option to disable previews needs to be taken into account -// if let linkPreview = await getLinkPreview(for: url) { -// .url(preview: linkPreview) -// } else { - .text(string: url.absoluteString) -// } - return .success(content) - } else { return .failure(ErrorAlert("Error preparing message")) } - - // Prepare Text message - case .text: - return if let text = try? await loadItem(forTypeIdentifier: type.identifier) as? String { - .success(.text(string: text)) + let data = try? Data(contentsOf: url), + let image = UIImage(data: data), + let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { + .success(.image(preview: preview, cryptoFile: cryptoFile)) + } else { .failure(ErrorAlert("Error preparing message")) } + + // Static + } else { + if let image = await staticImage(), + let cryptoFile = saveImage(image), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { + .success(.image(preview: preview, cryptoFile: cryptoFile)) } else { .failure(ErrorAlert("Error preparing message")) } - default: return .failure(ErrorAlert("Unsupported format")) } - } else { - return .failure(ErrorAlert("Unsupported format")) + + // Prepare Movie message + case .movie: + if let url = try? await inPlaceUrl(type: type), + let trancodedUrl = await transcodeVideo(from: url), + let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE), + let cryptoFile = moveTempFileFromURL(trancodedUrl) { + try? FileManager.default.removeItem(at: trancodedUrl) + return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile)) + } else { return .failure(ErrorAlert("Error preparing message")) } + + // Prepare Data message + case .fileURL: + if let url = try? await inPlaceUrl(type: .data) { + if isFileTooLarge(for: url) { + let sizeString = ByteCountFormatter.string( + fromByteCount: Int64(getMaxFileSize(.xftp)), + countStyle: .binary + ) + return .failure( + ErrorAlert( + title: "Large file!", + message: "Currently maximum supported file size is \(sizeString)." + ) + ) + } + if let file = saveFileFromURL(url) { + return .success(.data(cryptoFile: file)) + } + } + return .failure(ErrorAlert("Error preparing file")) + + // Prepare Link message + case .url: + if let url = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? URL { + let content: SharedContent = + if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) { + .url(preview: linkPreview) + } else { + .text(string: url.absoluteString) + } + return .success(content) + } else { return .failure(ErrorAlert("Error preparing message")) } + + // Prepare Text message + case .text: + return if let text = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? String { + .success(.text(string: text)) + } else { .failure(ErrorAlert("Error preparing message")) } + default: return .failure(ErrorAlert("Unsupported format")) } + } else { + return .failure(ErrorAlert("Unsupported format")) } - private func inPlaceUrl(type: UTType) async throws -> URL { + + func inPlaceUrl(type: UTType) async throws -> URL { try await withCheckedThrowingContinuation { cont in - let _ = loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in + let _ = ip.loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in if let url = url { cont.resume(returning: url) } else if let error = error { @@ -468,12 +497,23 @@ extension NSItemProvider { } } - private func firstMatching(of types: Array) -> UTType? { + func firstMatching(of types: Array) -> UTType? { for type in types { - if hasItemConformingToTypeIdentifier(type.identifier) { return type } + if ip.hasItemConformingToTypeIdentifier(type.identifier) { return type } } return nil } + + func staticImage() async -> UIImage? { + if let url = try? await inPlaceUrl(type: .image), + let downsampledImage = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE) { + downsampledImage + } else { + /// Fallback to loading image directly from `ItemProvider` + /// in case loading from disk is not possible. Required for sharing screenshots. + try? await ip.loadItem(forTypeIdentifier: UTType.image.identifier) as? UIImage + } + } } diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index 20e6450b99..dcdeae2b82 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -12,24 +12,39 @@ import SimpleXChat struct ShareView: View { @ObservedObject var model: ShareModel @Environment(\.colorScheme) var colorScheme + @State private var password = String() + @AppStorage(GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS, store: groupDefaults) private var radius = defaultProfileImageCorner var body: some View { NavigationView { ZStack(alignment: .bottom) { if model.isLoaded { List(model.filteredChats) { chat in + let isProhibited = model.isProhibited(chat) + let isSelected = model.selected == chat HStack { profileImage( chatInfoId: chat.chatInfo.id, - systemFallback: chatIconName(chat.chatInfo), + iconName: chatIconName(chat.chatInfo), size: 30 ) - Text(chat.chatInfo.displayName) + Text(chat.chatInfo.displayName).foregroundStyle( + isProhibited ? .secondary : .primary + ) Spacer() - radioButton(selected: chat == model.selected) + radioButton(selected: isSelected && !isProhibited) } .contentShape(Rectangle()) - .onTapGesture { model.selected = model.selected == chat ? nil : chat } + .onTapGesture { + if isProhibited { + model.errorAlert = ErrorAlert( + title: "Cannot forward message", + message: "Selected chat preferences prohibit this message." + ) { Button("Ok", role: .cancel) { } } + } else { + model.selected = isSelected ? nil : chat + } + } .tag(chat) } } else { @@ -53,7 +68,19 @@ struct ShareView: View { placement: .navigationBarDrawer(displayMode: .always) ) .alert($model.errorAlert) { alert in - Button("Ok") { model.completion() } + if model.alertRequiresPassword { + SecureField("Passphrase", text: $password) + Button("Ok") { + model.setup(with: password) + password = String() + } + Button("Cancel", role: .cancel) { model.completion() } + } else { + Button("Ok") { model.completion() } + } + } + .onChange(of: model.comment) { + model.hasSimplexLink = hasSimplexLink($0) } } @@ -66,7 +93,7 @@ struct ShareView: View { HStack { Group { if #available(iOSApplicationExtension 16.0, *) { - TextField("Comment", text: $model.comment, axis: .vertical) + TextField("Comment", text: $model.comment, axis: .vertical).lineLimit(6) } else { TextField("Comment", text: $model.comment) } @@ -159,17 +186,17 @@ struct ShareView: View { .background(Material.ultraThin) } - private func profileImage(chatInfoId: ChatInfo.ID, systemFallback: String, size: Double) -> some View { - Group { - if let uiImage = model.profileImages[chatInfoId] { - Image(uiImage: uiImage).resizable() - } else { - Image(systemName: systemFallback).resizable() - } + @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View { + if let uiImage = model.profileImages[chatInfoId] { + clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius) + } else { + Image(systemName: iconName) + .resizable() + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .frame(width: size, height: size) +// add background when adding themes to SE +// .background(Circle().fill(backgroundColor != nil ? backgroundColor! : .clear)) } - .foregroundStyle(Color(.tertiaryLabel)) - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: size * 0.225, style: .continuous)) } private func radioButton(selected: Bool) -> some View { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4a3be1e473..20c242c51f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; + 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; }; @@ -529,6 +530,7 @@ 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; + 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; @@ -716,6 +718,7 @@ 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, + 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */, ); path = Chat; sourceTree = ""; @@ -1426,6 +1429,7 @@ 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, + 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index df95b5ebb6..796554a839 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -2115,7 +2115,7 @@ public struct AppSettings: Codable, Equatable, Hashable { public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil public var iosCallKitEnabled: Bool? = nil public var iosCallKitCallsInRecents: Bool? = nil - public var uiProfileImageCornerRadius: Float? = nil + public var uiProfileImageCornerRadius: Double? = nil public var uiColorScheme: String? = nil public var uiDarkColorScheme: String? = nil public var uiCurrentThemeIds: [String: String]? = nil diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 6f9ad3b68e..1690c7bc73 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -11,6 +11,8 @@ import SwiftUI public let appSuspendTimeout: Int = 15 // seconds +public let defaultProfileImageCorner: Double = 22.5 + let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_SE_STATE = "seState" @@ -20,11 +22,18 @@ public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used +// replaces DEFAULT_PERFORM_LA +let GROUP_DEFAULT_PERFORM_LA = "performLocalAuthentication" +public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension" +// replaces DEFAULT_PRIVACY_LINK_PREVIEWS +let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" public let GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS = "privacyAskToApproveRelays" +// replaces DEFAULT_PROFILE_IMAGE_CORNER_RADIUS +public let GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" @@ -72,10 +81,14 @@ public func registerGroupDefaults() { GROUP_DEFAULT_INCOGNITO: false, GROUP_DEFAULT_STORE_DB_PASSPHRASE: true, GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false, + GROUP_DEFAULT_PERFORM_LA: true, + GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false, + GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: false, GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true, + GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false, GROUP_DEFAULT_CALL_KIT_ENABLED: true, GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false, @@ -190,6 +203,12 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) +public let performLAGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PERFORM_LA) + +public let allowShareExtensionGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_ALLOW_SHARE_EXTENSION) + +public let privacyLinkPreviewsGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS) + // This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) @@ -197,6 +216,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS) +public let profileImageCornerRadiusGroupDefault = Default(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) + public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) public let networkUseOnionHostsGroupDefault = EnumDefault( diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d173f2fad8..f9eb0ed39e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2454,13 +2454,16 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } } - public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember)? { + public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? { switch (chatInfo, chatDir) { case let (.group(groupInfo), .groupRcv(groupMember)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil + case let (.group(groupInfo), .groupSnd): + let m = groupInfo.membership + return m.memberRole >= .admin ? (groupInfo, nil) : nil default: return nil } } @@ -2475,6 +2478,10 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } } + public var canBeDeletedForSelf: Bool { + (content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete + } + public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index a37b6babf7..5f56180918 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -14,6 +14,45 @@ public protocol ChatLike { var chatStats: ChatStats { get } } +extension ChatLike { + public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { + if case let .group(groupInfo) = self.chatInfo { + let p = groupInfo.fullGroupPreferences + return switch feature { + case .timedMessages: p.timedMessages.on + case .directMessages: p.directMessages.on(for: groupInfo.membership) + case .fullDelete: p.fullDelete.on + case .reactions: p.reactions.on + case .voice: p.voice.on(for: groupInfo.membership) + case .files: p.files.on(for: groupInfo.membership) + case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) + case .history: p.history.on + } + } else { + return true + } + } + + public func prohibitedByPref( + hasSimplexLink: Bool, + isMediaOrFileAttachment: Bool, + isVoice: Bool + ) -> Bool { + // preference checks should match checks in compose view + let simplexLinkProhibited = hasSimplexLink && !groupFeatureEnabled(.simplexLinks) + let fileProhibited = isMediaOrFileAttachment && !groupFeatureEnabled(.files) + let voiceProhibited = isVoice && !chatInfo.featureEnabled(.voice) + return switch chatInfo { + case .direct: voiceProhibited + case .group: simplexLinkProhibited || fileProhibited || voiceProhibited + case .local: false + case .contactRequest: false + case .contactConnection: false + case .invalidJSON: false + } + } +} + public func filterChatsToForwardTo(chats: [C]) -> [C] { var filteredChats = chats.filter { c in c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) @@ -60,3 +99,15 @@ public func chatIconName(_ cInfo: ChatInfo) -> String { default: "circle.fill" } } + +public func hasSimplexLink(_ text: String?) -> Bool { + if let text, let parsedMsg = parseSimpleXMarkdown(text) { + parsedMsgHasSimplexLink(parsedMsg) + } else { + false + } +} + +public func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool { + parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false }) +} diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index 65ed4c6717..5b9acc4fca 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -72,25 +72,25 @@ extension View { _ errorAlert: Binding, @ViewBuilder actions: (ErrorAlert) -> A = { _ in EmptyView() } ) -> some View { - if let alert = errorAlert.wrappedValue { - self.alert( - alert.title, - isPresented: Binding( - get: { errorAlert.wrappedValue != nil }, - set: { if !$0 { errorAlert.wrappedValue = nil } } - ), - actions: { - if let actions_ = alert.actions { - actions_() - } else { - actions(alert) - } - }, - message: { - if let message = alert.message { Text(message) } + alert( + errorAlert.wrappedValue?.title ?? "", + isPresented: Binding( + get: { errorAlert.wrappedValue != nil }, + set: { if !$0 { errorAlert.wrappedValue = nil } } + ), + actions: { + if let actions_ = errorAlert.wrappedValue?.actions { + actions_() + } else { + if let alert = errorAlert.wrappedValue { actions(alert) } } - ) - } else { self } + }, + message: { + if let message = errorAlert.wrappedValue?.message { + Text(message) + } + } + ) } } diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index c387c84aaa..7a62d863e6 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -428,3 +428,22 @@ public func getLinkPreview(for url: URL) async -> LinkPreview? { getLinkPreview(url: url) { cont.resume(returning: $0) } } } + +private let squareToCircleRatio = 0.935 + +private let radiusFactor = (1 - squareToCircleRatio) / 50 + +@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View { + let v = img.resizable() + if radius >= 50 { + v.frame(width: size, height: size).clipShape(Circle()) + } else if radius <= 0 { + let sz = size * squareToCircleRatio + v.frame(width: sz, height: sz).padding((size - sz) / 2) + } else { + let sz = size * (squareToCircleRatio + radius * radiusFactor) + v.frame(width: sz, height: sz) + .clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous)) + .padding((size - sz) / 2) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 7d0312a43a..c203c3bd78 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -18,7 +18,7 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel.updatingChatsMutex +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DefaultTheme @@ -27,7 +27,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* -import kotlinx.coroutines.sync.withLock import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -86,7 +85,7 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - updatingChatsMutex.withLock { + withChats { kotlin.runCatching { val currentUserId = chatModel.currentUser.value?.userId val chats = ArrayList(chatController.apiGetChats(chatModel.remoteHostId())) @@ -99,7 +98,7 @@ class SimplexApp: Application(), LifecycleEventObserver { /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) } - chatModel.updateChats(chats) + updateChats(chats) } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index f0f733111a..1b9e80b8a0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -6,6 +6,7 @@ import androidx.compose.material.Divider import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* @@ -19,8 +20,14 @@ actual fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + oneHandUI: State ) { var modifier = Modifier.fillMaxWidth() + + if (oneHandUI != null && oneHandUI.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + if (!disabled) modifier = modifier .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) .onRightClick { showMenu.value = true } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 4a8b912cdd..db56eeb508 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -36,7 +36,7 @@ private val CALL_BOTTOM_ICON_OFFSET = (-15).dp private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET @Composable -actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) { +actual fun ActiveCallInteractiveArea(call: Call) { val onClick = { platform.androidStartCallActivity(false) } Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) { val source = remember { MutableInteractionSource() } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index 9601152773..e890668566 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -78,7 +78,7 @@ fun AppearanceScope.AppearanceLayout( Modifier.fillMaxWidth(), ) { AppBarTitle(stringResource(MR.strings.appearance_settings)) - SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) { + SectionView(stringResource(MR.strings.settings_section_title_interface), padding = PaddingValues()) { val context = LocalContext.current // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // SectionItemWithValue( @@ -104,6 +104,8 @@ fun AppearanceScope.AppearanceLayout( } } // } + + SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) } SectionDividerSpaced(maxTopPadding = true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 59f5307a19..7362e8fa46 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -278,7 +278,7 @@ fun AndroidScreen(settingsState: SettingsViewState) { } } if (call != null && showCallArea) { - ActiveCallInteractiveArea(call, remember { MutableStateFlow(AnimatedViewState.GONE) }) + ActiveCallInteractiveArea(call) } } } 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 16a61da6b8..c41a5d69f3 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 @@ -54,7 +54,9 @@ object ChatModel { val ctrlInitInProgress = mutableStateOf(false) val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) - val chats = mutableStateListOf() + private val _chats = mutableStateOf(SnapshotStateList()) + val chats: State> = _chats + private val chatsContext = ChatsContext() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) @@ -126,7 +128,7 @@ object ChatModel { val updatingProgress = mutableStateOf(null as Float?) var updatingRequest: Closeable? = null - val updatingChatsMutex: Mutex = Mutex() + private val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null @@ -170,11 +172,11 @@ object ChatModel { } // toList() here is to prevent ConcurrentModificationException that is rarely happens but happens - fun hasChat(rhId: Long?, id: String): Boolean = chats.toList().firstOrNull { it.id == id && it.remoteHostId == rhId } != null + fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null // TODO pass rhId? - fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } - fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } - fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } + fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id } + fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } + fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } fun populateGroupMembersIndexes() { groupMembersIndexes.clear() @@ -192,97 +194,102 @@ object ChatModel { } } - private fun getChatIndex(rhId: Long?, id: String): Int = chats.toList().indexOfFirst { it.id == id && it.remoteHostId == rhId } - fun addChat(chat: Chat) = chats.add(index = 0, chat) + suspend fun withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock { + chatsContext.action() + } - fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { - val i = getChatIndex(rhId, cInfo.id) - if (i >= 0) { - val currentCInfo = chats[i].chatInfo - var newCInfo = cInfo - if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) { - val currentStats = currentCInfo.contact.activeConn?.connectionStats - val newConn = newCInfo.contact.activeConn - val newStats = newConn?.connectionStats - if (currentStats != null && newConn != null && newStats == null) { - newCInfo = newCInfo.copy( - contact = newCInfo.contact.copy( - activeConn = newConn.copy( - connectionStats = currentStats + class ChatsContext { + val chats = _chats + + fun addChat(chat: Chat) = chats.add(index = 0, chat) + + fun updateChatInfo(rhId: Long?, cInfo: ChatInfo) { + val i = getChatIndex(rhId, cInfo.id) + if (i >= 0) { + val currentCInfo = chats[i].chatInfo + var newCInfo = cInfo + if (currentCInfo is ChatInfo.Direct && newCInfo is ChatInfo.Direct) { + val currentStats = currentCInfo.contact.activeConn?.connectionStats + val newConn = newCInfo.contact.activeConn + val newStats = newConn?.connectionStats + if (currentStats != null && newConn != null && newStats == null) { + newCInfo = newCInfo.copy( + contact = newCInfo.contact.copy( + activeConn = newConn.copy( + connectionStats = currentStats + ) ) ) - ) - } - } - chats[i] = chats[i].copy(chatInfo = newCInfo) - } - } - - fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) - - fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) - - fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) { - val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) - val updatedContact = contact.copy(activeConn = updatedConn) - updateContact(rhId, updatedContact) - } - - fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) - - private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { - if (hasChat(rhId, cInfo.id)) { - updateChatInfo(rhId, cInfo) - } else if (addMissing) { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) - } - } - - fun updateChats(newChats: List) { - chats.clear() - chats.addAll(newChats) - - val cId = chatId.value - // If chat is null, it was deleted in background after apiGetChats call - if (cId != null && getChat(cId) == null) { - chatId.value = null - } - } - - fun replaceChat(rhId: Long?, id: String, chat: Chat) { - val i = getChatIndex(rhId, id) - if (i >= 0) { - chats[i] = chat - } else { - // invalid state, correcting - chats.add(index = 0, chat) - } - } - - suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val newPreviewItem = when (cInfo) { - is ChatInfo.Group -> { - val currentPreviewItem = chat.chatItems.firstOrNull() - if (currentPreviewItem != null) { - if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { - cItem - } else { - currentPreviewItem - } - } else { - cItem } } - else -> cItem + chats[i] = chats[i].copy(chatInfo = newCInfo) } - chats[i] = chat.copy( - chatItems = arrayListOf(newPreviewItem), - chatStats = + } + + fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection)) + + fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed) + + fun updateContactConnectionStats(rhId: Long?, contact: Contact, connectionStats: ConnectionStats) { + val updatedConn = contact.activeConn?.copy(connectionStats = connectionStats) + val updatedContact = contact.copy(activeConn = updatedConn) + updateContact(rhId, updatedContact) + } + + fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo)) + + private fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { + if (hasChat(rhId, cInfo.id)) { + updateChatInfo(rhId, cInfo) + } else if (addMissing) { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf())) + } + } + + fun updateChats(newChats: List) { + chats.clear() + chats.addAll(newChats) + + val cId = chatId.value + // If chat is null, it was deleted in background after apiGetChats call + if (cId != null && getChat(cId) == null) { + chatId.value = null + } + } + + fun replaceChat(rhId: Long?, id: String, chat: Chat) { + val i = getChatIndex(rhId, id) + if (i >= 0) { + chats[i] = chat + } else { + // invalid state, correcting + chats.add(index = 0, chat) + } + } + suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + // update previews + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val newPreviewItem = when (cInfo) { + is ChatInfo.Group -> { + val currentPreviewItem = chat.chatItems.firstOrNull() + if (currentPreviewItem != null) { + if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { + cItem + } else { + currentPreviewItem + } + } else { + cItem + } + } + else -> cItem + } + chats[i] = chat.copy( + chatItems = arrayListOf(newPreviewItem), + chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId increaseUnreadCounter(rhId, currentUser.value!!) @@ -290,123 +297,197 @@ object ChatModel { } else chat.chatStats - ) - if (i > 0) { - popChat_(i) + ) + if (i > 0) { + chats.add(index = 0, chats.removeAt(i)) + } + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - } - withContext(Dispatchers.Main) { - // add to current chat - if (chatId.value == cInfo.id) { - // Prevent situation when chat item already in the list received from backend - if (chatItems.value.none { it.id == cItem.id }) { - if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem) - } else { - chatItems.add(cItem) + withContext(Dispatchers.Main) { + // add to current chat + if (chatId.value == cInfo.id) { + // Prevent situation when chat item already in the list received from backend + if (chatItems.value.none { it.id == cItem.id }) { + if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + } else { + chatItems.add(cItem) + } } } } } - } - suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - val res: Boolean - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(cItem)) - if (pItem.isRcvNew && !cItem.isRcvNew) { - // status changed from New to Read, update counter - decreaseCounterInChat(rhId, cInfo.id) + suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { + // update previews + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + val res: Boolean + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + if (pItem.isRcvNew && !cItem.isRcvNew) { + // status changed from New to Read, update counter + decreaseCounterInChat(rhId, cInfo.id) + } + } + res = false + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + res = true + } + return withContext(Dispatchers.Main) { + // update current chat + if (chatId.value == cInfo.id) { + val items = chatItems.value + val itemIndex = items.indexOfFirst { it.id == cItem.id } + if (itemIndex >= 0) { + items[itemIndex] = cItem + false + } else { + val status = chatItemStatuses.remove(cItem.id) + val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { + cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + } else { + cItem + } + chatItems.add(ci) + true + } + } else { + res } } - res = false - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - res = true } - return withContext(Dispatchers.Main) { - // update current chat + + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { + withContext(Dispatchers.Main) { + if (chatId.value == cInfo.id) { + val items = chatItems.value + val itemIndex = items.indexOfFirst { it.id == cItem.id } + if (itemIndex >= 0) { + items[itemIndex] = cItem + } + } else if (status != null) { + chatItemStatuses[cItem.id] = status + } + } + } + + fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + if (cItem.isRcvNew) { + decreaseCounterInChat(rhId, cInfo.id) + } + // update previews + val i = getChatIndex(rhId, cInfo.id) + val chat: Chat + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.lastOrNull() + if (pItem?.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) + } + } + // remove from current chat if (chatId.value == cInfo.id) { - val items = chatItems.value - val itemIndex = items.indexOfFirst { it.id == cItem.id } - if (itemIndex >= 0) { - items[itemIndex] = cItem + chatItems.removeAll { + val remove = it.id == cItem.id + if (remove) { AudioPlayer.stop(it) } + remove + } + } + } + + fun clearChat(rhId: Long?, cInfo: ChatInfo) { + // clear preview + val i = getChatIndex(rhId, cInfo.id) + if (i >= 0) { + decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) + chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) + } + // clear current chat + if (chatId.value == cInfo.id) { + chatItemStatuses.clear() + chatItems.clear() + } + } + + fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { + val cInfo = chat.chatInfo + val markedRead = markItemsReadInCurrentChat(chat, range) + // update preview + val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id) + if (chatIdx >= 0) { + val chat = chats[chatIdx] + val lastId = chat.chatItems.lastOrNull()?.id + if (lastId != null) { + val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 + decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + chats[chatIdx] = chat.copy( + chatStats = chat.chatStats.copy( + unreadCount = unreadCount, + // Can't use minUnreadItemId currently since chat items can have unread items between read items + //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 + ) + ) + } + } + } + + private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { + val chatIndex = getChatIndex(rhId, chatId) + if (chatIndex == -1) return + + val chat = chats[chatIndex] + val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) + chats[chatIndex] = chat.copy( + chatStats = chat.chatStats.copy( + unreadCount = unreadCount, + ) + ) + } + + fun removeChat(rhId: Long?, id: String) { + chats.removeAll { it.id == id && it.remoteHostId == rhId } + } + + fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { + // user member was updated + if (groupInfo.membership.groupMemberId == member.groupMemberId) { + updateGroup(rhId, groupInfo) + return false + } + // update current chat + return if (chatId.value == groupInfo.id) { + val memberIndex = groupMembersIndexes[member.groupMemberId] + if (memberIndex != null) { + groupMembers[memberIndex] = member false } else { - val status = chatItemStatuses.remove(cItem.id) - val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { - cItem.copy(meta = cItem.meta.copy(itemStatus = status)) - } else { - cItem - } - chatItems.add(ci) + groupMembers.add(member) + groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1 true } } else { - res + false + } + } + + fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { + val memberConn = member.activeConn + if (memberConn != null) { + val updatedConn = memberConn.copy(connectionStats = connectionStats) + val updatedMember = member.copy(activeConn = updatedConn) + upsertGroupMember(rhId, groupInfo, updatedMember) } } } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { - withContext(Dispatchers.Main) { - if (chatId.value == cInfo.id) { - val items = chatItems.value - val itemIndex = items.indexOfFirst { it.id == cItem.id } - if (itemIndex >= 0) { - items[itemIndex] = cItem - } - } else if (status != null) { - chatItemStatuses[cItem.id] = status - } - } - } - - fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { - if (cItem.isRcvNew) { - decreaseCounterInChat(rhId, cInfo.id) - } - // update previews - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - val pItem = chat.chatItems.lastOrNull() - if (pItem?.id == cItem.id) { - chats[i] = chat.copy(chatItems = arrayListOf(ChatItem.deletedItemDummy)) - } - } - // remove from current chat - if (chatId.value == cInfo.id) { - chatItems.removeAll { - val remove = it.id == cItem.id - if (remove) { AudioPlayer.stop(it) } - remove - } - } - } - - fun clearChat(rhId: Long?, cInfo: ChatInfo) { - // clear preview - val i = getChatIndex(rhId, cInfo.id) - if (i >= 0) { - decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) - chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) - } - // clear current chat - if (chatId.value == cInfo.id) { - chatItemStatuses.clear() - chatItems.clear() - } - } + private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId } fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return @@ -447,28 +528,6 @@ object ChatModel { } } - fun markChatItemsRead(chat: Chat, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) { - val cInfo = chat.chatInfo - val markedRead = markItemsReadInCurrentChat(chat, range) - // update preview - val chatIdx = getChatIndex(chat.remoteHostId, cInfo.id) - if (chatIdx >= 0) { - val chat = chats[chatIdx] - val lastId = chat.chatItems.lastOrNull()?.id - if (lastId != null) { - val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 - decreaseUnreadCounter(chat.remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) - chats[chatIdx] = chat.copy( - chatStats = chat.chatStats.copy( - unreadCount = unreadCount, - // Can't use minUnreadItemId currently since chat items can have unread items between read items - //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 - ) - ) - } - } - } - private fun markItemsReadInCurrentChat(chat: Chat, range: CC.ItemRange? = null): Int { val cInfo = chat.chatInfo var markedRead = 0 @@ -493,20 +552,6 @@ object ChatModel { return markedRead } - private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) { - val chatIndex = getChatIndex(rhId, chatId) - if (chatIndex == -1) return - - val chat = chats[chatIndex] - val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) - decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) - chats[chatIndex] = chat.copy( - chatStats = chat.chatStats.copy( - unreadCount = unreadCount, - ) - ) - } - fun increaseUnreadCounter(rhId: Long?, user: UserLike) { changeUnreadCounter(rhId, user, 1) } @@ -600,11 +645,6 @@ object ChatModel { // } // } - private fun popChat_(i: Int) { - val chat = chats.removeAt(i) - chats.add(index = 0, chat) - } - fun replaceConnReqView(id: String, withId: String) { if (id == showingInvitation.value?.connId) { showingInvitation.value = null @@ -629,41 +669,6 @@ object ChatModel { showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true) } - fun removeChat(rhId: Long?, id: String) { - chats.removeAll { it.id == id && it.remoteHostId == rhId } - } - - fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { - // user member was updated - if (groupInfo.membership.groupMemberId == member.groupMemberId) { - updateGroup(rhId, groupInfo) - return false - } - // update current chat - return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembersIndexes[member.groupMemberId] - if (memberIndex != null) { - groupMembers[memberIndex] = member - false - } else { - groupMembers.add(member) - groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1 - true - } - } else { - false - } - } - - fun updateGroupMemberConnectionStats(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, connectionStats: ConnectionStats) { - val memberConn = member.activeConn - if (memberConn != null) { - val updatedConn = memberConn.copy(connectionStats = connectionStats) - val updatedMember = member.copy(activeConn = updatedConn) - upsertGroupMember(rhId, groupInfo, updatedMember) - } - } - fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { val conn = contact.activeConn if (conn != null) { @@ -802,6 +807,7 @@ interface SomeChat { val id: ChatId val apiId: Long val ready: Boolean + val chatDeleted: Boolean val sendMsgEnabled: Boolean val ntfsEnabled: Boolean val incognito: Boolean @@ -882,6 +888,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contact.id override val apiId get() = contact.apiId override val ready get() = contact.ready + override val chatDeleted get() = contact.chatDeleted override val sendMsgEnabled get() = contact.sendMsgEnabled override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito @@ -906,6 +913,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = groupInfo.id override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready + override val chatDeleted get() = groupInfo.chatDeleted override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito @@ -930,6 +938,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = noteFolder.id override val apiId get() = noteFolder.apiId override val ready get() = noteFolder.ready + override val chatDeleted get() = noteFolder.chatDeleted override val sendMsgEnabled get() = noteFolder.sendMsgEnabled override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito @@ -954,6 +963,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactRequest.id override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready + override val chatDeleted get() = contactRequest.chatDeleted override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito @@ -978,6 +988,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactConnection.id override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready + override val chatDeleted get() = contactConnection.chatDeleted override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito @@ -1003,6 +1014,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = "" override val apiId get() = 0L override val ready get() = false + override val chatDeleted get() = false override val sendMsgEnabled get() = false override val ntfsEnabled get() = false override val incognito get() = false @@ -1079,6 +1091,7 @@ data class Contact( val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Direct @@ -1153,6 +1166,7 @@ data class Contact( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), contactGrpInvSent = false, + chatDeleted = false, uiThemes = null, ) } @@ -1161,7 +1175,8 @@ data class Contact( @Serializable enum class ContactStatus { @SerialName("active") Active, - @SerialName("deleted") Deleted; + @SerialName("deleted") Deleted, + @SerialName("deletedByUser") DeletedByUser; } @Serializable @@ -1304,6 +1319,7 @@ data class GroupInfo ( override val id get() = "#$groupId" override val apiId get() = groupId override val ready get() = membership.memberActive + override val chatDeleted get() = false override val sendMsgEnabled get() = membership.memberActive override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito @@ -1609,6 +1625,7 @@ class NoteFolder( override val chatType get() = ChatType.Local override val id get() = "*$noteFolderId" override val apiId get() = noteFolderId + override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = true override val ntfsEnabled get() = false @@ -1645,6 +1662,7 @@ class UserContactRequest ( override val chatType get() = ChatType.ContactRequest override val id get() = "<@$contactRequestId" override val apiId get() = contactRequestId + override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = false override val ntfsEnabled get() = false @@ -1684,6 +1702,7 @@ class PendingContactConnection( override val chatType get() = ChatType.ContactConnection override val id get () = ":$pccConnId" override val apiId get() = pccConnId + override val chatDeleted get() = false override val ready get() = false override val sendMsgEnabled get() = false override val ntfsEnabled get() = false @@ -2108,45 +2127,55 @@ data class ChatItem ( } } -fun MutableState>.add(index: Int, chatItem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(index, chatItem) } +fun MutableState>.add(index: Int, elem: T) { + value = SnapshotStateList().apply { addAll(value); add(index, elem) } } -fun MutableState>.add(chatItem: ChatItem) { - value = SnapshotStateList().apply { addAll(value); add(chatItem) } +fun MutableState>.add(elem: T) { + value = SnapshotStateList().apply { addAll(value); add(elem) } } -fun MutableState>.addAll(index: Int, chatItems: List) { - value = SnapshotStateList().apply { addAll(value); addAll(index, chatItems) } +fun MutableState>.addAll(index: Int, elems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(index, elems) } } -fun MutableState>.addAll(chatItems: List) { - value = SnapshotStateList().apply { addAll(value); addAll(chatItems) } +fun MutableState>.addAll(elems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(elems) } } -fun MutableState>.removeAll(block: (ChatItem) -> Boolean) { - value = SnapshotStateList().apply { addAll(value); removeAll(block) } +fun MutableState>.removeAll(block: (T) -> Boolean) { + value = SnapshotStateList().apply { addAll(value); removeAll(block) } } -fun MutableState>.removeAt(index: Int) { - value = SnapshotStateList().apply { addAll(value); removeAt(index) } +fun MutableState>.removeAt(index: Int): T { + val new = SnapshotStateList() + new.addAll(value) + val res = new.removeAt(index) + value = new + return res } -fun MutableState>.removeLast() { - value = SnapshotStateList().apply { addAll(value); removeLast() } +fun MutableState>.removeLast() { + value = SnapshotStateList().apply { addAll(value); removeLast() } } -fun MutableState>.replaceAll(chatItems: List) { - value = SnapshotStateList().apply { addAll(chatItems) } +fun MutableState>.replaceAll(elems: List) { + value = SnapshotStateList().apply { addAll(elems) } } -fun MutableState>.clear() { - value = SnapshotStateList() +fun MutableState>.clear() { + value = SnapshotStateList() } -fun State>.asReversed(): MutableList = value.asReversed() +fun State>.asReversed(): MutableList = value.asReversed() -val State>.size: Int get() = value.size +fun State>.toList(): List = value.toList() + +operator fun State>.get(i: Int): T = value[i] + +operator fun State>.set(index: Int, elem: T) { value[index] = elem } + +val State>.size: Int get() = value.size enum class CIMergeCategory { MemberConnected, 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 87bf26aea5..64a75c03ce 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 @@ -15,8 +15,8 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg -import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.changingActiveUserMutex +import chat.simplex.common.model.ChatModel.withChats import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -217,13 +217,16 @@ class AppPreferences { val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true) + val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true) val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false) val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true) val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false) - + val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, false) + private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( get = fun() = settings.getInt(prefName, default), @@ -381,6 +384,7 @@ class AppPreferences { private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized" private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" + private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI" private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used @@ -401,6 +405,8 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" + private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled" @@ -478,9 +484,9 @@ object ChatController { } Log.d(TAG, "startChat: started") } else { - updatingChatsMutex.withLock { + withChats { val chats = apiGetChats(null) - chatModel.updateChats(chats) + updateChats(chats) } Log.d(TAG, "startChat: running") } @@ -558,9 +564,9 @@ object ChatController { val hasUser = chatModel.currentUser.value != null chatModel.userAddress.value = if (hasUser) apiGetUserAddress(rhId) else null chatModel.chatItemTTL.value = if (hasUser) getChatItemTTL(rhId) else ChatItemTTL.None - updatingChatsMutex.withLock { + withChats { val chats = apiGetChats(rhId) - chatModel.updateChats(chats) + updateChats(chats) } } @@ -1177,16 +1183,18 @@ object ChatController { } } - suspend fun deleteChat(chat: Chat, notify: Boolean? = null) { + suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val cInfo = chat.chatInfo - if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) { - chatModel.removeChat(chat.remoteHostId, cInfo.id) + if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) { + withChats { + removeChat(chat.remoteHostId, cInfo.id) + } } } - suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean { chatModel.deletedChats.value += rh to type.type + id - val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) val success = when { r is CR.ContactDeleted && type == ChatType.Direct -> true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true @@ -1207,11 +1215,29 @@ object ChatController { return success } + suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? { + val type = ChatType.Direct + chatModel.deletedChats.value += rh to type.type + id + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val contact = when { + r is CR.ContactDeleted -> r.contact + else -> { + val titleId = MR.strings.error_deleting_contact + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + null + } + } + chatModel.deletedChats.value -= rh to type.type + id + return contact + } + fun clearChat(chat: Chat, close: (() -> Unit)? = null) { withBGApi { val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId) if (updatedChatInfo != null) { - chatModel.clearChat(chat.remoteHostId, updatedChatInfo) + withChats { + clearChat(chat.remoteHostId, updatedChatInfo) + } ntfManager.cancelNotificationsForChat(chat.chatInfo.id) close?.invoke() } @@ -1546,10 +1572,12 @@ object ChatController { val r = sendCmd(rh, CC.ApiJoinGroup(groupId)) when (r) { is CR.UserAcceptedGroupSent -> - chatModel.updateGroup(rh, r.groupInfo) + withChats { + updateGroup(rh, r.groupInfo) + } is CR.ChatCmdError -> { val e = r.chatError - suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { chatModel.removeChat(rh, "#$groupId") } } + suspend fun deleteGroup() { if (apiDeleteChat(rh, ChatType.Group, groupId)) { withChats { removeChat(rh, "#$groupId") } } } if (e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH) { deleteGroup() AlertManager.shared.showAlertMsg(generalGetString(MR.strings.alert_title_group_invitation_expired), generalGetString(MR.strings.alert_message_group_invitation_expired)) @@ -1703,7 +1731,9 @@ object ChatController { val prefs = contact.mergedPreferences.toPreferences().setAllowed(feature, param = param) val toContact = apiSetContactPrefs(rh, contact.contactId, prefs) if (toContact != null) { - chatModel.updateContact(rh, toContact) + withChats { + updateContact(rh, toContact) + } } } @@ -1973,16 +2003,20 @@ object ChatController { when (r) { is CR.ContactDeletedByContact -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(rhId, r.contact) + withChats { + updateContact(rhId, r.contact) + } } } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(rhId, r.contact) - val conn = r.contact.activeConn - if (conn != null) { - chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - chatModel.removeChat(rhId, conn.id) + withChats { + updateContact(rhId, r.contact) + val conn = r.contact.activeConn + if (conn != null) { + chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") + removeChat(rhId, conn.id) + } } } if (r.contact.directOrUsed) { @@ -1992,21 +2026,25 @@ object ChatController { } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(rhId, r.contact) - val conn = r.contact.activeConn - if (conn != null) { - chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - chatModel.removeChat(rhId, conn.id) + withChats { + updateContact(rhId, r.contact) + val conn = r.contact.activeConn + if (conn != null) { + chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") + removeChat(rhId, conn.id) + } } } } is CR.ContactSndReady -> { if (active(r.user) && r.contact.directOrUsed) { - chatModel.updateContact(rhId, r.contact) - val conn = r.contact.activeConn - if (conn != null) { - chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") - chatModel.removeChat(rhId, conn.id) + withChats { + updateContact(rhId, r.contact) + val conn = r.contact.activeConn + if (conn != null) { + chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") + removeChat(rhId, conn.id) + } } } chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) @@ -2015,10 +2053,12 @@ object ChatController { val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) if (active(r.user)) { - if (chatModel.hasChat(rhId, contactRequest.id)) { - chatModel.updateChatInfo(rhId, cInfo) - } else { - chatModel.addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) + withChats { + if (chatModel.hasChat(rhId, contactRequest.id)) { + updateChatInfo(rhId, cInfo) + } else { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf())) + } } } ntfManager.notifyContactRequestReceived(r.user, cInfo) @@ -2026,12 +2066,16 @@ object ChatController { is CR.ContactUpdated -> { if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) { val cInfo = ChatInfo.Direct(r.toContact) - chatModel.updateChatInfo(rhId, cInfo) + withChats { + updateChatInfo(rhId, cInfo) + } } } is CR.GroupMemberUpdated -> { if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.toMember) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.toMember) + } } } is CR.ContactsMerged -> { @@ -2039,7 +2083,9 @@ object ChatController { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } - chatModel.removeChat(rhId, r.mergedContact.id) + withChats { + removeChat(rhId, r.mergedContact.id) + } } } // ContactsSubscribed, ContactsDisconnected and ContactSubSummary are only used in CLI, @@ -2049,7 +2095,9 @@ object ChatController { is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { - chatModel.updateContact(rhId, sub.contact) + withChats { + updateContact(rhId, sub.contact) + } } val err = sub.contactError if (err == null) { @@ -2073,7 +2121,15 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { - chatModel.addChatItem(rhId, cInfo, cItem) + if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { + val updatedContact = cInfo.contact.copy(chatDeleted = false) + withChats { + updateContact(rhId, updatedContact) + } + } + withChats { + addChatItem(rhId, cInfo, cItem) + } } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { chatModel.increaseUnreadCounter(rhId, r.user) } @@ -2094,14 +2150,18 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (!cItem.isDeletedContent && active(r.user)) { - chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + withChats { + updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) + } } } is CR.ChatItemUpdated -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.ChatItemReaction -> { if (active(r.user)) { - chatModel.updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + withChats { + updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem) + } } } is CR.ChatItemsDeleted -> { @@ -2127,82 +2187,113 @@ object ChatController { generalGetString(if (toChatItem != null) MR.strings.marked_deleted_description else MR.strings.deleted_description) ) } - if (toChatItem == null) { - chatModel.removeChatItem(rhId, cInfo, cItem) - } else { - chatModel.upsertChatItem(rhId, cInfo, toChatItem.chatItem) + withChats { + if (toChatItem == null) { + removeChatItem(rhId, cInfo, cItem) + } else { + upsertChatItem(rhId, cInfo, toChatItem.chatItem) + } } } } is CR.ReceivedGroupInvitation -> { if (active(r.user)) { - chatModel.updateGroup(rhId, r.groupInfo) // update so that repeat group invitations are not duplicated + withChats { + // update so that repeat group invitations are not duplicated + updateGroup(rhId, r.groupInfo) + } // TODO NtfManager.shared.notifyGroupInvitation } } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return - chatModel.updateGroup(rhId, r.groupInfo) - val conn = r.hostContact?.activeConn - if (conn != null) { - chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}") - chatModel.removeChat(rhId, conn.id) + withChats { + updateGroup(rhId, r.groupInfo) + val conn = r.hostContact?.activeConn + if (conn != null) { + chatModel.replaceConnReqView(conn.id, "#${r.groupInfo.groupId}") + removeChat(rhId, conn.id) + } } } is CR.GroupLinkConnecting -> { if (!active(r.user)) return - chatModel.updateGroup(rhId, r.groupInfo) - val hostConn = r.hostMember.activeConn - if (hostConn != null) { - chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") - chatModel.removeChat(rhId, hostConn.id) + withChats { + updateGroup(rhId, r.groupInfo) + val hostConn = r.hostMember.activeConn + if (hostConn != null) { + chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") + removeChat(rhId, hostConn.id) + } } } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.DeletedMemberUser -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(rhId, r.groupInfo) + withChats { + updateGroup(rhId, r.groupInfo) + } } is CR.DeletedMember -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.deletedMember) + } } is CR.LeftMember -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRole -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberRoleUser -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.MemberBlockedForAll -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.GroupDeleted -> // TODO update user member if (active(r.user)) { - chatModel.updateGroup(rhId, r.groupInfo) + withChats { + updateGroup(rhId, r.groupInfo) + } } is CR.UserJoinedGroup -> if (active(r.user)) { - chatModel.updateGroup(rhId, r.groupInfo) + withChats { + updateGroup(rhId, r.groupInfo) + } } is CR.JoinedGroupMember -> if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } is CR.ConnectedToGroupMember -> { if (active(r.user)) { - chatModel.upsertGroupMember(rhId, r.groupInfo, r.member) + withChats { + upsertGroupMember(rhId, r.groupInfo, r.member) + } } if (r.memberContact != null) { chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) @@ -2210,11 +2301,15 @@ object ChatController { } is CR.GroupUpdated -> if (active(r.user)) { - chatModel.updateGroup(rhId, r.toGroup) + withChats { + updateGroup(rhId, r.toGroup) + } } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { - chatModel.updateContact(rhId, r.contact) + withChats { + updateContact(rhId, r.contact) + } } is CR.RcvFileStart -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) @@ -2314,19 +2409,27 @@ object ChatController { } is CR.ContactSwitch -> if (active(r.user)) { - chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + withChats { + updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + } } is CR.GroupMemberSwitch -> if (active(r.user)) { - chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + withChats { + updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + } } is CR.ContactRatchetSync -> if (active(r.user)) { - chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + withChats { + updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + } } is CR.GroupMemberRatchetSync -> if (active(r.user)) { - chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + withChats { + updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + } } is CR.RemoteHostSessionCode -> { chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) @@ -2338,7 +2441,9 @@ object ChatController { } is CR.ContactDisabled -> { if (active(r.user)) { - chatModel.updateContact(rhId, r.contact) + withChats { + updateContact(rhId, r.contact) + } } } is CR.RemoteHostStopped -> { @@ -2459,7 +2564,9 @@ object ChatController { } is CR.ContactPQEnabled -> if (active(r.user)) { - chatModel.updateContact(rhId, r.contact) + withChats { + updateContact(rhId, r.contact) + } } is CR.ChatRespError -> when { r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { @@ -2535,7 +2642,9 @@ object ChatController { suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { - chatModel.updateGroup(rh, groupInfo) + withChats { + updateGroup(rh, groupInfo) + } } } @@ -2545,7 +2654,7 @@ object ChatController { val notify = { ntfManager.notifyMessageReceived(user, cInfo, cItem) } if (!activeUser(rh, user)) { notify() - } else if (chatModel.upsertChatItem(rh, cInfo, cItem)) { + } else if (withChats { upsertChatItem(rh, cInfo, cItem) }) { notify() } else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) { notify() @@ -2589,7 +2698,9 @@ object ChatController { chatModel.currentUser.value = user if (user == null) { chatModel.chatItems.clear() - chatModel.chats.clear() + withChats { + chats.clear() + } } val statuses = apiGetNetworkStatuses(rhId) if (statuses != null) { @@ -2790,7 +2901,7 @@ sealed class CC { class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() - class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -2943,11 +3054,7 @@ sealed class CC { is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" - is ApiDeleteChat -> if (notify != null) { - "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" - } else { - "/_delete ${chatRef(type, id)}" - } + is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" @@ -3169,8 +3276,6 @@ sealed class CC { null } - private fun onOff(b: Boolean): String = if (b) "on" else "off" - private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) companion object { @@ -3180,6 +3285,8 @@ sealed class CC { } } +fun onOff(b: Boolean): String = if (b) "on" else "off" + @Serializable data class NewUser( val profile: Profile?, @@ -5147,6 +5254,19 @@ fun chatError(r: CR): ChatErrorType? { ) } +@Serializable +sealed class ChatDeleteMode { + @Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode() + @Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode() + @Serializable @SerialName("messages") class Messages: ChatDeleteMode() + + val cmdString: String get() = when (this) { + is ChatDeleteMode.Full -> "full notify=${onOff(notify)}" + is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}" + is ChatDeleteMode.Messages -> "messages" + } +} + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() @@ -6034,6 +6154,7 @@ data class AppSettings( var uiDarkColorScheme: String? = null, var uiCurrentThemeIds: Map? = null, var uiThemes: List? = null, + var oneHandUI: Boolean? = null ) { fun prepareForExport(): AppSettings { val empty = AppSettings() @@ -6064,6 +6185,7 @@ data class AppSettings( if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme } if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds } if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes } + if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI } return empty } @@ -6102,6 +6224,7 @@ data class AppSettings( uiDarkColorScheme?.let { def.systemDarkTheme.set(it) } uiCurrentThemeIds?.let { def.currentThemeIds.set(it) } uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) } + oneHandUI?.let { def.oneHandUI.set(it) } } companion object { @@ -6133,6 +6256,7 @@ data class AppSettings( uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName, uiCurrentThemeIds = null, uiThemes = null, + oneHandUI = false ) val current: AppSettings @@ -6165,6 +6289,7 @@ data class AppSettings( uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName, uiCurrentThemeIds = def.currentThemeIds.get(), uiThemes = def.themeOverrides.get(), + oneHandUI = def.oneHandUI.get() ) } } 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 838225afb8..da2ead23e3 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 @@ -12,18 +12,23 @@ import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chatlist.* import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -31,6 +36,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -54,10 +60,11 @@ fun ChatInfoView( localAlias: String, connectionCode: String?, close: () -> Unit, + onSearchClicked: () -> Unit ) { BackHandler(onBack = close) val contact = rememberUpdatedState(contact).value - val chat = remember(contact.id) { chatModel.chats.firstOrNull { it.id == contact.id } } + val chat = remember(contact.id) { chatModel.chats.value.firstOrNull { it.id == contact.id } } val currentUser = remember { chatModel.currentUser }.value val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() @@ -102,7 +109,9 @@ fun ChatInfoView( val cStats = chatModel.controller.apiSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) + withChats { + updateContactConnectionStats(chatRh, contact, cStats) + } } close.invoke() } @@ -114,7 +123,9 @@ fun ChatInfoView( val cStats = chatModel.controller.apiAbortSwitchContact(chatRh, contact.contactId) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) + withChats { + updateContactConnectionStats(chatRh, contact, cStats) + } } } }) @@ -124,7 +135,9 @@ fun ChatInfoView( val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) + withChats { + updateContactConnectionStats(chatRh, contact, cStats) + } } close.invoke() } @@ -135,7 +148,9 @@ fun ChatInfoView( val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = true) connStats.value = cStats if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) + withChats { + updateContactConnectionStats(chatRh, contact, cStats) + } } close.invoke() } @@ -151,14 +166,16 @@ fun ChatInfoView( verify = { code -> chatModel.controller.apiVerifyContact(chatRh, ct.contactId, code)?.let { r -> val (verified, existingCode) = r - chatModel.updateContact( - chatRh, - ct.copy( - activeConn = ct.activeConn?.copy( - connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + withChats { + updateContact( + chatRh, + ct.copy( + activeConn = ct.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + ) ) ) - ) + } r } }, @@ -166,7 +183,9 @@ fun ChatInfoView( ) } } - } + }, + close = close, + onSearchClicked = onSearchClicked ) } } @@ -201,34 +220,42 @@ sealed class SendReceipts { fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Direct) { + val contact = chatInfo.contact + when { + contact.sndReady && contact.active && !chatInfo.chatDeleted -> + deleteContactOrConversationDialog(chat, contact, chatModel, close) + + contact.sndReady && contact.active && chatInfo.chatDeleted -> + deleteContactWithoutConversation(chat, chatModel, close) + + else -> // !(contact.sndReady && contact.active) + deleteNotReadyContact(chat, chatModel, close) + } + } +} + +private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)?) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), buttons = { Column { - if (chatInfo is ChatInfo.Direct && chatInfo.contact.sndReady && chatInfo.contact.active) { - // Delete and notify contact - SectionItemView({ - AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, notify = true) - }) { - Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - // Delete - SectionItemView({ - AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, notify = false) - }) { - Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - } else { - // Delete - SectionItemView({ - AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close) - }) { - Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + // Only delete conversation + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages()) + if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) { + showDeleteConversationNotice(contact) } + }) { + Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Delete contact + SectionItemView({ + AlertManager.shared.hideAlert() + deleteActiveContactDialog(chat, contact, chatModel, close) + }) { + Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Cancel SectionItemView({ @@ -241,13 +268,207 @@ fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = ) } -fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { +private fun showDeleteConversationNotice(contact: Contact) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.conversation_deleted), + text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showDeleteConversationNotice.set(false) + }, + ) +} + +sealed class ContactDeleteMode { + class Full: ContactDeleteMode() + class Entity: ContactDeleteMode() + + fun toChatDeleteMode(notify: Boolean): ChatDeleteMode = + when (this) { + is Full -> ChatDeleteMode.Full(notify) + is Entity -> ChatDeleteMode.Entity(notify) + } +} + +private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) { + val contactDeleteMode = mutableStateOf(ContactDeleteMode.Full()) + + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), + text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + buttons = { + Column { + // Keep conversation toggle + SectionItemView { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text(stringResource(MR.strings.keep_conversation)) + Spacer(Modifier.width(DEFAULT_PADDING)) + DefaultSwitch( + checked = contactDeleteMode.value is ContactDeleteMode.Entity, + onCheckedChange = { + contactDeleteMode.value = + if (it) ContactDeleteMode.Entity() else ContactDeleteMode.Full() + }, + ) + } + } + // Delete without notification + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false)) + if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { + showDeleteContactNotice(contact) + } + }) { + Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Delete contact and notify + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true)) + if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { + showDeleteContactNotice(contact) + } + }) { + Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.confirm_delete_contact_question), + text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + buttons = { + Column { + // Delete and notify contact + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact( + chat, + chatModel, + close, + chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = true) + ) + }) { + Text( + generalGetString(MR.strings.delete_and_notify_contact), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + // Delete without notification + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact( + chat, + chatModel, + close, + chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false) + ) + }) { + Text( + generalGetString(MR.strings.delete_without_notification), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text( + stringResource(MR.strings.cancel_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.primary + ) + } + } + ) +} + +private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.confirm_delete_contact_question), + text = generalGetString(MR.strings.delete_contact_cannot_undo_warning), + buttons = { + // Confirm + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact( + chat, + chatModel, + close, + chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false) + ) + }) { + Text( + generalGetString(MR.strings.confirm_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.error + ) + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text( + stringResource(MR.strings.cancel_verb), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.primary + ) + } + } + ) +} + +private fun showDeleteContactNotice(contact: Contact) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.contact_deleted), + text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showDeleteContactNotice.set(false) + }, + ) +} + +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val chatInfo = chat.chatInfo withBGApi { val chatRh = chat.remoteHostId - val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify) - if (r) { - chatModel.removeChat(chatRh, chatInfo.id) + val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode) + if (ct != null) { + withChats { + when (chatDeleteMode) { + is ChatDeleteMode.Full -> + removeChat(chatRh, chatInfo.id) + is ChatDeleteMode.Entity -> + updateContact(chatRh, ct) + is ChatDeleteMode.Messages -> + clearChat(chatRh, ChatInfo.Direct(ct)) + } + } if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -300,6 +521,8 @@ fun ChatInfoLayout( syncContactConnection: () -> Unit, syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, + close: () -> Unit, + onSearchClicked: () -> Unit ) { val cStats = connStats.value val scrollState = rememberScrollState() @@ -319,7 +542,27 @@ fun ChatInfoLayout( } LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) + SectionSpacer() + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + SearchButton(chat, contact, close, onSearchClicked) + Spacer(Modifier.weight(1f)) + AudioCallButton(chat, contact) + Spacer(Modifier.weight(1f)) + VideoButton(chat, contact) + Spacer(Modifier.weight(1f)) + MuteButton(chat, contact) + } + + SectionSpacer() + if (customUserProfile != null) { SectionView(generalGetString(MR.strings.incognito).uppercase()) { SectionItemViewSpaceBetween { @@ -347,7 +590,7 @@ fun ChatInfoLayout( WallpaperButton { ModalManager.end.showModal { - val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } } + val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { ChatWallpaperEditorModal(c) @@ -535,6 +778,174 @@ fun LocalAliasEditor( } } +@Composable +fun SearchButton(chat: Chat, contact: Contact, close: () -> Unit, onSearchClicked: () -> Unit) { + val disabled = !contact.ready || chat.chatItems.isEmpty() + InfoViewActionButton( + icon = painterResource(MR.images.ic_search), + title = generalGetString(MR.strings.info_view_search_button), + disabled = disabled, + disabledLook = disabled, + onClick = { + if (appPlatform.isAndroid) { + close.invoke() + } + onSearchClicked() + } + ) +} + +@Composable +fun MuteButton(chat: Chat, contact: Contact) { + val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + val disabled = !contact.ready || !contact.active + + InfoViewActionButton( + icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + disabled = disabled, + disabledLook = disabled, + onClick = { + toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled) + } + ) +} + +@Composable +fun AudioCallButton(chat: Chat, contact: Contact) { + CallButton( + chat, + contact, + icon = painterResource(MR.images.ic_call), + title = generalGetString(MR.strings.info_view_call_button), + mediaType = CallMediaType.Audio + ) +} + +@Composable +fun VideoButton(chat: Chat, contact: Contact) { + CallButton( + chat, + contact, + icon = painterResource(MR.images.ic_videocam), + title = generalGetString(MR.strings.info_view_video_button), + mediaType = CallMediaType.Video + ) +} + +@Composable +fun CallButton(chat: Chat, contact: Contact, icon: Painter, title: String, mediaType: CallMediaType) { + val canCall = contact.ready && contact.active && contact.mergedPreferences.calls.enabled.forUser && chatModel.activeCall.value == null + val needToAllowCallsToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.calls) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + val allowedCallsByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Calls) } + + InfoViewActionButton( + icon = icon, + title = title, + disabled = chatModel.activeCall.value != null, + disabledLook = !canCall, + onClick = + when { + canCall -> { { startChatCall(chat, mediaType) } } + contact.nextSendGrpInv -> { { showCantCallContactSendMessageAlert() } } + !contact.active -> { { showCantCallContactDeletedAlert() } } + !contact.ready -> { { showCantCallContactConnectingAlert() } } + needToAllowCallsToContact -> { { showNeedToAllowCallsAlert(onConfirm = { allowCallsToContact(chat) }) } } + !allowedCallsByPrefs -> { { showCallsProhibitedAlert() }} + else -> { { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.cant_call_contact_alert_title)) } } + } + ) +} + +private fun showCantCallContactSendMessageAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_call_contact_alert_title), + text = generalGetString(MR.strings.cant_call_member_send_message_alert_text) + ) +} + +private fun showCantCallContactConnectingAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_call_contact_alert_title), + text = generalGetString(MR.strings.cant_call_contact_connecting_wait_alert_text) + ) +} + +private fun showCantCallContactDeletedAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_call_contact_alert_title), + text = generalGetString(MR.strings.cant_call_contact_deleted_alert_text) + ) +} + +private fun showNeedToAllowCallsAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.allow_calls_question), + text = generalGetString(MR.strings.you_need_to_allow_calls), + confirmText = generalGetString(MR.strings.allow_verb), + dismissText = generalGetString(MR.strings.cancel_verb), + onConfirm = onConfirm, + ) +} + +private fun allowCallsToContact(chat: Chat) { + val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return + withBGApi { + chatModel.controller.allowFeatureToContact(chat.remoteHostId, contact, ChatFeature.Calls) + } +} + +private fun showCallsProhibitedAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.calls_prohibited_alert_title), + text = generalGetString(MR.strings.calls_prohibited_ask_to_enable_calls_alert_text) + ) +} + +// for ChatInfoView (it has most buttons - 4) we use Spacer(Modifier.weight(1f)) to fit, +// for GroupChat And GroupMemberInfoViews (2 to 3 buttons) we use this as approximately equal to spacing in ChatInfoView +val INFO_VIEW_BUTTONS_PADDING = 36.dp + +@Composable +fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, disabledLook: Boolean, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + IconButton( + onClick = onClick, + enabled = !disabled + ) { + Box( + modifier = Modifier + .background( + if (disabledLook) MaterialTheme.colors.secondaryVariant else MaterialTheme.colors.primary, + shape = CircleShape + ) + .padding(16.dp) + ) { + Icon( + icon, + contentDescription = null, + Modifier.size(24.dp * fontSizeSqrtMultiplier), + tint = if (disabledLook) MaterialTheme.colors.secondary else MaterialTheme.colors.onPrimary + ) + } + } + Text( + title.capitalize(Locale.current), + style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(top = DEFAULT_SPACE_AFTER_ICON) + ) + } +} + @Composable private fun NetworkStatusRow(networkStatus: NetworkStatus) { Row( @@ -768,10 +1179,12 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c wallpaperFilesToDelete.forEach(::removeWallpaperFile) if (controller.apiSetChatUIThemes(chat.remoteHostId, chat.id, changedThemes)) { - if (chat.chatInfo is ChatInfo.Direct) { - chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) - } else if (chat.chatInfo is ChatInfo.Group) { - chatModel.updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) + withChats { + if (chat.chatInfo is ChatInfo.Direct) { + updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(contact = chat.chatInfo.contact.copy(uiThemes = changedThemes))) + } else if (chat.chatInfo is ChatInfo.Group) { + updateChatInfo(chat.remoteHostId, chat.chatInfo.copy(groupInfo = chat.chatInfo.groupInfo.copy(uiThemes = changedThemes))) + } } } } @@ -779,7 +1192,9 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { - chatModel.updateContact(chatRh, it) + withChats { + updateContact(chatRh, it) + } } } @@ -847,6 +1262,8 @@ fun PreviewChatInfoLayout() { syncContactConnection = {}, syncContactConnectionForce = {}, verifyClicked = {}, + close = {}, + onSearchClicked = {} ) } } 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 d293bfe1fb..3a6d80fa09 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 @@ -11,7 +11,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.* @@ -25,6 +24,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -45,8 +45,9 @@ import kotlin.math.sign @Composable fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: String) -> Unit) { - val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) } + val activeChat = remember { mutableStateOf(chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatId }) } val searchText = rememberSaveable { mutableStateOf("") } + val showSearch = rememberSaveable { mutableStateOf(false) } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { @@ -74,6 +75,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (activeChat.value?.id != chatId) { // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Also for situation when chatId changes after clicking in notification, etc + showSearch.value = false activeChat.value = chatModel.getChat(chatId) } markUnreadChatAsRead(activeChat, chatModel) @@ -87,7 +89,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: * TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that * */ try { - chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } + chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } } catch (e: ConcurrentModificationException) { Log.e(TAG, e.stackTraceToString()) null @@ -112,7 +114,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { derivedStateOf { - chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0 + chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0 } } val clipboard = LocalClipboardManager.current @@ -190,7 +192,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second preloadedCode = code } - ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) + ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) { + showSearch.value = true + } } else if (chat?.chatInfo is ChatInfo.Group) { var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chat.id) { @@ -201,7 +205,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: GroupChatInfoView(chatModel, chatRh, chat.id, link?.first, link?.second, { link = it preloadedLink = it - }, close) + }, close, { showSearch.value = true }) } else { LaunchedEffect(Unit) { close() @@ -268,10 +272,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (deleted != null) { deletedChatItem = deleted.deletedChatItem.chatItem toChatItem = deleted.toChatItem?.chatItem - if (toChatItem != null) { - chatModel.upsertChatItem(chatRh, cInfo, toChatItem) - } else { - chatModel.removeChatItem(chatRh, cInfo, deletedChatItem) + withChats { + if (toChatItem != null) { + upsertChatItem(chatRh, cInfo, toChatItem) + } else { + removeChatItem(chatRh, cInfo, deletedChatItem) + } } } } @@ -284,8 +290,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: chatRh, chatInfo.chatType, chatInfo.apiId, itemIds, CIDeleteMode.cidmInternal ) if (deleted != null) { - for (di in deleted) { - chatModel.removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + withChats { + for (di in deleted) { + removeChatItem(chatRh, chatInfo, di.deletedChatItem.chatItem) + } } } } @@ -303,18 +311,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: onComplete.invoke() } }, - startCall = out@{ media -> - withBGApi { - val cInfo = chat.chatInfo - if (cInfo is ChatInfo.Direct) { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId) - val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi - chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile) - chatModel.showCallView.value = true - chatModel.callCommand.add(WCallCommand.Capabilities(media)) - } - } - }, + startCall = out@{ media -> startChatCall(chat, media) }, endCall = { val call = chatModel.activeCall.value if (call != null) withBGApi { chatModel.callManager.endCall(call) } @@ -351,7 +348,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (r != null) { val contactStats = r.first if (contactStats != null) - chatModel.updateContactConnectionStats(chatRh, contact, contactStats) + withChats { + updateContactConnectionStats(chatRh, contact, contactStats) + } } } }, @@ -361,7 +360,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: if (r != null) { val memStats = r.second if (memStats != null) { - chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + withChats { + updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, memStats) + } } } } @@ -370,7 +371,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withBGApi { val cStats = chatModel.controller.apiSyncContactRatchet(chatRh, contact.contactId, force = false) if (cStats != null) { - chatModel.updateContactConnectionStats(chatRh, contact, cStats) + withChats { + updateContactConnectionStats(chatRh, contact, cStats) + } } } }, @@ -378,7 +381,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: withBGApi { val r = chatModel.controller.apiSyncGroupMemberRatchet(chatRh, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { - chatModel.updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) + withChats { + updateGroupMemberConnectionStats(chatRh, groupInfo, r.first, r.second) + } } } }, @@ -399,7 +404,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: reaction = reaction ) if (updatedCI != null) { - chatModel.updateChatItem(cInfo, updatedCI) + withChats { + updateChatItem(cInfo, updatedCI) + } } } }, @@ -443,36 +450,20 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, - addMembers = { groupInfo -> - hideKeyboard(view) - withBGApi { - setGroupMembers(chatRh, groupInfo, chatModel) - ModalManager.end.closeModals() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(chatRh, groupInfo, false, chatModel, close) - } - } - }, - openGroupLink = { groupInfo -> - hideKeyboard(view) - withBGApi { - val link = chatModel.controller.apiGetGroupLink(chatRh, groupInfo.groupId) - ModalManager.end.closeModals() - ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, chatRh, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) - } - } - }, + addMembers = { groupInfo -> addGroupMembers(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) }, + openGroupLink = { groupInfo -> openGroupLink(view = view, groupInfo = groupInfo, rhId = chatRh, close = { ModalManager.end.closeModals() }) }, markRead = { range, unreadCountAfter -> - chatModel.markChatItemsRead(chat, range, unreadCountAfter) - ntfManager.cancelNotificationsForChat(chat.id) withBGApi { - chatModel.controller.apiChatRead( - chatRh, - chat.chatInfo.chatType, - chat.chatInfo.apiId, - range - ) + withChats { + markChatItemsRead(chat, range, unreadCountAfter) + ntfManager.cancelNotificationsForChat(chat.id) + chatModel.controller.apiChatRead( + chatRh, + chat.chatInfo.chatType, + chat.chatInfo.apiId, + range + ) + } } }, changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) }, @@ -488,6 +479,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), + showSearch = showSearch ) } } @@ -518,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } +fun startChatCall(chat: Chat, media: CallMediaType) { + withBGApi { + val cInfo = chat.chatInfo + if (cInfo is ChatInfo.Direct) { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId) + val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi + chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile) + chatModel.showCallView.value = true + chatModel.callCommand.add(WCallCommand.Capabilities(media)) + } + } +} + @Composable fun ChatLayout( chat: Chat, @@ -559,10 +564,12 @@ fun ChatLayout( onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, - showViaProxy: Boolean + showViaProxy: Boolean, + showSearch: MutableState ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } + Box( Modifier .fillMaxWidth() @@ -602,7 +609,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -648,16 +655,17 @@ fun ChatInfoToolbar( openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, + showSearch: MutableState ) { val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } - var showSearch by rememberSaveable { mutableStateOf(false) } + val onBackClicked = { - if (!showSearch) { + if (!showSearch.value) { back() } else { onSearchValueChanged("") - showSearch = false + showSearch.value = false } } if (appPlatform.isAndroid) { @@ -668,9 +676,10 @@ fun ChatInfoToolbar( val activeCall by remember { chatModel.activeCall } if (chat.chatInfo is ChatInfo.Local) { barButtons.add { - IconButton({ - showMenu.value = false - showSearch = true + IconButton( + { + showMenu.value = false + showSearch.value = true }, enabled = chat.chatInfo.noteFolder.ready ) { Icon( @@ -684,7 +693,7 @@ fun ChatInfoToolbar( menuItems.add { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false - showSearch = true + showSearch.value = true }) } } @@ -783,6 +792,7 @@ fun ChatInfoToolbar( } } } + if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) { val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } menuItems.add { @@ -810,10 +820,10 @@ fun ChatInfoToolbar( } DefaultTopAppBar( - navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } }, + navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } }, title = { ChatInfoToolbarTitle(chat.chatInfo) }, onTitleClick = if (chat.chatInfo is ChatInfo.Local) null else info, - showSearch = showSearch, + showSearch = showSearch.value, onSearchValueChanged = onSearchValueChanged, buttons = barButtons ) @@ -1141,7 +1151,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } .collect { try { - if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.size)) { + if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.value.size)) { if (appPlatform.isAndroid) listState.animateScrollToItem(0) else listState.scrollToItem(0) } else { if (appPlatform.isAndroid) listState.animateScrollBy(scrollDistance) else listState.scrollBy(scrollDistance) @@ -1245,7 +1255,7 @@ fun BoxWithConstraintsScope.FloatingButtons( painterResource(MR.images.ic_check), onClick = { markRead( - CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), + CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), bottomUnreadCount ) showDropDown.value = false @@ -1332,6 +1342,28 @@ private fun TopEndFloatingButton( val chatViewScrollState = MutableStateFlow(false) +fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) { + hideKeyboard(view) + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + close?.invoke() + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(rhId, groupInfo, false, chatModel, close) + } + } +} + +fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) { + hideKeyboard(view) + withBGApi { + val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) + close?.invoke() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, rhId, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } +} + private fun bottomEndFloatingButton( unreadCount: Int, showButtonWithCounter: Boolean, @@ -1388,8 +1420,10 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha false ) if (success && chat.id == activeChat.value?.id) { - activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)) - chatModel.replaceChat(chatRh, chat.id, activeChat.value!!) + withChats { + activeChat.value = chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)) + replaceChat(chatRh, chat.id, activeChat.value!!) + } } } } @@ -1587,6 +1621,7 @@ fun PreviewChatLayout() { onComposed = {}, developerTools = false, showViaProxy = false, + showSearch = remember { mutableStateOf(false) } ) } } @@ -1661,6 +1696,7 @@ fun PreviewGroupChatLayout() { onComposed = {}, developerTools = false, showViaProxy = false, + showSearch = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index a0e1bf8107..404a8636ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* @@ -393,7 +394,9 @@ fun ComposeView( ttl = ttl ) if (aChatItem != null) { - chatModel.addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + withChats { + addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) + } return aChatItem.chatItem } if (file != null) removeFile(file.filePath) @@ -421,7 +424,9 @@ fun ComposeView( ttl = ttl ) if (chatItem != null) { - chatModel.addChatItem(rhId, chat.chatInfo, chatItem) + withChats { + addChatItem(rhId, chat.chatInfo, chatItem) + } } return chatItem } @@ -458,7 +463,9 @@ fun ComposeView( val mc = checkLinkPreview() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { - chatModel.updateContact(chat.remoteHostId, contact) + withChats { + updateContact(chat.remoteHostId, contact) + } } } @@ -474,7 +481,9 @@ fun ComposeView( mc = updateMsgContent(oldMsgContent), live = live ) - if (updatedItem != null) chatModel.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + if (updatedItem != null) withChats { + upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + } return updatedItem?.chatItem } return null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 502074d629..f7f0a55372 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -19,6 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.res.MR @@ -40,8 +41,10 @@ fun ContactPreferencesView( val prefs = contactFeaturesAllowedToPrefs(featuresAllowed) val toContact = m.controller.apiSetContactPrefs(rhId, ct.contactId, prefs) if (toContact != null) { - m.updateContact(rhId, toContact) - currentFeaturesAllowed = featuresAllowed + withChats { + updateContact(rhId, toContact) + currentFeaturesAllowed = featuresAllowed + } } afterSave() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 931cc17872..42fbec35cd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* @@ -58,7 +59,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea for (contactId in selectedContacts) { val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { - chatModel.upsertGroupMember(rhId, groupInfo, member) + withChats { + upsertGroupMember(rhId, groupInfo, member) + } } else { break } @@ -81,7 +84,7 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List { val memberContactIds = chatModel.groupMembers .filter { it.memberCurrent } .mapNotNull { it.memberContactId } - return chatModel.chats + return chatModel.chats.value .asSequence() .map { it.chatInfo } .filterIsInstance() 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 c91bc3bcfc..cb05752abe 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -40,10 +41,10 @@ import kotlinx.coroutines.launch const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 @Composable -fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit) { +fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) { BackHandler(onBack = close) // TODO derivedStateOf? - val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } + val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } val currentUser = chatModel.currentUser.value val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) { @@ -113,7 +114,8 @@ fun GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLi leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } - } + }, + onSearchClicked = onSearchClicked ) } } @@ -131,13 +133,15 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(chat.remoteHostId, chatInfo.id) - if (chatModel.chatId.value == chatInfo.id) { - chatModel.chatId.value = null - ModalManager.end.closeModals() + withChats { + removeChat(chat.remoteHostId, chatInfo.id) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() + } + ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() } - ntfManager.cancelNotificationsForChat(chatInfo.id) - close?.invoke() } } }, @@ -169,7 +173,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe withBGApi { val updatedMember = chatModel.controller.apiRemoveMember(rhId, groupInfo.groupId, mem.groupMemberId) if (updatedMember != null) { - chatModel.upsertGroupMember(rhId, groupInfo, updatedMember) + withChats { + upsertGroupMember(rhId, groupInfo, updatedMember) + } } } }, @@ -177,6 +183,57 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe ) } +@Composable +fun SearchButton(chat: Chat, group: GroupInfo, close: () -> Unit, onSearchClicked: () -> Unit) { + val disabled = !group.ready || chat.chatItems.isEmpty() + + InfoViewActionButton( + icon = painterResource(MR.images.ic_search), + title = generalGetString(MR.strings.info_view_search_button), + disabled = disabled, + disabledLook = disabled, + onClick = { + if (appPlatform.isAndroid) { + close.invoke() + } + onSearchClicked() + } + ) +} + +@Composable +fun MuteButton(chat: Chat, groupInfo: GroupInfo) { + val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } + + InfoViewActionButton( + icon = if (ntfsEnabled.value) painterResource(MR.images.ic_notifications_off) else painterResource(MR.images.ic_notifications), + title = if (ntfsEnabled.value) stringResource(MR.strings.mute_chat) else stringResource(MR.strings.unmute_chat), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = { + toggleNotifications(chat, !ntfsEnabled.value, chatModel, ntfsEnabled) + } + ) +} + +@Composable +fun AddGroupMembersButton(chat: Chat, groupInfo: GroupInfo) { + InfoViewActionButton( + icon = if (groupInfo.incognito) painterResource(MR.images.ic_add_link) else painterResource(MR.images.ic_person_add_500), + title = stringResource(MR.strings.action_button_add_members), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = { + if (groupInfo.incognito) { + openGroupLink(groupInfo = groupInfo, rhId = chat.remoteHostId) + } else { + addGroupMembers(groupInfo = groupInfo, rhId = chat.remoteHostId) + } + } + ) +} + + @Composable fun GroupChatInfoLayout( chat: Chat, @@ -196,6 +253,8 @@ fun GroupChatInfoLayout( clearChat: () -> Unit, leaveGroup: () -> Unit, manageGroupLink: () -> Unit, + close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, + onSearchClicked: () -> Unit ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -219,6 +278,24 @@ fun GroupChatInfoLayout( } SectionSpacer() + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + SearchButton(chat, groupInfo, close, onSearchClicked) + if (groupInfo.canAddMembers) { + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + AddGroupMembersButton(chat, groupInfo) + } + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + MuteButton(chat, groupInfo) + } + + SectionSpacer() + SectionView { if (groupInfo.canEdit) { EditGroupProfileButton(editGroupProfile) @@ -235,7 +312,7 @@ fun GroupChatInfoLayout( WallpaperButton { ModalManager.end.showModal { - val chat = remember { derivedStateOf { chatModel.chats.firstOrNull { it.id == chat.id } } } + val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { ChatWallpaperEditorModal(c) @@ -584,7 +661,7 @@ fun PreviewGroupChatInfoLayout() { members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), developerTools = false, groupLink = null, - addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, + addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {}, ) } } 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 98b9ca729f..7a087a9263 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 @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -52,7 +53,7 @@ fun GroupMemberInfoView( closeAll: () -> Unit, // Close all open windows up to ChatView ) { BackHandler(onBack = close) - val chat = chatModel.chats.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } + val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() var progressIndicator by remember { mutableStateOf(false) } @@ -72,13 +73,15 @@ fun GroupMemberInfoView( withBGApi { val c = chatModel.controller.apiGetChat(rhId, ChatType.Direct, it) if (c != null) { - if (chatModel.getContactChat(it) == null) { - chatModel.addChat(c) + withChats { + if (chatModel.getContactChat(it) == null) { + addChat(c) + } + chatModel.chatItemStatuses.clear() + chatModel.chatItems.replaceAll(c.chatItems) + chatModel.chatId.value = c.id + closeAll() } - chatModel.chatItemStatuses.clear() - chatModel.chatItems.replaceAll(c.chatItems) - chatModel.chatId.value = c.id - closeAll() } } }, @@ -88,8 +91,10 @@ fun GroupMemberInfoView( val memberContact = chatModel.controller.apiCreateMemberContact(rhId, groupInfo.apiId, member.groupMemberId) if (memberContact != null) { val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf()) - chatModel.addChat(memberChat) - openLoadedChat(memberChat, chatModel) + withChats { + addChat(memberChat) + openLoadedChat(memberChat, chatModel) + } closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } @@ -114,7 +119,9 @@ fun GroupMemberInfoView( withBGApi { kotlin.runCatching { val mem = chatModel.controller.apiMemberRole(rhId, groupInfo.groupId, member.groupMemberId, it) - chatModel.upsertGroupMember(rhId, groupInfo, mem) + withChats { + upsertGroupMember(rhId, groupInfo, mem) + } }.onFailure { newRole.value = prevValue } @@ -127,7 +134,9 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withChats { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -139,7 +148,9 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiAbortSwitchGroupMember(rhId, groupInfo.apiId, member.groupMemberId) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withChats { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -150,7 +161,9 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = false) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withChats { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -161,7 +174,9 @@ fun GroupMemberInfoView( val r = chatModel.controller.apiSyncGroupMemberRatchet(rhId, groupInfo.apiId, member.groupMemberId, force = true) if (r != null) { connStats.value = r.second - chatModel.updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + withChats { + updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second) + } close.invoke() } } @@ -177,15 +192,17 @@ fun GroupMemberInfoView( verify = { code -> chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r -> val (verified, existingCode) = r - chatModel.upsertGroupMember( - rhId, - groupInfo, - mem.copy( - activeConn = mem.activeConn?.copy( - connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + withChats { + upsertGroupMember( + rhId, + groupInfo, + mem.copy( + activeConn = mem.activeConn?.copy( + connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null + ) ) ) - ) + } r } }, @@ -211,7 +228,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c withBGApi { val removedMember = chatModel.controller.apiRemoveMember(rhId, member.groupId, member.groupMemberId) if (removedMember != null) { - chatModel.upsertGroupMember(rhId, groupInfo, removedMember) + withChats { + upsertGroupMember(rhId, groupInfo, removedMember) + } } close?.invoke() } @@ -246,10 +265,10 @@ fun GroupMemberInfoLayout( verifyClicked: () -> Unit, ) { val cStats = connStats.value - fun knownDirectChat(contactId: Long): Chat? { + fun knownDirectChat(contactId: Long): Pair? { val chat = getContactChat(contactId) return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) { - chat + chat to chat.chatInfo.contact } else { null } @@ -309,17 +328,53 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + OpenChatButton(onClick = { openDirectChat(contact.contactId) }) + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + AudioCallButton(chat, contact) + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + VideoButton(chat, contact) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group + } else { + OpenChatButton(onClick = { createMemberContact() }) + } + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + } else { // no known contact chat && directMessages are off + InfoViewActionButton(painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title)) + }) + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + InfoViewActionButton(painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title)) + }) + Spacer(Modifier.width(INFO_VIEW_BUTTONS_PADDING)) + InfoViewActionButton(painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title)) + }) + } + } + SectionSpacer() + if (member.memberActive) { SectionView { - if (contactId != null && knownDirectChat(contactId) != null) { - OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) { - OpenChatButton(onClick = { createMemberContact() }) - } - } if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } @@ -420,6 +475,20 @@ fun GroupMemberInfoLayout( } } +private fun showSendMessageToEnableCallsAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.cant_call_member_alert_title), + text = generalGetString(MR.strings.cant_call_member_send_message_alert_text) + ) +} + +private fun showDirectMessagesProhibitedAlert(title: String) { + AlertManager.shared.showAlertMsg( + title = title, + text = generalGetString(MR.strings.direct_messages_are_prohibited_in_chat) + ) +} + @Composable fun GroupMemberInfoHeader(member: GroupMember) { Column( @@ -513,12 +582,12 @@ fun RemoveMemberButton(onClick: () -> Unit) { @Composable fun OpenChatButton(onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_chat), - stringResource(MR.strings.button_send_direct_message), - click = onClick, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, + InfoViewActionButton( + icon = painterResource(MR.images.ic_chat_bubble), + title = generalGetString(MR.strings.info_view_message_button), + disabled = false, + disabledLook = false, + onClick = onClick ) } @@ -621,7 +690,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem withBGApi { val success = ChatController.apiSetMemberSettings(rhId, gInfo.groupId, member.groupMemberId, memberSettings) if (success) { - ChatModel.upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + withChats { + upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings)) + } } } } @@ -652,7 +723,9 @@ 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) - chatModel.upsertGroupMember(rhId, gInfo, updatedMember) + withChats { + upsertGroupMember(rhId, gInfo, updatedMember) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 82646a99c5..b7d66dd4f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -17,6 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -43,8 +44,10 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) if (g != null) { - m.updateGroup(rhId, g) - currentPreferences = preferences + withChats { + updateGroup(rhId, g) + currentPreferences = preferences + } } afterSave() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 7975a298d1..6375ef1a20 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* @@ -38,7 +39,9 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl withBGApi { val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) if (gInfo != null) { - chatModel.updateGroup(rhId, gInfo) + withChats { + updateGroup(rhId, gInfo) + } close.invoke() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 3bbeceb03c..794d6d4a20 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -28,6 +28,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatJsonLength @@ -52,7 +53,9 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) if (res != null) { gInfo = res - m.updateGroup(rhId, res) + withChats { + updateGroup(rhId, res) + } welcomeText.value = welcome ?: "" } afterSave() 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 f6d23c020f..8fc9d3957c 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 @@ -1,6 +1,7 @@ package chat.simplex.common.views.chatlist import SectionItemView +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -19,6 +20,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -32,7 +34,7 @@ import kotlinx.coroutines.* import kotlinx.datetime.Clock @Composable -fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { +fun ChatListNavLinkView(chat: Chat, nextChatSelected: State, oneHandUI: State) { val showMenu = remember { mutableStateOf(false) } val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) { chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat @@ -47,6 +49,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { val showChatPreviews = chatModel.showChatPreviews.value val inProgress = remember { mutableStateOf(false) } var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { progressByTimeout = if (inProgress.value) { delay(1000) @@ -77,6 +80,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) } is ChatInfo.Group -> @@ -96,6 +100,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) is ChatInfo.Local -> { ChatListNavLinkLayout( @@ -114,6 +119,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) } is ChatInfo.ContactRequest -> @@ -133,6 +139,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( @@ -153,6 +160,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( @@ -169,12 +177,13 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + oneHandUI ) } } @Composable -private fun ErrorChatListItem() { +fun ErrorChatListItem() { Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) } @@ -471,13 +480,13 @@ fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, sh } @Composable -fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { +fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState, onSuccess: ((chat: Chat) -> Unit)? = null) { ItemAction( stringResource(MR.strings.accept_contact_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = false, chatInfo.apiId, chatInfo, true, chatModel, onSuccess) showMenu.value = false } ) @@ -486,7 +495,7 @@ fun ContactRequestMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactRequest, chat painterResource(MR.images.ic_theater_comedy), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(rhId, incognito = true, chatInfo.apiId, chatInfo, true, chatModel, onSuccess) showMenu.value = false } ) @@ -556,7 +565,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { withApi { if (chat.chatStats.unreadCount > 0) { val minUnreadItemId = chat.chatStats.minUnreadItemId - chatModel.markChatItemsRead(chat) + withChats { + markChatItemsRead(chat) + } chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, @@ -573,7 +584,9 @@ fun markChatRead(c: Chat, chatModel: ChatModel) { false ) if (success) { - chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + withChats { + replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false))) + } } } } @@ -591,12 +604,14 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { true ) if (success) { - chatModel.replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + withChats { + replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true))) + } } } } -fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, onSucess: ((chat: Chat) -> Unit)? = null) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), @@ -604,13 +619,13 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque Column { SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel, onSucess) }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel, onSucess) }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -626,13 +641,16 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque ) } -fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { +fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel, close: ((chat: Chat) -> Unit)? = null ) { withBGApi { val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) - chatModel.replaceChat(rhId, contactRequest.id, chat) + withChats { + replaceChat(rhId, contactRequest.id, chat) + } chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) + close?.invoke(chat) } } } @@ -640,7 +658,9 @@ fun acceptContactRequest(rhId: Long?, incognito: Boolean, apiId: Long, contactRe fun rejectContactRequest(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { withBGApi { chatModel.controller.apiRejectContactRequest(rhId, contactRequest.apiId) - chatModel.removeChat(rhId, contactRequest.id) + withChats { + removeChat(rhId, contactRequest.id) + } } } @@ -656,7 +676,9 @@ fun deleteContactConnectionAlert(rhId: Long?, connection: PendingContactConnecti withBGApi { AlertManager.shared.hideAlert() if (chatModel.controller.apiDeleteChat(rhId, ChatType.ContactConnection, connection.apiId)) { - chatModel.removeChat(rhId, connection.id) + withChats { + removeChat(rhId, connection.id) + } onSuccess() } } @@ -675,7 +697,9 @@ fun pendingContactAlertDialog(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatMo withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, chatInfo.chatType, chatInfo.apiId) if (r) { - chatModel.removeChat(rhId, chatInfo.id) + withChats { + removeChat(rhId, chatInfo.id) + } if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -737,7 +761,9 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactId: Long, incognito: Boolean): Boolean { val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { - chatModel.updateContact(rhId, contact) + withChats { + updateContact(rhId, contact) + } AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), @@ -778,7 +804,9 @@ fun deleteGroup(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { withBGApi { val r = chatModel.controller.apiDeleteChat(rhId, ChatType.Group, groupInfo.apiId) if (r) { - chatModel.removeChat(rhId, groupInfo.id) + withChats { + removeChat(rhId, groupInfo.id) + } if (chatModel.chatId.value == groupInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -827,7 +855,9 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo else -> false } if (res && newChatInfo != null) { - chatModel.updateChatInfo(chat.remoteHostId, newChatInfo) + withChats { + updateChatInfo(chat.remoteHostId, newChatInfo) + } if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } @@ -848,6 +878,7 @@ expect fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + oneHandUI: State ) @Preview/*( @@ -890,7 +921,8 @@ fun PreviewChatListNavLinkDirect() { showMenu = remember { mutableStateOf(false) }, disabled = false, selectedChat = remember { mutableStateOf(false) }, - nextChatSelected = remember { mutableStateOf(false) } + nextChatSelected = remember { mutableStateOf(false) }, + oneHandUI = remember { mutableStateOf(false) } ) } } @@ -935,7 +967,8 @@ fun PreviewChatListNavLinkGroup() { showMenu = remember { mutableStateOf(false) }, disabled = false, selectedChat = remember { mutableStateOf(false) }, - nextChatSelected = remember { mutableStateOf(false) } + nextChatSelected = remember { mutableStateOf(false) }, + oneHandUI = remember { mutableStateOf(false) } ) } } @@ -957,7 +990,8 @@ fun PreviewChatListNavLinkContactRequest() { showMenu = remember { mutableStateOf(false) }, disabled = false, selectedChat = remember { mutableStateOf(false) }, - nextChatSelected = remember { mutableStateOf(false) } + nextChatSelected = remember { mutableStateOf(false) }, + oneHandUI = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 43f0b7ef9b..0eeed55da3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.text.font.FontStyle @@ -33,7 +33,6 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call -import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @@ -42,28 +41,31 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import java.net.URI -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +private fun showNewChatSheet(oneHandUI: State, barTitle: String) { + ModalManager.start.closeModals() + ModalManager.end.closeModals() + ModalManager.start.showModalCloseable( + closeOnTop = !oneHandUI.value, + closeBarTitle = if (oneHandUI.value) barTitle else null, + endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) } + ) { close -> + NewChatSheet(rh = chatModel.currentRemoteHost.value, close) + } +} + @Composable fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } - val showNewChatSheet = { - newChatSheetState.value = AnimatedViewState.VISIBLE - } - val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> - if (animated) newChatSheetState.value = AnimatedViewState.HIDING - else newChatSheetState.value = AnimatedViewState.GONE - } + val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI } + LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { delay(1000L) ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } } } - LaunchedEffect(chatModel.clearOverlays.value) { - if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) - } + if (appPlatform.isDesktop) { KeyChangeEffect(chatModel.chatId.value) { if (chatModel.chatId.value != null) { @@ -77,7 +79,31 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val scope = rememberCoroutineScope() val (userPickerState, scaffoldState ) = settingsState - Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(scaffoldState.drawerState, userPickerState, stopped)} }, + Scaffold( + topBar = { + if (!oneHandUI.state.value) { + Box(Modifier.padding(end = endPadding)) { + ChatListToolbar( + scaffoldState.drawerState, + userPickerState, + stopped, + oneHandUI + ) + } + } + }, + bottomBar = { + if (oneHandUI.state.value) { + Box(Modifier.padding(end = endPadding)) { + ChatListToolbar( + scaffoldState.drawerState, + userPickerState, + stopped, + oneHandUI + ) + } + } + }, scaffoldState = scaffoldState, drawerContent = { tryOrShowError("Settings", error = { ErrorSettingsView() }) { @@ -89,11 +115,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { - if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { + if (!oneHandUI.state.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { FloatingActionButton( onClick = { if (!stopped) { - if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet() + showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat)) } }, Modifier @@ -108,25 +134,33 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, contentColor = Color.White ) { - Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier)) + Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier)) } } } ) { - Box(Modifier.padding(it).padding(end = endPadding)) { + var modifier = Modifier.padding(it).padding(end = endPadding) + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + Box(modifier) { Box( modifier = Modifier .fillMaxSize() ) { if (!chatModel.desktopNoUserNoRemote) { - ChatList(chatModel, searchText = searchText) + ChatList(chatModel, searchText = searchText, oneHandUI = oneHandUI) } - if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { - Text(stringResource( - if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) - if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) { - OnboardingButtons(showNewChatSheet) + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + var textModifier = Modifier.align(Alignment.Center) + + if (oneHandUI.state.value) { + textModifier = textModifier.scale(scaleX = 1f, scaleY = -1f) } + + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), textModifier, color = MaterialTheme.colors.secondary) } } } @@ -135,17 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (appPlatform.isDesktop) { val call = remember { chatModel.activeCall }.value if (call != null) { - ActiveCallInteractiveArea(call, newChatSheetState) + ActiveCallInteractiveArea(call) } } - // TODO disable this button and sheet for the duration of the switch - tryOrShowError("NewChatSheet", error = {}) { - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) - } } if (appPlatform.isAndroid) { tryOrShowError("UserPicker", error = {}) { - UserPicker(chatModel, userPickerState) { + UserPicker( + chatModel = chatModel, + userPickerState = userPickerState, + contentAlignment = if (oneHandUI.state.value) Alignment.BottomStart else Alignment.TopStart + ) { scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } userPickerState.value = AnimatedViewState.GONE } @@ -153,27 +187,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } -@Composable -private fun OnboardingButtons(openNewChatSheet: () -> Unit) { - Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) { - ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet) - val color = MaterialTheme.colors.primaryVariant - Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = { - val trianglePath = Path().apply { - moveTo(0.dp.toPx(), 0f) - lineTo(16.dp.toPx(), 0.dp.toPx()) - lineTo(8.dp.toPx(), 10.dp.toPx()) - lineTo(0.dp.toPx(), 0.dp.toPx()) - } - drawPath( - color = color, - path = trianglePath - ) - }) - Spacer(Modifier.height(62.dp)) - } -} - @Composable private fun ConnectButton(text: String, onClick: () -> Unit) { Button( @@ -191,10 +204,37 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { +private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean, oneHandUI: SharedPreference) { val serversSummary: MutableState = remember { mutableStateOf(null) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val updatingProgress = remember { chatModel.updatingProgress }.value + + if (oneHandUI.state.value) { + val sp16 = with(LocalDensity.current) { 16.sp.toDp() } + + barButtons.add { + IconButton( + onClick = { + if (!stopped) { + showNewChatSheet(oneHandUI.state, generalGetString(MR.strings.new_chat)) + } + }, + ) { + Box( + modifier = Modifier + .background(if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, shape = CircleShape) + .padding(DEFAULT_PADDING_HALF) + ){ + Icon( + painterResource(MR.images.ic_edit_filled), + stringResource(MR.strings.add_contact_or_create_group), + Modifier.size(sp16), + tint = if (!stopped) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSecondary) + } + } + } + } + if (updatingProgress != null) { barButtons.add { val interactionSource = remember { MutableInteractionSource() } @@ -291,10 +331,12 @@ fun SubscriptionStatusIndicator(click: (() -> Unit)) { val scope = rememberCoroutineScope() suspend fun setSubsTotal() { - val r = chatModel.controller.getAgentSubsTotal(chatModel.remoteHostId()) - if (r != null) { - subs = r.first - hasSess = r.second + if (chatModel.currentUser.value != null) { + val r = chatModel.controller.getAgentSubsTotal(chatModel.remoteHostId()) + if (r != null) { + subs = r.first + hasSess = r.second + } } } @@ -342,6 +384,7 @@ fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> U } } + @Composable private fun BoxScope.unreadBadge(text: String? = "") { Text( @@ -377,7 +420,7 @@ private fun ToggleFilterEnabledButton() { } @Composable -expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) +expect fun ActiveCallInteractiveArea(call: Call) fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") @@ -391,11 +434,22 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { } @Composable -private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState) { - Row(verticalAlignment = Alignment.CenterVertically) { +private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState, oneHandUI: SharedPreference) { + var modifier = Modifier.fillMaxWidth(); + + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { val focusRequester = remember { FocusRequester() } var focused by remember { mutableStateOf(false) } - Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.secondary) + Icon( + painterResource(MR.images.ic_search), + contentDescription = null, + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), + tint = MaterialTheme.colors.secondary + ) SearchTextField( Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), placeholder = stringResource(MR.strings.search_or_paste_simplex_link), @@ -415,7 +469,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState } } else { val padding = if (appPlatform.isDesktop) 0.dp else 7.dp - if (chatModel.chats.size > 0) { + if (chatModel.chats.value.isNotEmpty()) { ToggleFilterEnabledButton() } Spacer(Modifier.width(padding)) @@ -481,9 +535,35 @@ private fun ErrorSettingsView() { private var lazyListState = 0 to 0 +enum class ScrollDirection { + Up, Down, Idle +} + @Composable -private fun ChatList(chatModel: ChatModel, searchText: MutableState) { +private fun ChatList(chatModel: ChatModel, searchText: MutableState, oneHandUI: SharedPreference) { val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) } + var previousIndex by remember { mutableStateOf(0) } + var previousScrollOffset by remember { mutableStateOf(0) } + + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + val currentIndex = listState.firstVisibleItemIndex + val currentScrollOffset = listState.firstVisibleItemScrollOffset + val threshold = 25 + + scrollDirection = when { + currentIndex > previousIndex -> ScrollDirection.Down + currentIndex < previousIndex -> ScrollDirection.Up + currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down + currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up + currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle + else -> scrollDirection + } + + previousIndex = currentIndex + previousScrollOffset = currentScrollOffset + } + DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } @@ -494,7 +574,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState(null) } - val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.toList()) + val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList()) LazyColumnWithScrollBar( Modifier.fillMaxWidth(), listState @@ -504,7 +584,9 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD } else { chats.filter { chat -> when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) - } else { - (viewNameContains(cInfo, s) || - cInfo.contact.profile.displayName.lowercase().contains(s) || - cInfo.contact.fullName.lowercase().contains(s)) - } + is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && ( + if (s.isEmpty()) { + chat.id == chatModel.chatId.value || filtered(chat) + } else { + (viewNameContains(cInfo, s) || + cInfo.contact.profile.displayName.lowercase().contains(s) || + cInfo.contact.fullName.lowercase().contains(s)) + }) is ChatInfo.Group -> if (s.isEmpty()) { chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 2cf9008fc3..cb22bd8f60 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -207,7 +207,7 @@ fun ChatPreviewView( } else { when (cInfo) { is ChatInfo.Direct -> - if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) { + if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) { Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary) } else if (!cInfo.contact.sndReady && cInfo.contact.activeConn != null) { if (cInfo.contact.nextSendGrpInv) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 8621c73ef3..4bb5474fa6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -719,7 +719,9 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta val scope = rememberCoroutineScope() suspend fun setServersSummary() { - serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId()) + if (chatModel.currentUser.value != null) { + serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId()) + } } LaunchedEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index c755ab50a0..8035cddc6d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -6,6 +6,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -21,7 +22,8 @@ fun ShareListNavLinkView( chatModel: ChatModel, isMediaOrFileAttachment: Boolean, isVoice: Boolean, - hasSimplexLink: Boolean + hasSimplexLink: Boolean, + oneHandUI: State ) { val stopped = chatModel.chatRunning.value == false val scope = rememberCoroutineScope() @@ -29,7 +31,7 @@ fun ShareListNavLinkView( is ChatInfo.Direct -> { val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice) ShareListNavLinkLayout( - chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited) }, + chatLinkPreview = { SharePreviewView(chat, disabled = voiceProhibited, oneHandUI = oneHandUI) }, click = { if (voiceProhibited) { showForwardProhibitedByPrefAlert() @@ -46,7 +48,7 @@ fun ShareListNavLinkView( val voiceProhibited = isVoice && !chat.chatInfo.featureEnabled(ChatFeature.Voice) val prohibitedByPref = simplexLinkProhibited || fileProhibited || voiceProhibited ShareListNavLinkLayout( - chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref) }, + chatLinkPreview = { SharePreviewView(chat, disabled = prohibitedByPref, oneHandUI = oneHandUI) }, click = { if (prohibitedByPref) { showForwardProhibitedByPrefAlert() @@ -59,7 +61,7 @@ fun ShareListNavLinkView( } is ChatInfo.Local -> ShareListNavLinkLayout( - chatLinkPreview = { SharePreviewView(chat, disabled = false) }, + chatLinkPreview = { SharePreviewView(chat, disabled = false, oneHandUI = oneHandUI) }, click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, stopped ) @@ -78,7 +80,7 @@ private fun showForwardProhibitedByPrefAlert() { private fun ShareListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, - stopped: Boolean + stopped: Boolean, ) { SectionItemView(minHeight = 50.dp, click = click, disabled = stopped) { chatLinkPreview() @@ -87,9 +89,15 @@ private fun ShareListNavLinkLayout( } @Composable -private fun SharePreviewView(chat: Chat, disabled: Boolean) { +private fun SharePreviewView(chat: Chat, disabled: Boolean, oneHandUI: State) { + var modifier = Modifier.fillMaxSize() + + if (oneHandUI.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + Row( - Modifier.fillMaxSize(), + modifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 69382d9fde..b86bc16dbc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -24,13 +25,16 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe var searchInList by rememberSaveable { mutableStateOf("") } val (userPickerState, scaffoldState) = settingsState val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp + val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI } + Scaffold( Modifier.padding(end = endPadding), contentColor = LocalContentColor.current, drawerContentColor = LocalContentColor.current, scaffoldState = scaffoldState, - topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } }, - ) { + topBar = { if (!oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } }, + bottomBar = { if (oneHandUI.state.value) Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } }, + ) { val sharedContent = chatModel.sharedContent.value var isMediaOrFileAttachment = false var isVoice = false @@ -56,21 +60,27 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } null -> {} } + var modifier = Modifier.fillMaxSize() + + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + Box(Modifier.padding(it)) { Column( - modifier = Modifier - .fillMaxSize() + modifier = modifier ) { - if (chatModel.chats.isNotEmpty()) { + if (chatModel.chats.value.isNotEmpty()) { ShareList( chatModel, search = searchInList, isMediaOrFileAttachment = isMediaOrFileAttachment, isVoice = isVoice, - hasSimplexLink = hasSimplexLink + hasSimplexLink = hasSimplexLink, + oneHandUI = oneHandUI.state ) } else { - EmptyList() + EmptyList(oneHandUI = oneHandUI.state) } } } @@ -91,8 +101,14 @@ private fun hasSimplexLink(msg: String): Boolean { } @Composable -private fun EmptyList() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { +private fun EmptyList(oneHandUI: State) { + var modifier = Modifier.fillMaxSize() + + if (oneHandUI.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + Box(modifier, contentAlignment = Alignment.Center) { Text(stringResource(MR.strings.you_have_no_chats), color = MaterialTheme.colors.secondary) } } @@ -127,7 +143,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState }) } } - if (chatModel.chats.size >= 8) { + if (chatModel.chats.value.size >= 8) { barButtons.add { IconButton({ showSearch = true }) { Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) @@ -182,11 +198,12 @@ private fun ShareList( search: String, isMediaOrFileAttachment: Boolean, isVoice: Boolean, - hasSimplexLink: Boolean + hasSimplexLink: Boolean, + oneHandUI: State ) { val chats by remember(search) { derivedStateOf { - val sorted = chatModel.chats.toList().sortedByDescending { it.chatInfo is ChatInfo.Local } + val sorted = chatModel.chats.value.toList().sortedByDescending { it.chatInfo is ChatInfo.Local } if (search.isEmpty()) { sorted.filter { it.chatInfo.ready } } else { @@ -203,7 +220,8 @@ private fun ShareList( chatModel, isMediaOrFileAttachment = isMediaOrFileAttachment, isVoice = isVoice, - hasSimplexLink = hasSimplexLink + hasSimplexLink = hasSimplexLink, + oneHandUI = oneHandUI ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index f79ff441d3..de77d13cbf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -40,6 +40,7 @@ fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, showSettings: Boolean = true, + contentAlignment: Alignment = Alignment.TopStart, showCancel: Boolean = false, cancelClicked: () -> Unit = {}, useFromDesktopClicked: () -> Unit = {}, @@ -149,7 +150,8 @@ fun UserPicker( .graphicsLayer { alpha = animatedFloat.value translationY = (animatedFloat.value - 1) * xOffset - } + }, + contentAlignment = contentAlignment ) { Column( Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt new file mode 100644 index 0000000000..4622bff03b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -0,0 +1,143 @@ +package chat.simplex.common.views.contacts + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.ContactType +import chat.simplex.common.views.newchat.chatContactType +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +private fun onRequestAccepted(chat: Chat) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Direct) { + ModalManager.start.closeModals() + if (chatInfo.contact.sndReady) { + openLoadedChat(chat, chatModel) + } + } +} + +@Composable +fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, oneHandUI: State) { + val showMenu = remember { mutableStateOf(false) } + val rhId = chat.remoteHostId + val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(rhId to chat.chatInfo.id) + val contactType = chatContactType(chat) + + LaunchedEffect(chat.id) { + showMenu.value = false + delay(500L) + } + + val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } } + val view = LocalMultiplatformView() + + when (chat.chatInfo) { + is ChatInfo.Direct -> { + ChatListNavLinkLayout( + chatLinkPreview = { + tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) { + ContactPreviewView(chat, disabled) + } + }, + click = { + hideKeyboard(view) + when (contactType) { + ContactType.RECENT -> { + withApi { + openChat(rhId, chat.chatInfo, chatModel) + ModalManager.start.closeModals() + } + } + ContactType.CHAT_DELETED -> { + withApi { + openChat(rhId, chat.chatInfo, chatModel) + withChats { + updateContact(rhId, chat.chatInfo.contact.copy(chatDeleted = false)) + } + ModalManager.start.closeModals() + } + } + ContactType.CARD -> { + askCurrentOrIncognitoProfileConnectContactViaAddress( + chatModel, + rhId, + chat.chatInfo.contact, + close = { ModalManager.start.closeModals() }, + openChat = true + ) + } + else -> {} + } + }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) { + DeleteContactAction(chat, chatModel, showMenu) + } + }, + showMenu, + disabled, + selectedChat, + nextChatSelected, + oneHandUI + ) + } + is ChatInfo.ContactRequest -> { + ChatListNavLinkLayout( + chatLinkPreview = { + tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) { + ContactPreviewView(chat, disabled) + } + }, + click = { + hideKeyboard(view) + contactRequestAlertDialog( + rhId, + chat.chatInfo, + chatModel, + onSucess = { onRequestAccepted(it) } + ) + }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) { + ContactRequestMenuItems( + rhId = chat.remoteHostId, + chatInfo = chat.chatInfo, + chatModel = chatModel, + showMenu = showMenu, + onSuccess = { onRequestAccepted(it) } + ) + } + }, + showMenu, + disabled, + selectedChat, + nextChatSelected, + oneHandUI + ) + } + else -> {} + } +} + +@Composable +fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.delete_contact_menu_action), + painterResource(MR.images.ic_delete), + onClick = { + deleteContactDialog(chat, chatModel) + showMenu.value = false + }, + color = Color.Red + ) +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt new file mode 100644 index 0000000000..d47853f03c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -0,0 +1,131 @@ +package chat.simplex.common.views.contacts + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.newchat.ContactType +import chat.simplex.common.views.newchat.chatContactType +import chat.simplex.res.MR + +@Composable +fun ContactPreviewView( + chat: Chat, + disabled: Boolean, +) { + val cInfo = chat.chatInfo + val contactType = chatContactType(chat) + + @Composable + fun VerifiedIcon() { + Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) + } + + @Composable + fun chatPreviewTitle() { + val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } + + val textColor = when { + deleting -> MaterialTheme.colors.secondary + contactType == ContactType.CARD -> MaterialTheme.colors.primary + contactType == ContactType.REQUEST -> MaterialTheme.colors.primary + contactType == ContactType.RECENT && chat.chatInfo.incognito -> Indigo + else -> Color.Unspecified + } + + when (cInfo) { + is ChatInfo.Direct -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (cInfo.contact.verified) { + VerifiedIcon() + } + Text( + cInfo.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = textColor + ) + } + is ChatInfo.ContactRequest -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + cInfo.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = textColor + ) + } + else -> {} + } + } + + Row( + modifier = Modifier.padding(PaddingValues(horizontal = DEFAULT_PADDING_HALF)), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(contentAlignment = Alignment.BottomEnd) { + ChatInfoImage(cInfo, size = 42.dp) + } + + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + + Box(modifier = Modifier.weight(10f, fill = true)) { + chatPreviewTitle() + } + + Spacer(Modifier.fillMaxWidth().weight(1f)) + + if (chat.chatInfo is ChatInfo.ContactRequest) { + Icon( + painterResource(MR.images.ic_check), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(23.dp) + ) + } + + if (contactType == ContactType.CARD) { + Icon( + painterResource(MR.images.ic_mail), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(21.dp) + ) + } + + if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(17.dp) + ) + if (chat.chatInfo.incognito) { + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + } + } + + + if (chat.chatInfo.incognito) { + Icon( + painterResource(MR.images.ic_theater_comedy), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(21.dp) + ) + } + } +} \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa43048e9e..3ade14bdce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller -import chat.simplex.common.model.ChatModel.updatingChatsMutex +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -502,7 +502,11 @@ fun deleteChatDatabaseFilesAndState() { // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null chatModel.chatItems.clear() - chatModel.chats.clear() + withLongRunningApi { + withChats { + chats.clear() + } + } chatModel.users.clear() ntfManager.cancelAllNotifications() } @@ -714,10 +718,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - updatingChatsMutex.withLock { + withChats { // this is using current remote host on purpose - if it changes during update, it will load correct chats val chats = m.controller.apiGetChats(m.remoteHostId()) - m.updateChats(chats) + updateChats(chats) } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt index d92dccddc2..8cdac67bf4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* @@ -18,17 +20,26 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @Composable -fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, endButtons: @Composable RowScope.() -> Unit = {}) { +fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}) { + var rowModifier = Modifier + .fillMaxWidth() + .height(AppBarHeight * fontSizeSqrtMultiplier) + + if (!closeBarTitle.isNullOrEmpty()) { + rowModifier = rowModifier.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + } + Column( - Modifier + verticalArrangement = arrangement, + modifier = Modifier .fillMaxWidth() .heightIn(min = AppBarHeight * fontSizeSqrtMultiplier) - .padding(horizontal = AppBarHorizontalPadding) ) { Row( + modifier = Modifier.padding(horizontal = AppBarHorizontalPadding), content = { Row( - Modifier.fillMaxWidth().height(AppBarHeight * fontSizeSqrtMultiplier), + rowModifier, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -37,6 +48,18 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co } else { Spacer(Modifier) } + if (!closeBarTitle.isNullOrEmpty()) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + closeBarTitle, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + } + } Row { endButtons() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 3d6d242832..c7ae522684 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -16,7 +16,7 @@ import chat.simplex.res.MR @Composable fun DefaultTopAppBar( - navigationButton: @Composable RowScope.() -> Unit, + navigationButton: (@Composable RowScope.() -> Unit)? = null, title: (@Composable () -> Unit)?, onTitleClick: (() -> Unit)? = null, showSearch: Boolean, @@ -126,5 +126,6 @@ private fun TopAppBar( val AppBarHeight = 56.dp val AppBarHorizontalPadding = 4.dp +val BottomAppBarHeight = 60.dp private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding val TitleInsetWithIcon = 72.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 4acb18561a..805fb772f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs @@ -24,6 +25,8 @@ fun ModalView( enableClose: Boolean = true, background: Color = MaterialTheme.colors.background, modifier: Modifier = Modifier, + closeOnTop: Boolean = true, + closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { @@ -32,8 +35,16 @@ fun ModalView( } Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { - CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons) - Box(modifier) { content() } + if (closeOnTop) { + CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons) + } + Box(if (closeOnTop) modifier else modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier)) { + content() + } + } + + if (!closeOnTop) { + CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons, arrangement = Arrangement.Bottom, closeBarTitle = closeBarTitle) } } } @@ -60,17 +71,17 @@ class ModalManager(private val placement: ModalPlacement? = null) { // java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null) - fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null,endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { val data = ModalData() showCustomModal { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content() }) + ModalView(close, showClose = showClose, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, endButtons = endButtons, content = { data.content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, closeBarTitle: String? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { val data = ModalData() showCustomModal { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content(close) }) + ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, closeBarTitle = closeBarTitle, content = { data.content(close) }) } } @@ -105,6 +116,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { val hasModalsOpen: Boolean @Composable get () = remember { modalCount }.value > 0 + fun openModalCount() = modalCount.value + fun closeModal() { if (modalViews.isNotEmpty()) { if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 807b3a09c0..a5458859db 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -18,6 +18,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers @@ -32,19 +33,22 @@ import kotlinx.coroutines.launch import java.net.URI @Composable -fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit) { +fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, closeAll: () -> Unit) { val rhId = rh?.remoteHostId AddGroupLayout( createGroup = { incognito, groupProfile -> withBGApi { val groupInfo = chatModel.controller.apiNewGroup(rhId, incognito, groupProfile) if (groupInfo != null) { - chatModel.updateGroup(rhId = rhId, groupInfo) - chatModel.chatItems.clear() - chatModel.chatItemStatuses.clear() - chatModel.chatId.value = groupInfo.id + withChats { + updateGroup(rhId = rhId, groupInfo) + chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() + chatModel.chatId.value = groupInfo.id + } setGroupMembers(rhId, groupInfo, chatModel) - close.invoke() + closeAll.invoke() + if (!groupInfo.incognito) { ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 5b56fe5e39..e49fbcf1e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* @@ -331,7 +332,9 @@ suspend fun connectViaUri( val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString()) val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (pcc != null) { - chatModel.updateContactConnection(rhId, pcc) + withChats { + updateContactConnection(rhId, pcc) + } close?.invoke() AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 31623e4a61..09a6c447d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -22,6 +22,7 @@ import chat.simplex.common.views.chat.LocalAliasEditor import chat.simplex.common.views.chatlist.deleteContactConnectionAlert import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* @@ -186,7 +187,9 @@ fun DeleteButton(onClick: () -> Unit) { private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnection, localAlias: String, chatModel: ChatModel) = withBGApi { chatModel.controller.apiSetConnectionAlias(rhId, contactConnection.pccConnId, localAlias)?.let { - chatModel.updateContactConnection(rhId, it) + withChats { + updateContactConnection(rhId, it) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 9faee4532a..6714e3aff6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -1,177 +1,597 @@ package chat.simplex.common.views.newchat -import androidx.compose.animation.* -import androidx.compose.animation.core.* +import SectionDividerSpaced +import SectionItemView +import SectionView +import TextIconSpaced import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import chat.simplex.common.model.ChatModel +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.ScrollDirection +import chat.simplex.common.views.contacts.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlin.math.roundToInt +import kotlinx.coroutines.flow.distinctUntilChanged +import java.net.URI @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { - // TODO close new chat if remote host changes in model - if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } - NewChatSheetLayout( - newChatSheetState, - stopped, - addContact = { - closeNewChatSheet(false) - ModalManager.center.closeModals() - ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) } - }, - scanPaste = { - closeNewChatSheet(false) - ModalManager.center.closeModals() - ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) } - }, - createGroup = { - closeNewChatSheet(false) - ModalManager.center.closeModals() - ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close) } - }, - closeNewChatSheet, - ) +fun NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { + val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI } + + Column( + modifier = Modifier.fillMaxSize() + ) { + if (!oneHandUI.state.value) { + Box(contentAlignment = Alignment.Center) { + val bottomPadding = DEFAULT_PADDING + AppBarTitle( + stringResource(MR.strings.new_chat), + hostDevice(rh?.remoteHostId), + bottomPadding = bottomPadding + ) + } + } + + val closeAll = { ModalManager.start.closeModals() } + + var modifier = Modifier.fillMaxSize() + + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + Column(modifier = modifier) { + NewChatSheetLayout( + addContact = { + ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) } + }, + scanPaste = { + ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) } + }, + createGroup = { + ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) } + }, + rh = rh, + close = close, + oneHandUI = oneHandUI + ) + } + } } -private val titles = listOf( - MR.strings.add_contact_tab, - MR.strings.scan_paste_link, - MR.strings.create_group_button -) -private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group) +enum class ContactType { + CARD, REQUEST, RECENT, CHAT_DELETED, UNLISTED +} + +fun chatContactType(chat: Chat): ContactType { + return when (val cInfo = chat.chatInfo) { + is ChatInfo.ContactRequest -> ContactType.REQUEST + is ChatInfo.Direct -> { + val contact = cInfo.contact; + + when { + contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD + contact.chatDeleted -> ContactType.CHAT_DELETED + contact.contactStatus == ContactStatus.Active -> ContactType.RECENT + else -> ContactType.UNLISTED + } + } + else -> ContactType.UNLISTED + } +} + +private fun filterContactTypes(c: List, contactTypes: List): List { + return c.filter { chat -> contactTypes.contains(chatContactType(chat)) } +} + +private var lazyListState = 0 to 0 @Composable private fun NewChatSheetLayout( - newChatSheetState: StateFlow, - stopped: Boolean, + rh: RemoteHostInfo?, addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit, - closeNewChatSheet: (animated: Boolean) -> Unit, + close: () -> Unit, + oneHandUI: SharedPreference ) { - var newChat by remember { mutableStateOf(newChatSheetState.value) } - val resultingColor = if (isInDarkTheme()) Color.Black.copy(0.64f) else DrawerDefaults.scrimColor - val animatedColor = remember { - Animatable( - if (newChat.isVisible()) Color.Transparent else resultingColor, - Color.VectorConverter(resultingColor.colorSpace) - ) + val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val searchShowingSimplexLink = remember { mutableStateOf(false) } + val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } + val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value + val baseContactTypes = listOf(ContactType.CARD, ContactType.RECENT, ContactType.REQUEST) + val contactTypes by remember(baseContactTypes, searchText.value.text.isEmpty()) { + derivedStateOf { contactTypesSearchTargets(baseContactTypes, searchText.value.text.isEmpty()) } } - val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } - LaunchedEffect(Unit) { - launch { - newChatSheetState.collect { - newChat = it - launch { - animatedColor.animateTo(if (newChat.isVisible()) resultingColor else Color.Transparent, newChatSheetAnimSpec()) - } - launch { - animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) - if (newChat.isHiding()) closeNewChatSheet(false) + val allChats by remember(chatModel.chats.value, contactTypes) { + derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) } + } + var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) } + var previousIndex by remember { mutableStateOf(0) } + var previousScrollOffset by remember { mutableStateOf(0) } + + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + val currentIndex = listState.firstVisibleItemIndex + val currentScrollOffset = listState.firstVisibleItemScrollOffset + val threshold = 25 + + scrollDirection = when { + currentIndex > previousIndex -> ScrollDirection.Down + currentIndex < previousIndex -> ScrollDirection.Up + currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down + currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up + currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle + else -> scrollDirection + } + + previousIndex = currentIndex + previousScrollOffset = currentScrollOffset + } + + val filteredContactChats = filteredContactChats( + showUnreadAndFavorites = showUnreadAndFavorites, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + searchShowingSimplexLink = searchShowingSimplexLink, + searchText = searchText.value.text, + contactChats = allChats + ) + + var sectionModifier = Modifier.fillMaxWidth() + + if (oneHandUI.state.value) { + sectionModifier = sectionModifier.scale(scaleX = 1f, scaleY = -1f) + } + + LazyColumnWithScrollBar( + Modifier.fillMaxWidth(), + listState + ) { + stickyHeader { + Column( + Modifier + .offset { + val y = if (searchText.value.text.isEmpty()) { + if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) { + 0 + } else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000 + } else { + 0 + } + IntOffset(0, y) + } + .background(MaterialTheme.colors.background) + ) { + if (!oneHandUI.state.value) { + Divider() } + ContactsSearchBar( + listState = listState, + searchText = searchText, + searchShowingSimplexLink = searchShowingSimplexLink, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + close = close, + oneHandUI = oneHandUI + ) + Divider() } } - } - val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp - val maxWidth = with(LocalDensity.current) { windowWidth() * density } - Column( - Modifier - .fillMaxSize() - .padding(end = endPadding) - .offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else 0, 0) } - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) } - .drawBehind { drawRect(animatedColor.value) }, - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.End - ) { - val actions = remember { listOf(addContact, scanPaste, createGroup) } - val backgroundColor = if (isInDarkTheme()) - blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F) - else - MaterialTheme.colors.background - LazyColumn(Modifier - .graphicsLayer { - alpha = animatedFloat.value - translationY = (1 - animatedFloat.value) * 20.dp.toPx() - }) { - items(actions.size) { index -> + item { + Spacer(Modifier.padding(bottom = DEFAULT_PADDING)) + + if (searchText.value.text.isEmpty()) { Row { - Spacer(Modifier.weight(1f)) - Box(contentAlignment = Alignment.CenterEnd) { - Button( - actions[index], - shape = RoundedCornerShape(21.dp * fontSizeSqrtMultiplier), - colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor), - elevation = null, - contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF), - modifier = Modifier.height(42.dp * fontSizeSqrtMultiplier) - ) { - Text( - stringResource(titles[index]), - Modifier.padding(start = DEFAULT_PADDING_HALF), - color = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary, - fontWeight = FontWeight.Medium, - ) - Icon( - painterResource(icons[index]), - stringResource(titles[index]), - Modifier.size(42.dp * fontSizeSqrtMultiplier), - tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary - ) + SectionView { + NewChatButton( + icon = painterResource(MR.images.ic_add_link), + text = stringResource(MR.strings.add_contact_tab), + click = addContact, + extraPadding = true, + oneHandUI = oneHandUI.state + ) + NewChatButton( + icon = painterResource(MR.images.ic_qr_code), + text = if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link), + click = scanPaste, + extraPadding = true, + oneHandUI = oneHandUI.state + ) + NewChatButton( + icon = painterResource(MR.images.ic_group), + text = stringResource(MR.strings.create_group_button), + click = createGroup, + extraPadding = true, + oneHandUI = oneHandUI.state + ) + } + } + SectionDividerSpaced(maxBottomPadding = false) + + val deletedContactTypes = listOf(ContactType.CHAT_DELETED) + val deletedChats by remember(chatModel.chats.value, deletedContactTypes) { + derivedStateOf { filterContactTypes(chatModel.chats.value, deletedContactTypes) } + } + if (deletedChats.isNotEmpty()) { + Row(modifier = sectionModifier) { + SectionView { + SectionItemView( + click = { + ModalManager.start.showCustomModal { closeDeletedChats -> + ModalView( + close = closeDeletedChats, + closeOnTop = !oneHandUI.state.value, + closeBarTitle = if (oneHandUI.state.value) generalGetString(MR.strings.deleted_chats) else null, + endButtons = { Spacer(Modifier.minimumInteractiveComponentSize()) } + ) { + DeletedContactsView(rh = rh, close = { + ModalManager.start.closeModals() + }) + } + } + } + ) { + Icon( + painterResource(MR.images.ic_folder_open), + contentDescription = stringResource(MR.strings.deleted_chats), + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced(extraPadding = true) + Text(text = stringResource(MR.strings.deleted_chats), color = MaterialTheme.colors.onBackground) + } } } - Spacer(Modifier.width(DEFAULT_PADDING)) + SectionDividerSpaced() } - Spacer(Modifier.height(DEFAULT_PADDING)) } } - FloatingActionButton( - onClick = { if (!stopped) closeNewChatSheet(true) }, - Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING).size(AppBarHeight * fontSizeSqrtMultiplier), - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp, - ), - backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - contentColor = Color.White + + item { + if (filteredContactChats.isNotEmpty() && !oneHandUI.state.value) { + Text( + stringResource(MR.strings.contact_list_header_title).uppercase(), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, + modifier = sectionModifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), fontSize = 12.sp + ) + } + } + + itemsIndexed(filteredContactChats) { index, chat -> + val nextChatSelected = remember(chat.id, filteredContactChats) { + derivedStateOf { + chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value + } + } + ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state) + } + } + + if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) { + Column(sectionModifier.fillMaxSize().padding(DEFAULT_PADDING)) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + generalGetString(MR.strings.no_filtered_contacts), + color = MaterialTheme.colors.secondary + ) + } + } + } +} + +@Composable +private fun NewChatButton( + icon: Painter, + text: String, + click: () -> Unit, + textColor: Color = Color.Unspecified, + iconColor: Color = MaterialTheme.colors.secondary, + disabled: Boolean = false, + extraPadding: Boolean = false, + oneHandUI: State +) { + SectionItemView(click, disabled = disabled) { + Row(modifier = if (oneHandUI.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier) { + Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) + TextIconSpaced(extraPadding) + Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor) + } + } +} + +@Composable +private fun ContactsSearchBar( + listState: LazyListState, + searchText: MutableState, + searchShowingSimplexLink: MutableState, + searchChatFilteredBySimplexLink: MutableState, + close: () -> Unit, + oneHandUI: SharedPreference +) { + var modifier = Modifier.fillMaxWidth(); + + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + var focused by remember { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + val focusRequester = remember { FocusRequester() } + Icon( + painterResource(MR.images.ic_search), + contentDescription = null, + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), + tint = MaterialTheme.colors.secondary + ) + SearchTextField( + Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + placeholder = stringResource(MR.strings.search_or_paste_simplex_link), + alwaysVisible = true, + searchText = searchText, + trailingContent = null, ) { - Icon( - painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), - Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier) - ) - Icon( - painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group), - Modifier.graphicsLayer { alpha = animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier) + searchText.value = searchText.value.copy(it) + } + val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } } + if (hasText.value) { + val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() } + BackHandler(onBack = hideSearchOnBack) + KeyChangeEffect(chatModel.currentRemoteHost.value) { + hideSearchOnBack() + } + } else { + Row { + val padding = if (appPlatform.isDesktop) 0.dp else 7.dp + if (chatModel.chats.size > 0) { + ToggleFilterButton() + } + Spacer(Modifier.width(padding)) + } + } + val focusManager = LocalFocusManager.current + val keyboardState = getKeyboardState() + LaunchedEffect(keyboardState.value) { + if (keyboardState.value == KeyboardState.Closed && focused) { + focusManager.clearFocus() + } + } + val view = LocalMultiplatformView() + LaunchedEffect(Unit) { + snapshotFlow { searchText.value.text } + .distinctUntilChanged() + .collect { + val link = strHasSingleSimplexLink(it.trim()) + if (link != null) { + // if SimpleX link is pasted, show connection dialogue + hideKeyboard(view) + if (link.format is Format.SimplexLink) { + val linkText = + link.simplexLinkText(link.format.linkType, link.format.smpHosts) + searchText.value = + searchText.value.copy(linkText, selection = TextRange.Zero) + } + searchShowingSimplexLink.value = true + searchChatFilteredBySimplexLink.value = null + connect( + link = link.text, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + close = close, + cleanup = { searchText.value = TextFieldValue() } + ) + } else if (!searchShowingSimplexLink.value || it.isEmpty()) { + if (it.isNotEmpty()) { + // if some other text is pasted, enter search mode + focusRequester.requestFocus() + } else if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } + searchShowingSimplexLink.value = false + searchChatFilteredBySimplexLink.value = null + } + } + } + } +} + +@Composable +private fun ToggleFilterButton() { + val pref = remember { ChatController.appPrefs.showUnreadAndFavorites } + IconButton(onClick = { pref.set(!pref.get()) }) { + val sp16 = with(LocalDensity.current) { 16.sp.toDp() } + Icon( + painterResource(MR.images.ic_filter_list), + null, + tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary, + modifier = Modifier + .padding(3.dp) + .background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) + .border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) + .padding(3.dp) + .size(sp16) + ) + } +} + +private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState, close: () -> Unit, cleanup: (() -> Unit)?) { + withBGApi { + planAndConnect( + chatModel.remoteHostId(), + URI.create(link), + incognito = null, + filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id }, + close = close, + cleanup = cleanup, + ) + } +} + +private fun filteredContactChats( + showUnreadAndFavorites: Boolean, + searchShowingSimplexLink: State, + searchChatFilteredBySimplexLink: State, + searchText: String, + contactChats: List +): List { + val linkChatId = searchChatFilteredBySimplexLink.value + val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() + + return if (linkChatId != null) { + contactChats.filter { it.id == linkChatId } + } else { + contactChats.filter { chat -> + filterChat( + chat = chat, + searchText = s, + showUnreadAndFavorites = showUnreadAndFavorites ) } } + .sortedWith(chatsByTypeComparator) +} + +private fun filterChat(chat: Chat, searchText: String, showUnreadAndFavorites: Boolean): Boolean { + var meetsPredicate = true; + val s = searchText.trim().lowercase() + val cInfo = chat.chatInfo + + if (searchText.isNotEmpty()) { + meetsPredicate = viewNameContains(cInfo, s) || + if (cInfo is ChatInfo.Direct) (cInfo.contact.profile.displayName.lowercase().contains(s) || + cInfo.contact.fullName.lowercase().contains(s)) else false + } + + if (showUnreadAndFavorites) { + meetsPredicate = meetsPredicate && (cInfo.chatSettings?.favorite ?: false) + } + + return meetsPredicate; +} + +private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = + cInfo.chatViewName.lowercase().contains(s.lowercase()) + +private val chatsByTypeComparator = Comparator { chat1, chat2 -> + val chat1Type = chatContactType(chat1) + val chat2Type = chatContactType(chat2) + + when { + chat1Type.ordinal < chat2Type.ordinal -> -1 + chat1Type.ordinal > chat2Type.ordinal -> 1 + + else -> chat2.chatInfo.chatTs.compareTo(chat1.chatInfo.chatTs) + } +} + +private fun contactTypesSearchTargets(baseContactTypes: List, searchEmpty: Boolean): List { + return if (baseContactTypes.contains(ContactType.CHAT_DELETED) || searchEmpty) { + baseContactTypes + } else { + baseContactTypes + ContactType.CHAT_DELETED + } +} + +@Composable +private fun DeletedContactsView(rh: RemoteHostInfo?, close: () -> Unit) { + val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI } + + var modifier = Modifier.fillMaxSize() + + if (oneHandUI.state.value) { + modifier = modifier.scale(scaleX = 1f, scaleY = -1f) + } + + Column( + modifier, + ) { + if (!oneHandUI.state.value) { + Box(contentAlignment = Alignment.Center) { + val bottomPadding = DEFAULT_PADDING + AppBarTitle( + stringResource(MR.strings.deleted_chats), + hostDevice(rh?.remoteHostId), + bottomPadding = bottomPadding + ) + } + } + + val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val searchShowingSimplexLink = remember { mutableStateOf(false) } + val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } + val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value + val contactTypes = listOf(ContactType.CHAT_DELETED) + val allChats by remember(chatModel.chats.value, contactTypes) { + derivedStateOf { filterContactTypes(chatModel.chats.value, contactTypes) } + } + val filteredContactChats = filteredContactChats( + showUnreadAndFavorites = showUnreadAndFavorites, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + searchShowingSimplexLink = searchShowingSimplexLink, + searchText = searchText.value.text, + contactChats = allChats + ) + + LazyColumnWithScrollBar( + Modifier.fillMaxWidth(), + listState + ) { + item { + Divider() + ContactsSearchBar( + listState = listState, + searchText = searchText, + searchShowingSimplexLink = searchShowingSimplexLink, + searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink, + close = close, + oneHandUI = oneHandUI + ) + Divider() + + Spacer(Modifier.padding(bottom = DEFAULT_PADDING)) + } + + itemsIndexed(filteredContactChats) { index, chat -> + val nextChatSelected = remember(chat.id, filteredContactChats) { + derivedStateOf { + chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value + } + } + ContactListNavLinkView(chat, nextChatSelected, oneHandUI.state) + } + } + if (filteredContactChats.isEmpty() && allChats.isNotEmpty()) { + Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING)) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + generalGetString(MR.strings.no_filtered_contacts), + color = MaterialTheme.colors.secondary, + modifier = if (oneHandUI.state.value) Modifier.scale(scaleX = 1f, scaleY = -1f) else Modifier + ) + } + } + } + } } @Composable @@ -267,13 +687,6 @@ fun ActionButton( @Composable private fun PreviewNewChatSheet() { SimpleXTheme { - NewChatSheetLayout( - MutableStateFlow(AnimatedViewState.VISIBLE), - stopped = false, - addContact = {}, - scanPaste = {}, - createGroup = {}, - closeNewChatSheet = {}, - ) + NewChatSheet(rh = null, close = {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index a877e123b3..77e5662b21 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -25,6 +25,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -62,7 +63,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC * It will be dropped automatically when connection established or when user goes away from this screen. * It applies only to Android because on Desktop center space will not be overlapped by [AddContactLearnMore] **/ - if (chatModel.showingInvitation.value != null && (!ModalManager.center.hasModalsOpen() || appPlatform.isDesktop)) { + if (chatModel.showingInvitation.value != null && (ModalManager.start.openModalCount() == 1 || appPlatform.isDesktop)) { val conn = contactConnection.value if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) { AlertManager.shared.showAlertDialog( @@ -218,8 +219,10 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection withBGApi { val contactConn = contactConnection.value ?: return@withBGApi val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi - contactConnection.value = conn - chatModel.updateContactConnection(rhId, conn) + withChats { + contactConnection.value = conn + updateContactConnection(rhId, conn) + } } chatModel.markShowingInvitationUsed() } @@ -234,7 +237,8 @@ private fun AddContactLearnMoreButton() { ModalManager.end.showModalCloseable { close -> AddContactLearnMore(close) } - } + }, + Modifier.size(18.dp * fontSizeSqrtMultiplier) ) { Icon( painterResource(MR.images.ic_info), @@ -367,9 +371,11 @@ private fun createInvitation( withBGApi { val (r, alert) = controller.apiAddContact(rhId, incognito = controller.appPrefs.incognito.get()) if (r != null) { - chatModel.updateContactConnection(rhId, r.second) - chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false) - contactConnection.value = r.second + withChats { + updateContactConnection(rhId, r.second) + chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false) + contactConnection.value = r.second + } } else { creatingConnReq.value = false if (alert != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index cd0e40f5d0..96a0bdcda3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.res.MR @@ -33,7 +34,9 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { if (updated != null) { val (updatedProfile, updatedContacts) = updated m.updateCurrentUser(user.remoteHostId, updatedProfile, preferences) - updatedContacts.forEach { m.updateContact(user.remoteHostId, it) } + withChats { + updatedContacts.forEach { updateContact(user.remoteHostId, it) } + } currentPreferences = preferences } afterSave() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 88b14b6a66..578a505e0c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -32,6 +32,7 @@ import chat.simplex.common.views.isValidDisplayName import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.platform.* import kotlin.math.min import kotlin.math.roundToInt @@ -121,14 +122,16 @@ fun PrivacySettingsView( chatModel.currentUser.value = currentUser.copy(sendRcptsContacts = enable) if (clearOverrides) { // For loop here is to prevent ConcurrentModificationException that happens with forEach - for (i in 0 until chatModel.chats.size) { - val chat = chatModel.chats[i] - if (chat.chatInfo is ChatInfo.Direct) { - var contact = chat.chatInfo.contact - val sendRcpts = contact.chatSettings.sendRcpts - if (sendRcpts != null && sendRcpts != enable) { - contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) - chatModel.updateContact(currentUser.remoteHostId, contact) + withChats { + for (i in 0 until chats.size) { + val chat = chats[i] + if (chat.chatInfo is ChatInfo.Direct) { + var contact = chat.chatInfo.contact + val sendRcpts = contact.chatSettings.sendRcpts + if (sendRcpts != null && sendRcpts != enable) { + contact = contact.copy(chatSettings = contact.chatSettings.copy(sendRcpts = null)) + updateContact(currentUser.remoteHostId, contact) + } } } } @@ -143,15 +146,17 @@ fun PrivacySettingsView( chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) chatModel.currentUser.value = currentUser.copy(sendRcptsSmallGroups = enable) if (clearOverrides) { - // For loop here is to prevent ConcurrentModificationException that happens with forEach - for (i in 0 until chatModel.chats.size) { - val chat = chatModel.chats[i] - if (chat.chatInfo is ChatInfo.Group) { - var groupInfo = chat.chatInfo.groupInfo - val sendRcpts = groupInfo.chatSettings.sendRcpts - if (sendRcpts != null && sendRcpts != enable) { - groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) - chatModel.updateGroup(currentUser.remoteHostId, groupInfo) + withChats { + // For loop here is to prevent ConcurrentModificationException that happens with forEach + for (i in 0 until chats.size) { + val chat = chats[i] + if (chat.chatInfo is ChatInfo.Group) { + var groupInfo = chat.chatInfo.groupInfo + val sendRcpts = groupInfo.chatSettings.sendRcpts + if (sendRcpts != null && sendRcpts != enable) { + groupInfo = groupInfo.copy(chatSettings = groupInfo.chatSettings.copy(sendRcpts = null)) + updateGroup(currentUser.remoteHostId, groupInfo) + } } } } @@ -164,7 +169,7 @@ fun PrivacySettingsView( DeliveryReceiptsSection( currentUser = currentUser, setOrAskSendReceiptsContacts = { enable -> - val contactReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + val contactReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> if (chat.chatInfo is ChatInfo.Direct) { val sendRcpts = chat.chatInfo.contact.chatSettings.sendRcpts count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) @@ -179,7 +184,7 @@ fun PrivacySettingsView( } }, setOrAskSendReceiptsGroups = { enable -> - val groupReceiptsOverrides = chatModel.chats.fold(0) { count, chat -> + val groupReceiptsOverrides = chatModel.chats.value.fold(0) { count, chat -> if (chat.chatInfo is ChatInfo.Group) { val sendRcpts = chat.chatInfo.groupInfo.chatSettings.sendRcpts count + (if (sendRcpts == null || sendRcpts == enable) 0 else 1) 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 0564d87cd3..9812e7b10a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -351,6 +351,7 @@ Welcome! This text is available in settings Chats + Settings connecting… send direct message you are invited to group @@ -442,10 +443,25 @@ Notifications + connect + open + message + call + search + video Delete contact? Contact and all messages will be deleted - this cannot be undone! + Contact will be deleted - this cannot be undone! + Keep conversation + Only delete conversation + Confirm contact deletion? Delete and notify contact + Delete without notification Delete contact + Conversation deleted! + You can still send messages to %1$s from the Deleted chats. + Contact deleted! + You can still view conversation with %1$s in the list of chats. Set contact name… Connected Disconnected @@ -633,6 +649,7 @@ New chat Add contact Scan / Paste link + Paste link One-time invitation link 1-time link SimpleX address @@ -651,6 +668,10 @@ Invalid QR code The code you scanned is not a SimpleX link QR code. + Deleted chats + No filtered contacts + Your contacts + Scan code Incorrect security code! @@ -1123,6 +1144,7 @@ Developer tools Experimental features SOCKS PROXY + INTERFACE LANGUAGE APP ICON THEMES @@ -1258,6 +1280,7 @@ Database downgrade Incompatible database version Confirm database upgrades + One-hand UI Show console in new window Show chat list in new window Invalid migration confirmation @@ -1450,6 +1473,7 @@ disabled Receipts are disabled This group has over %1$d members, delivery receipts are not sent. + Invite FOR CONSOLE @@ -1527,6 +1551,17 @@ none server queue info: %1$s\n\nlast received msg: %2$s + Can\'t call contact + Connecting to contact, please wait or check later! + Contact is deleted. + Allow calls? + You need to allow your contact to call to be able to call them. + Calls prohibited! + Please ask your contact to enable calls. + Can\'t call group member + Send message to enable calls. + Can\'t message group member + Welcome message Save welcome message? diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt index 1caf96902d..91e9c70a20 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/ScrollableColumn.desktop.kt @@ -52,9 +52,11 @@ actual fun LazyColumnWithScrollBar( } } } - LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { - DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout) + Box { + LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) { + DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout) + } } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 189f1842dd..57069a8caa 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -35,6 +35,7 @@ actual fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + oneHandUI: State, ) { var modifier = Modifier.fillMaxWidth() if (!disabled) modifier = modifier diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index c2333393e5..9e4eeb0c96 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -22,53 +22,67 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.MutableStateFlow @Composable -actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) { - // if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { - if (!newChatSheetState.collectAsState().value.isVisible()) { - val showMenu = remember { mutableStateOf(false) } - val media = call.peerMedia ?: call.localMedia - CompositionLocalProvider( - LocalIndication provides NoIndication - ) { - Box( - Modifier - .fillMaxSize(), - contentAlignment = Alignment.BottomEnd - ) { - Box( - Modifier - .padding(end = 71.dp, bottom = 92.dp) - .size(67.dp) - .combinedClickable(onClick = { - val chat = chatModel.getChat(call.contact.id) - if (chat != null) { - withBGApi { - openChat(chat.remoteHostId, chat.chatInfo, chatModel) - } - } - }, - onLongClick = { showMenu.value = true }) - .onRightClick { showMenu.value = true }, - contentAlignment = Alignment.Center - ) { - Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { - ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) - } - Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { - if (media == CallMediaType.Video) { - Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) - } else { - Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) +actual fun ActiveCallInteractiveArea(call: Call) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withBGApi { + openChat(chat.remoteHostId, chat.chatInfo, chatModel) } } - DefaultDropdownMenu(showMenu) { - ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { - withBGApi { chatModel.callManager.endCall(call) } - showMenu.value = false - }) - } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box( + Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp) + .align(Alignment.TopEnd) + ) { + if (media == CallMediaType.Video) { + Icon( + painterResource(MR.images.ic_videocam_filled), + stringResource(MR.strings.icon_descr_video_call), + Modifier.size(18.dp), + tint = Color.White + ) + } else { + Icon( + painterResource(MR.images.ic_call_filled), + stringResource(MR.strings.icon_descr_audio_call), + Modifier.size(18.dp), + tint = Color.White + ) } } + DefaultDropdownMenu(showMenu) { + ItemAction( + stringResource(MR.strings.icon_descr_hang_up), + painterResource(MR.images.ic_call_end_filled), + color = MaterialTheme.colors.error, + onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } } } + } } diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index d6ee659dbf..8e1643571b 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.size import chat.simplex.common.platform.* import chat.simplex.common.platform.DesktopPlatform import chat.simplex.common.showApp diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index 3bbdd65b49..5084a0276d 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -40,6 +40,10 @@ if [ ! -f ../appimagetool-x86_64.AppImage ]; then wget --secure-protocol=TLSv1_3 https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O ../appimagetool-x86_64.AppImage chmod +x ../appimagetool-x86_64.AppImage fi -../appimagetool-x86_64.AppImage . +if [ ! -f ../runtime-fuse3-x86_64 ]; then + wget --secure-protocol=TLSv1_3 https://github.com/AppImage/type2-runtime/releases/download/old/runtime-fuse3-x86_64 -O ../runtime-fuse3-x86_64 + chmod +x ../runtime-fuse3-x86_64 +fi +../appimagetool-x86_64.AppImage --runtime-file ../runtime-fuse3-x86_64 . mv *imple*.AppImage ../../ diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b1bd1b8dd2..6dbd9124c5 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1745,7 +1745,7 @@ markDirectChatItemDeleted db User {userId} Contact {contactId} ci@ChatItem {meta WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] (DBCIDeleted, deletedTs, currentTs, userId, contactId, itemId) - pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs}} + pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs, editable = False, deletable = False}} getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do @@ -1945,7 +1945,7 @@ markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) - pure ci {meta = meta {itemDeleted}} + pure ci {meta = meta {itemDeleted, editable = False, deletable = False}} markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do @@ -1958,7 +1958,7 @@ markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlocked, deletedTs, deletedTs, userId, groupId, chatItemId' ci) - pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs}} + pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs, editable = False, deletable = False}} markGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do @@ -1971,7 +1971,7 @@ markGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci@ChatItem {meta WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, deletedTs, userId, groupId, chatItemId' ci) - pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs}} + pure ci {meta = meta {itemDeleted = Just $ CIBlockedByAdmin $ Just deletedTs, editable = False, deletable = False}} getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do diff --git a/website/langs/cs.json b/website/langs/cs.json index 7e3e9530b9..8429b5d8ad 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -248,7 +248,7 @@ "signing-key-fingerprint": "Otisk podpisového klíče (SHA-256)", "f-droid-org-repo": "F-Droid.org repozitář", "stable-versions-built-by-f-droid-org": "Stabilní verze vytvořené F-Droid.org", - "releases-to-this-repo-are-done-1-2-days-later": "Vydání v tomto repozitáři se provádí o 1-2 dny později", + "releases-to-this-repo-are-done-1-2-days-later": "Vydání v tomto repozitáři se provádí o několik dní později", "jobs": "Připojit k týmu", "hero-overlay-card-3-p-2": "Trail of Bits přezkoumala kryptografii a síťové komponenty SimpleX platformy v listopadu 2022.", "hero-overlay-card-3-p-3": "Přečtěte si více v ohlášení.",