diff --git a/.gitignore b/.gitignore index e3ea5d267b..645b55ec9d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,7 @@ website/package/generated* # Ignore build tool output, e.g. code coverage website/.nyc_output/ website/coverage/ +result # Ignore API documentation website/api-docs/ diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index dd91d4af68..5845793aa7 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -15,16 +15,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { logger.debug("AppDelegate: didFinishLaunchingWithOptions") application.registerForRemoteNotifications() - NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil) removePasscodesIfReinstalled() prepareForLaunch() return true } - @objc func pasteboardChanged() { - ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings - } - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.map { String(format: "%02hhx", $0) }.joined() logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)") diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json new file mode 100644 index 0000000000..8e38b499dd --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "symbols": [ + { + "filename": "checkmark.2.svg", + "idiom": "universal" + } + ] +} \ No newline at end of file diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg new file mode 100644 index 0000000000..577fa1db76 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/checkmark.2.symbolset/checkmark.2.svg @@ -0,0 +1,227 @@ + + + checkmark.2 + + + + + + + Weight/Scale Variations + + + Ultralight + + + Thin + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Heavy + + + Black + + + + + + + + + + + + + Design Variations + + + Symbols are supported in up to nine weights and three scales. + + + For optimal layout with text and other symbols, vertically align + + + symbols with the adjacent text. + + + + + + + + + Margins + + + Leading and trailing margins on the left and right side of each symbol + + + + can be adjusted by modifying the x-location of the margin guidelines. + + + + Modifications are automatically applied proportionally to all + + + scales and weights. + + + + + + Exporting + + + Symbols should be outlined when exporting to ensure the + + + design is preserved when submitting to Xcode. + + + Template v.5.0 + + + Requires Xcode 15 or greater + + + Generated from double.checkmark + + + Typeset at 100.0 points + + + Small + + + Medium + + + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json new file mode 100644 index 0000000000..11a91cb811 --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "symbols": [ + { + "filename": "checkmark.wide.svg", + "idiom": "universal" + } + ] +} \ No newline at end of file diff --git a/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg new file mode 100644 index 0000000000..b5dfc6b3de --- /dev/null +++ b/apps/ios/Shared/Assets.xcassets/checkmark.wide.symbolset/checkmark.wide.svg @@ -0,0 +1,218 @@ + + + checkmark.wide + + + + + + + Weight/Scale Variations + + + Ultralight + + + Thin + + + Light + + + Regular + + + Medium + + + Semibold + + + Bold + + + Heavy + + + Black + + + + + + + + + + + + + Design Variations + + + Symbols are supported in up to nine weights and three scales. + + + For optimal layout with text and other symbols, vertically align + + + symbols with the adjacent text. + + + + + + + + + Margins + + + Leading and trailing margins on the left and right side of each symbol + + + + can be adjusted by modifying the x-location of the margin guidelines. + + + + Modifications are automatically applied proportionally to all + + + scales and weights. + + + + + + Exporting + + + Symbols should be outlined when exporting to ensure the + + + design is preserved when submitting to Xcode. + + + Template v.5.0 + + + Requires Xcode 15 or greater + + + Generated from double.checkmark + + + Typeset at 100.0 points + + + Small + + + Medium + + + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2be3191f4f..c03a0083ec 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -61,7 +61,7 @@ class ItemsModel: ObservableObject { init() { publisher - .throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true) + .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .sink { self.objectWillChange.send() } .store(in: &bag) } @@ -191,7 +191,6 @@ final class ChatModel: ObservableObject { @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? @Published var draftChatId: String? - @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings @Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) var messageDelivery: Dictionary Void> = [:] @@ -563,6 +562,7 @@ final class ChatModel: ObservableObject { // update preview _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) + self.updateFloatingButtons(unreadCount: 0) chat.chatStats = ChatStats() } // update current chat @@ -579,6 +579,12 @@ final class ChatModel: ObservableObject { } } + private func updateFloatingButtons(unreadCount: Int) { + let fbm = ChatView.FloatingButtonModel.shared + fbm.totalUnread = unreadCount + fbm.objectWillChange.send() + } + func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { if let cItem = aboveItem { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { @@ -597,6 +603,7 @@ final class ChatModel: ObservableObject { if markedCount > 0 { chat.chatStats.unreadCount -= markedCount self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) + self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } } } @@ -626,19 +633,15 @@ final class ChatModel: ObservableObject { } } - func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { - if chatId == cInfo.id, - let itemIndex = getChatItemIndex(cItem), - im.reversedChatItems[itemIndex].isRcvNew { - await MainActor.run { - withTransaction(Transaction()) { - // update current chat - markChatItemRead_(itemIndex) - // update preview - unreadCollector.changeUnreadCounter(cInfo.id, by: -1) + func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) { + if self.chatId == cInfo.id { + for itemId in itemIds { + if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) { + markChatItemRead_(i) } } } + self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count) } private let unreadCollector = UnreadCollector() @@ -664,9 +667,10 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatId: ChatId, by count: Int) { - DispatchQueue.main.async { - self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count + if chatId == ChatModel.shared.chatId { + ChatView.FloatingButtonModel.shared.totalUnread += count } + self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count subject.send() } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 020b40f73e..7312b42b1b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1000,6 +1000,10 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange)) } +func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws { + try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds)) +} + func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) } @@ -1287,11 +1291,23 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { - logger.debug("apiMarkChatItemRead: \(cItem.id)") try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) - await ChatModel.shared.markChatItemRead(cInfo, cItem) + DispatchQueue.main.async { + ChatModel.shared.markChatItemsRead(cInfo, [cItem.id]) + } } catch { - logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") + logger.error("apiChatRead error: \(responseError(error))") + } +} + +func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async { + do { + try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) + DispatchQueue.main.async { + ChatModel.shared.markChatItemsRead(cInfo, itemIds) + } + } catch { + logger.error("apiChatItemsRead error: \(responseError(error))") } } diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index e2641eb8dd..53f2931d16 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -102,7 +102,7 @@ extension ThemeWallpaper { public func importFromString() -> ThemeWallpaper { if preset == nil, let image { // Need to save image from string and to save its path - if let parsed = UIImage(base64Encoded: image), + if let parsed = imageFromBase64(image), let filename = saveWallpaperFile(image: parsed) { var copy = self copy.image = nil diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 4960281d72..5479a9fada 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -38,6 +38,7 @@ struct IncomingCallView: View { } HStack { ProfilePreview(profileOf: invitation.contact, color: .white) + .padding(.vertical, 6) Spacer() callButton("Reject", "phone.down.fill", .red) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index da859c1606..1a77b36d6f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -46,7 +46,7 @@ struct CIGroupInvitationView: View { .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) .overlay(DetermineWidth()) } @@ -54,7 +54,7 @@ struct CIGroupInvitationView: View { ( groupInvitationText() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) .overlay(DetermineWidth()) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 3966d7e258..b06c6df48c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -165,9 +165,9 @@ struct CIImageView: View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { Image(systemName: icon) .resizable() + .invertedForegroundStyle() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) - .foregroundColor(.white) .padding(padding) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 3c864ab172..692e6bb8a6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -16,7 +16,7 @@ struct CILinkView: View { var body: some View { VStack(alignment: .center, spacing: 6) { - if let uiImage = UIImage(base64Encoded: linkPreview.image) { + if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() .scaledToFit() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 3f2ec8f38e..9840b22fc8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -18,6 +18,7 @@ struct CIMetaView: View { var paleMetaColor = Color(UIColor.tertiaryLabel) var showStatus = true var showEdited = true + var invertedMaterial = false @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false @@ -25,96 +26,138 @@ struct CIMetaView: View { if chatItem.isDeletedContent { chatItem.timestampText.font(.caption).foregroundColor(metaColor) } else { - let meta = chatItem.meta - let ttl = chat.chatInfo.timedMessagesTTL - let encrypted = chatItem.encryptedFile - switch meta.itemStatus { - case let .sndSent(sndProgress): - switch sndProgress { - case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) - case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ZStack { + ciMetaText( + chatItem.meta, + chatTTL: chat.chatInfo.timedMessagesTTL, + encrypted: chatItem.encryptedFile, + color: metaColor, + paleColor: paleMetaColor, + colorMode: invertedMaterial + ? .invertedMaterial + : .normal, + showStatus: showStatus, + showEdited: showEdited, + showViaProxy: showSentViaProxy, + showTimesamp: showTimestamp + ).invertedForegroundStyle(enabled: invertedMaterial) + if invertedMaterial { + ciMetaText( + chatItem.meta, + chatTTL: chat.chatInfo.timedMessagesTTL, + encrypted: chatItem.encryptedFile, + colorMode: .normal, + onlyOverrides: true, + showStatus: showStatus, + showEdited: showEdited, + showViaProxy: showSentViaProxy, + showTimesamp: showTimestamp + ) } - case let .sndRcvd(_, sndProgress): - switch sndProgress { - case .complete: - ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) - } - case .partial: - ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) - } - } - default: - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } } } -enum SentCheckmark { - case sent - case rcvd1 - case rcvd2 +enum MetaColorMode { + // Renders provided colours + case normal + // Fully transparent meta - used for reserving space + case transparent + // Renders white on dark backgrounds and black on light ones + case invertedMaterial + + func resolve(_ c: Color?) -> Color? { + switch self { + case .normal: c + case .transparent: .clear + case .invertedMaterial: nil + } + } + + var statusSpacer: Text { + switch self { + case .normal, .transparent: Text(Image(systemName: "circlebadge.fill")).foregroundColor(.clear) + case .invertedMaterial: Text(" ").kerning(13) + } + } } func ciMetaText( _ meta: CIMeta, chatTTL: Int?, encrypted: Bool?, - color: Color = .clear, + color: Color = .clear, // we use this function to reserve space without rendering meta + paleColor: Color? = nil, primaryColor: Color = .accentColor, - transparent: Bool = false, - sent: SentCheckmark? = nil, + colorMode: MetaColorMode = .normal, + onlyOverrides: Bool = false, // only render colors that differ from base showStatus: Bool = true, showEdited: Bool = true, showViaProxy: Bool, showTimesamp: Bool ) -> Text { var r = Text("") + var space: Text? = nil + let appendSpace = { + if let sp = space { + r = r + sp + space = nil + } + } + let resolved = colorMode.resolve(color) if showEdited, meta.itemEdited { - r = r + statusIconText("pencil", color) + r = r + statusIconText("pencil", resolved) } if meta.disappearing { - r = r + statusIconText("timer", color).font(.caption2) + r = r + statusIconText("timer", resolved).font(.caption2) let ttl = meta.itemTimed?.ttl if ttl != chatTTL { - r = r + Text(shortTimeText(ttl)).foregroundColor(color) + r = r + colored(Text(shortTimeText(ttl)), resolved) } - r = r + Text(" ") + space = Text(" ") } if showViaProxy, meta.sentViaProxy == true { - r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2) + appendSpace() + r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2) } if showStatus { - if let (icon, statusColor) = meta.statusIcon(color, primaryColor) { - let t = Text(Image(systemName: icon)).font(.caption2) - let gap = Text(" ").kerning(-1.25) - let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) - switch sent { - case nil: r = r + t1 - case .sent: r = r + t1 + gap - case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap - case .rcvd2: r = r + gap + t1 + appendSpace() + if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) { + let metaColor = if onlyOverrides && statusColor == color { + Color.clear + } else { + colorMode.resolve(statusColor) } - r = r + Text(" ") + r = r + colored(Text(image), metaColor) + space = Text(" ") } else if !meta.disappearing { - r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") + space = colorMode.statusSpacer + Text(" ") } } if let enc = encrypted { - r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") + appendSpace() + r = r + statusIconText(enc ? "lock" : "lock.open", resolved) + space = Text(" ") } if showTimesamp { - r = r + meta.timestampText.foregroundColor(color) + appendSpace() + r = r + colored(meta.timestampText, resolved) } return r.font(.caption) } -private func statusIconText(_ icon: String, _ color: Color) -> Text { - Text(Image(systemName: icon)).foregroundColor(color) +private func statusIconText(_ icon: String, _ color: Color?) -> Text { + colored(Text(Image(systemName: icon)), color) +} + +// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier +private func colored(_ t: Text, _ color: Color?) -> Text { + if let color { + t.foregroundColor(color) + } else { + t + } } struct CIMetaView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 9f721f83b7..c76ffe8c05 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -126,7 +126,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) } .padding(.horizontal, 12) @@ -145,7 +145,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(.red) .italic() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } .padding(.horizontal, 12) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 4670fc685f..851b90bc3d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -292,30 +292,22 @@ struct CIVideoView: View { .clipShape(Circle()) } - private func durationProgress() -> some View { - HStack { - Text("\(durationText(videoPlaying ? progress : duration))") - .foregroundColor(.white) - .font(.caption) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color.black.opacity(0.35)) - .cornerRadius(10) - .padding([.top, .leading], 6) - - if let file = chatItem.file, !videoPlaying { - Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))") - .foregroundColor(.white) - .font(.caption) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color.black.opacity(0.35)) - .cornerRadius(10) - .padding(.top, 6) - } + private var fileSizeString: String { + if let file = chatItem.file, !videoPlaying { + " " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary) + } else { + "" } } + private func durationProgress() -> some View { + Text((durationText(videoPlaying ? progress : duration)) + fileSizeString) + .invertedForegroundStyle() + .font(.caption) + .padding(.vertical, 6) + .padding(.horizontal, 12) + } + private func imageView(_ img: UIImage) -> some View { let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth return ZStack(alignment: .topTrailing) { @@ -411,9 +403,9 @@ struct CIVideoView: View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { Image(systemName: icon) .resizable() + .invertedForegroundStyle() .aspectRatio(contentMode: .fit) .frame(width: size, height: size) - .foregroundColor(.white) .padding(smallView ? 0 : padding) } @@ -428,10 +420,8 @@ struct CIVideoView: View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { Circle() .trim(from: 0, to: Double(progress) / Double(total)) - .stroke( - Color(uiColor: .white), - style: StrokeStyle(lineWidth: 2) - ) + .stroke(style: StrokeStyle(lineWidth: 2)) + .invertedForegroundStyle() .rotationEffect(.degrees(-90)) .frame(width: 16, height: 16) .padding([.trailing, .top], smallView ? 0 : 11) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 260ac64e43..5f2930951f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -64,12 +64,17 @@ struct FramedItemView: View { .overlay(DetermineWidth()) } - if chatItem.content.msgContent != nil { - CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary) - .padding(.horizontal, 12) - .padding(.bottom, 6) - .overlay(DetermineWidth()) - .accessibilityLabel("") + if let content = chatItem.content.msgContent { + CIMetaView( + chat: chat, + chatItem: chatItem, + metaColor: theme.colors.secondary, + invertedMaterial: useWhiteMetaColor + ) + .padding(.horizontal, 12) + .padding(.bottom, 6) + .overlay(DetermineWidth()) + .accessibilityLabel("") } } .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) } @@ -185,7 +190,7 @@ struct FramedItemView: View { let v = ZStack(alignment: .topTrailing) { switch (qi.content) { case let .image(_, image): - if let uiImage = UIImage(base64Encoded: image) { + if let uiImage = imageFromBase64(image) { ciQuotedMsgView(qi) .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) Image(uiImage: uiImage) @@ -197,7 +202,7 @@ struct FramedItemView: View { ciQuotedMsgView(qi) } case let .video(_, image, _): - if let uiImage = UIImage(base64Encoded: image) { + if let uiImage = imageFromBase64(image) { ciQuotedMsgView(qi) .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) Image(uiImage: uiImage) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 9cc4179723..63d5dc30dc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -85,7 +85,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index f6a856dad1..62ea607d27 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -450,20 +450,8 @@ struct ChatItemInfoView: View { .foregroundColor(theme.colors.secondary).opacity(0.67) } let v = Group { - let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) - switch status { - case .rcvd: - ZStack(alignment: .trailing) { - Image(systemName: icon) - .foregroundColor(statusColor.opacity(0.67)) - .padding(.trailing, 6) - Image(systemName: icon) - .foregroundColor(statusColor.opacity(0.67)) - } - default: - Image(systemName: icon) - .foregroundColor(statusColor) - } + let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) + image.foregroundColor(statusColor) } if let (title, text) = status.statusInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d444ce0735..bf09d15ff1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -72,7 +72,7 @@ struct ChatItemView: View { default: nil } } - .flatMap { UIImage(base64Encoded: $0) } + .flatMap { imageFromBase64($0) } let adjustedMaxWidth = { if let preview, preview.size.width <= preview.size.height { maxWidth * 0.75 diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a5ad7ce456..5c11cdc3df 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -23,7 +23,6 @@ struct ChatView: View { @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat @StateObject private var scrollModel = ReverseListScrollModel() - @StateObject private var floatingButtonModel: FloatingButtonModel = .shared @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -76,8 +75,7 @@ struct ChatView: View { VStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { chatItemsList() - // TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates - floatingButtons(unreadBelow: floatingButtonModel.unreadBelow, isNearBottom: floatingButtonModel.isNearBottom) + FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat) } connectingText() if selectedChatItems == nil { @@ -341,6 +339,7 @@ struct ChatView: View { await markChatUnread(chat, unreadChat: false) } } + ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount } private func searchToolbar() -> some View { @@ -427,7 +426,7 @@ struct ChatView: View { .onChange(of: im.itemAdded) { added in if added { im.itemAdded = false - if floatingButtonModel.isReallyNearBottom { + if FloatingButtonModel.shared.isReallyNearBottom { scrollModel.scrollToBottom() } } @@ -453,86 +452,162 @@ struct ChatView: View { static let shared = FloatingButtonModel() @Published var unreadBelow: Int = 0 @Published var isNearBottom: Bool = true - var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 } - let visibleItems = PassthroughSubject<[String], Never>() - let scrollOffset = CurrentValueSubject(0) - private var bag = Set() + @Published var date: Date? + @Published var isDateVisible: Bool = false + var totalUnread: Int = 0 + var isReallyNearBottom: Bool = true + var hideDateWorkItem: DispatchWorkItem? - init() { - visibleItems - .receive(on: DispatchQueue.global(qos: .background)) - .map { itemIds in - if let viewId = itemIds.first, - let index = ItemsModel.shared.reversedChatItems.firstIndex(where: { $0.viewId == viewId }) { - ItemsModel.shared.reversedChatItems[.. 0 && listState.scrollOffset < 500 + } + + // set floating button indication mode + let nearBottom = listState.scrollOffset < 800 + if nearBottom != self.isNearBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.isNearBottom = nearBottom + } + } + + // hide Date indicator after 1 second of no scrolling + hideDateWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let it = self else { return } + it.setDate(visibility: false) + it.hideDateWorkItem = nil + } + DispatchQueue.main.async { [weak self] in + self?.hideDateWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + } } + + func resetDate() { + date = nil + isDateVisible = false + } + + private func setDate(visibility isVisible: Bool) { + if isVisible { + if !isNearBottom, + !isDateVisible, + let date, !Calendar.current.isDateInToday(date) { + withAnimation { self.isDateVisible = true } + } + } else if isDateVisible { + withAnimation { self.isDateVisible = false } + } + } + } - private func floatingButtons(unreadBelow: Int, isNearBottom: Bool) -> some View { - VStack { - let unreadAbove = chat.chatStats.unreadCount - unreadBelow - if unreadAbove > 0 { - circleButton { - unreadCountText(unreadAbove) - .font(.callout) - .foregroundColor(theme.colors.primary) + private struct FloatingButtons: View { + let theme: AppTheme + let scrollModel: ReverseListScrollModel + let chat: Chat + @ObservedObject var model = FloatingButtonModel.shared + + var body: some View { + ZStack(alignment: .top) { + if let date = model.date { + DateSeparator(date: date) + .padding(.vertical, 4).padding(.horizontal, 8) + .background(.thinMaterial) + .clipShape(Capsule()) + .opacity(model.isDateVisible ? 1 : 0) } - .onTapGesture { - scrollModel.scrollToNextPage() - } - .contextMenu { - Button { - Task { - await markChatRead(chat) + VStack { + let unreadAbove = model.totalUnread - model.unreadBelow + if unreadAbove > 0 { + circleButton { + unreadCountText(unreadAbove) + .font(.callout) + .foregroundColor(theme.colors.primary) } - } label: { - Label("Mark read", systemImage: "checkmark") + .onTapGesture { + scrollModel.scrollToNextPage() + } + .contextMenu { + Button { + Task { + await markChatRead(chat) + } + } label: { + Label("Mark read", systemImage: "checkmark") + } + } + } + Spacer() + if model.unreadBelow > 0 { + circleButton { + unreadCountText(model.unreadBelow) + .font(.callout) + .foregroundColor(theme.colors.primary) + } + .onTapGesture { + scrollModel.scrollToBottom() + } + } else if !model.isNearBottom { + circleButton { + Image(systemName: "chevron.down") + .foregroundColor(theme.colors.primary) + } + .onTapGesture { scrollModel.scrollToBottom() } } } + .padding() + .frame(maxWidth: .infinity, alignment: .trailing) } - Spacer() - if unreadBelow > 0 { - circleButton { - unreadCountText(unreadBelow) - .font(.callout) - .foregroundColor(theme.colors.primary) - } - .onTapGesture { - scrollModel.scrollToBottom() - } - } else if !isNearBottom { - circleButton { - Image(systemName: "chevron.down") - .foregroundColor(theme.colors.primary) - } - .onTapGesture { scrollModel.scrollToBottom() } + .onDisappear(perform: model.resetDate) + } + + private func circleButton(_ content: @escaping () -> Content) -> some View { + ZStack { + Circle() + .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground)) + .frame(width: 44, height: 44) + content() } } - .padding() } - private func circleButton(_ content: @escaping () -> Content) -> some View { - ZStack { - Circle() - .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground)) - .frame(width: 44, height: 44) - content() + private struct DateSeparator: View { + let date: Date + + var body: some View { + Text(String.localizedStringWithFormat( + NSLocalizedString("%@, %@", comment: "format for date separator in chat"), + date.formatted(.dateTime.weekday(.abbreviated)), + date.formatted(.dateTime.day().month(.abbreviated)) + )) + .font(.callout) + .fontWeight(.medium) + .foregroundStyle(.secondary) } } @@ -693,6 +768,7 @@ struct ChatView: View { @Binding var selectedChatItems: Set? @State private var allowMenu: Bool = true + @State private var markedRead = false var revealed: Bool { chatItem == revealedChatItem } @@ -704,7 +780,7 @@ struct ChatView: View { let nextItem = im.reversedChatItems[i - 1] let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60 return ( - timestamp: largeGap || formatTimestampText(chatItem.meta.itemTs) != formatTimestampText(nextItem.meta.itemTs), + timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs), largeGap: largeGap, date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs ) @@ -743,15 +819,7 @@ struct ChatView: View { VStack(spacing: 0) { chatItemView(chatItem, range, prevItem, timeSeparation) if let date = timeSeparation.date { - Text(String.localizedStringWithFormat( - NSLocalizedString("%@, %@", comment: "format for date separator in chat"), - date.formatted(.dateTime.weekday(.abbreviated)), - date.formatted(.dateTime.day().month(.abbreviated)) - )) - .font(.callout) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .padding(8) + DateSeparator(date: date).padding(8) } } .overlay { @@ -767,12 +835,16 @@ struct ChatView: View { } } .onAppear { + if markedRead { + return + } else { + markedRead = true + } if let range { - if let items = unreadItems(range) { + let itemIds = unreadItemIds(range) + if !itemIds.isEmpty { waitToMarkRead { - for ci in items { - await apiMarkChatItemRead(chat.chatInfo, ci) - } + await apiMarkChatItemsRead(chat.chatInfo, itemIds) } } } else if chatItem.isRcvNew { @@ -782,24 +854,24 @@ struct ChatView: View { } } } - - private func unreadItems(_ range: ClosedRange) -> [ChatItem]? { + + private func unreadItemIds(_ range: ClosedRange) -> [ChatItem.ID] { let im = ItemsModel.shared - let items = range.compactMap { i in + return range.compactMap { i in if i >= 0 && i < im.reversedChatItems.count { let ci = im.reversedChatItems[i] - return if ci.isRcvNew { ci } else { nil } + return if ci.isRcvNew { ci.id } else { nil } } else { return nil } } - return if items.isEmpty { nil } else { items } } private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + Task { + _ = try? await Task.sleep(nanoseconds: 600_000000) if m.chatId == chat.chatInfo.id { - Task(operation: op) + await op() } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift index df3a8caf55..14026d79d1 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeImageView.swift @@ -18,7 +18,7 @@ struct ComposeImageView: View { var body: some View { HStack(alignment: .center, spacing: 8) { let imgs: [UIImage] = images.compactMap { image in - UIImage(base64Encoded: image) + imageFromBase64(image) } if imgs.count == 0 { ProgressView() diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index f7f1a89299..6c44aeea83 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -40,7 +40,7 @@ struct ComposeLinkView: View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { HStack(alignment: .center, spacing: 8) { - if let uiImage = UIImage(base64Encoded: linkPreview.image) { + if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) diff --git a/apps/ios/Shared/Views/Chat/ReverseList.swift b/apps/ios/Shared/Views/Chat/ReverseList.swift index f64189f95f..2e09909c5e 100644 --- a/apps/ios/Shared/Views/Chat/ReverseList.swift +++ b/apps/ios/Shared/Views/Chat/ReverseList.swift @@ -107,9 +107,14 @@ struct ReverseList: UIViewControllerRepresentable { name: notificationName, object: nil ) + updateFloatingButtons - .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) - .sink { self.updateVisibleItems() } + .throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true) + .sink { + if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) { + ChatView.FloatingButtonModel.shared.updateOnListChange(listState) + } + } .store(in: &bag) } @@ -203,25 +208,35 @@ struct ReverseList: UIViewControllerRepresentable { updateFloatingButtons.send() } - private func updateVisibleItems() { - let fbm = ChatView.FloatingButtonModel.shared - fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset) - fbm.visibleItems.send( - (tableView.indexPathsForVisibleRows ?? []) - .compactMap { indexPath -> String? in - guard let relativeFrame = tableView.superview?.convert( - tableView.rectForRow(at: indexPath), - from: tableView - ) else { return nil } - // Checks that the cell is visible accounting for the added insets - let isVisible = - relativeFrame.maxY > InvertedTableView.inset && - relativeFrame.minY < tableView.frame.height - InvertedTableView.inset - return indexPath.item < representer.items.count && isVisible - ? representer.items[indexPath.item].viewId - : nil + func getListState() -> ListState? { + if let visibleRows = tableView.indexPathsForVisibleRows, + visibleRows.last?.item ?? 0 < representer.items.count { + let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset + let topItemDate: Date? = + if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) { + representer.items[lastVisible.item].meta.itemTs + } else { + nil } - ) + let bottomItemId: ChatItem.ID? = + if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) { + representer.items[firstVisible.item].id + } else { + nil + } + return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId) + } + return nil + } + + private func isVisible(indexPath: IndexPath) -> Bool { + if let relativeFrame = tableView.superview?.convert( + tableView.rectForRow(at: indexPath), + from: tableView + ) { + relativeFrame.maxY > InvertedTableView.inset && + relativeFrame.minY < tableView.frame.height - InvertedTableView.inset + } else { false } } } @@ -265,6 +280,12 @@ struct ReverseList: UIViewControllerRepresentable { } } +typealias ListState = ( + scrollOffset: Double, + topItemDate: Date?, + bottomItemId: ChatItem.ID? +) + /// Manages ``ReverseList`` scrolling class ReverseListScrollModel: ObservableObject { /// Represents Scroll State of ``ReverseList`` diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 8ad03236f1..156b8694c4 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -9,6 +9,17 @@ import SwiftUI import SimpleXChat +enum UserPickerSheet: Identifiable { + case address + case chatPreferences + case chatProfiles + case currentProfile + case useFromDesktop + case settings + + var id: Self { self } +} + struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -18,9 +29,9 @@ struct ChatListView: View { @State private var searchText = "" @State private var searchShowingSimplexLink = false @State private var searchChatFilteredBySimplexLink: String? = nil - @State private var userPickerVisible = false - @State private var showConnectDesktop = false @State private var scrollToSearchBar = false + @State private var activeUserPickerSheet: UserPickerSheet? = nil + @State private var userPickerShown: Bool = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @@ -46,21 +57,44 @@ struct ChatListView: View { ), destination: chatView ) { chatListView } - if userPickerVisible { - Rectangle().fill(.white.opacity(0.001)).onTapGesture { - withAnimation { - userPickerVisible.toggle() + } + .sheet(isPresented: $userPickerShown) { + UserPicker(activeSheet: $activeUserPickerSheet) + .sheet(item: $activeUserPickerSheet) { sheet in + if let currentUser = chatModel.currentUser { + switch sheet { + case .address: + NavigationView { + UserAddressView(shareViaProfile: currentUser.addressShared) + .navigationTitle("Public address") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + case .chatProfiles: + NavigationView { + UserProfilesView() + } + case .currentProfile: + NavigationView { + UserProfile() + .navigationTitle("Your current profile") + .modifier(ThemedBackground()) + } + case .chatPreferences: + NavigationView { + PreferencesView(profile: currentUser.profile, preferences: currentUser.fullPreferences, currentPreferences: currentUser.fullPreferences) + .navigationTitle("Your preferences") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } + case .useFromDesktop: + ConnectDesktopView(viaSettings: false) + case .settings: + SettingsView(showSettings: $showSettings) + .navigationBarTitleDisplayMode(.large) + } } } - } - UserPicker( - showSettings: $showSettings, - showConnectDesktop: $showConnectDesktop, - userPickerVisible: $userPickerVisible - ) - } - .sheet(isPresented: $showConnectDesktop) { - ConnectDesktopView() } } @@ -73,7 +107,7 @@ struct ChatListView: View { .navigationBarHidden(searchMode || oneHandUI) } .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .onDisappear() { withAnimation { userPickerVisible = false } } + .onDisappear() { activeUserPickerSheet = nil } .refreshable { AlertManager.shared.showAlert(Alert( title: Text("Reconnect servers?"), @@ -164,7 +198,7 @@ struct ChatListView: View { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel)) - .padding(.trailing, 4) + .padding([.top, .trailing], 3) let allRead = chatModel.users .filter { u in !u.user.activeUser && !u.user.hidden } .allSatisfy { u in u.unreadCount == 0 } @@ -173,13 +207,7 @@ struct ChatListView: View { } } .onTapGesture { - if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { - withAnimation { - userPickerVisible.toggle() - } - } else { - showSettings = true - } + userPickerShown = true } } @@ -269,7 +297,7 @@ struct ChatListView: View { } } - private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { + private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) .foregroundColor(theme.colors.primary) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 43892ec469..cf9977860d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -302,7 +302,7 @@ struct ChatPreviewView: View { case let .link(_, preview): smallContentPreview(size: dynamicMediaSize) { ZStack(alignment: .topTrailing) { - Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!) + Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!) .resizable() .aspectRatio(contentMode: .fill) .frame(width: dynamicMediaSize, height: dynamicMediaSize) @@ -323,12 +323,12 @@ struct ChatPreviewView: View { } case let .image(_, image): smallContentPreview(size: dynamicMediaSize) { - CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) + CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) .environmentObject(ReverseListScrollModel()) } case let .video(_,image, duration): smallContentPreview(size: dynamicMediaSize) { - CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) + CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) .environmentObject(ReverseListScrollModel()) } case let .voice(_, duration): diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 5041e093db..9d7f6bbd9c 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -8,179 +8,228 @@ import SimpleXChat struct UserPicker: View { @EnvironmentObject var m: ChatModel - @Environment(\.scenePhase) var scenePhase @EnvironmentObject var theme: AppTheme - @Binding var showSettings: Bool - @Binding var showConnectDesktop: Bool - @Binding var userPickerVisible: Bool - @State var scrollViewContentSize: CGSize = .zero - @State var disableScrolling: Bool = true - private let menuButtonHeight: CGFloat = 68 - @State var chatViewNameWidth: CGFloat = 0 - + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @Environment(\.scenePhase) private var scenePhase: ScenePhase + @Environment(\.colorScheme) private var colorScheme: ColorScheme + @Environment(\.dismiss) private var dismiss: DismissAction + @Binding var activeSheet: UserPickerSheet? + @State private var switchingProfile = false var body: some View { - VStack { - Spacer().frame(height: 1) - VStack(spacing: 0) { - ScrollView { - ScrollViewReader { sp in - let users = m.users - .filter({ u in u.user.activeUser || !u.user.hidden }) - .sorted { u, _ in u.user.activeUser } - VStack(spacing: 0) { - ForEach(users) { u in - userView(u) - Divider() - if u.user.activeUser { Divider() } - } - } - .overlay { - GeometryReader { geo -> Color in - DispatchQueue.main.async { - scrollViewContentSize = geo.size - let scenes = UIApplication.shared.connectedScenes - if let windowScene = scenes.first as? UIWindowScene { - let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame - disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height - } - } - return Color.clear - } - } - .onChange(of: userPickerVisible) { visible in - if visible, let u = users.first { - sp.scrollTo(u.id) + if #available(iOS 16.0, *) { + let v = viewBody.presentationDetents([.height(420)]) + if #available(iOS 16.4, *) { + v.scrollBounceBehavior(.basedOnSize) + } else { + v + } + } else { + viewBody + } + } + + private var viewBody: some View { + let otherUsers = m.users.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId } + return List { + Section(header: Text("You").foregroundColor(theme.colors.secondary)) { + if let user = m.currentUser { + openSheetOnTap(label: { + ZStack { + let v = ProfilePreview(profileOf: user) + .foregroundColor(.primary) + .padding(.leading, -8) + if #available(iOS 16.0, *) { + v + } else { + v.padding(.vertical, 4) } } + }) { + activeSheet = .currentProfile } - } - .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) - .frame(maxHeight: scrollViewContentSize.height) - menuButton("Use from desktop", icon: "desktopcomputer") { - showConnectDesktop = true - withAnimation { - userPickerVisible.toggle() + openSheetOnTap(title: m.userAddress == nil ? "Create public address" : "Your public address", icon: "qrcode") { + activeSheet = .address + } + + openSheetOnTap(title: "Chat preferences", icon: "switch.2") { + activeSheet = .chatPreferences } } - Divider() - menuButton("Settings", icon: "gearshape") { - showSettings = true - withAnimation { - userPickerVisible.toggle() + } + + Section { + if otherUsers.isEmpty { + openSheetOnTap(title: "Your chat profiles", icon: "person.crop.rectangle.stack") { + activeSheet = .chatProfiles + } + } else { + let v = userPickerRow(otherUsers, size: 44) + .padding(.leading, -11) + if #available(iOS 16.0, *) { + v + } else { + v.padding(.vertical, 4) + } + } + + openSheetOnTap(title: "Use from desktop", icon: "desktopcomputer") { + activeSheet = .useFromDesktop + } + + ZStack(alignment: .trailing) { + openSheetOnTap(title: "Settings", icon: "gearshape") { + activeSheet = .settings + } + Label {} icon: { + Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill") + .resizable() + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: 20, maxHeight: 20) + } + .onTapGesture { + if (colorScheme == .light) { + ThemeManager.applyTheme(systemDarkThemeDefault.get()) + } else { + ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName) + } + } + .onLongPressGesture { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) } } } } - .clipShape(RoundedRectangle(cornerRadius: 16)) - .background( - Rectangle() - .fill(theme.colors.surface) - .cornerRadius(16) - .shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0) - ) - .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 } - .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300) - .padding(8) - .opacity(userPickerVisible ? 1.0 : 0.0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { - // This check prevents the call of listUsers after the app is suspended, and the database is closed. - if case .active = scenePhase { - Task { - do { - let users = try await listUsersAsync() - await MainActor.run { m.users = users } - } catch { - logger.error("Error loading users \(responseError(error))") - } - } - } - } - } - - private func userView(_ u: UserInfo) -> some View { - let user = u.user - return Button(action: { - if user.activeUser { - showSettings = true - withAnimation { - userPickerVisible.toggle() - } - } else { + // This check prevents the call of listUsers after the app is suspended, and the database is closed. + if case .active = scenePhase { Task { do { - try await changeActiveUserAsync_(user.userId, viewPwd: nil) - await MainActor.run { userPickerVisible = false } + let users = try await listUsersAsync() + await MainActor.run { m.users = users } } catch { - await MainActor.run { - AlertManager.shared.showAlertMsg( - title: "Error switching profile!", - message: "Error: \(responseError(error))" - ) - } + logger.error("Error loading users \(responseError(error))") } } } - }, label: { - HStack(spacing: 0) { - ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill)) - .padding(.trailing, 12) - Text(user.chatViewName) - .fontWeight(user.activeUser ? .medium : .regular) - .foregroundColor(theme.colors.onBackground) - .overlay(DetermineWidth()) - Spacer() - if user.activeUser { - Image(systemName: "checkmark") - } else if u.unreadCount > 0 { - unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary) - } else if !user.showNtfs { - Image(systemName: "speaker.slash") + } + .modifier(ThemedBackground(grouped: true)) + .disabled(switchingProfile) + } + + private func userPickerRow(_ users: [UserInfo], size: CGFloat) -> some View { + HStack(spacing: 6) { + let s = ScrollView(.horizontal) { + HStack(spacing: 27) { + ForEach(users) { u in + if !u.user.hidden && u.user.userId != m.currentUser?.userId { + userView(u, size: size) + } + } + } + .padding(.leading, 4) + .padding(.trailing, 22) + } + ZStack(alignment: .trailing) { + if #available(iOS 16.0, *) { + s.scrollIndicators(.hidden) + } else { + s + } + LinearGradient( + colors: [.clear, .black], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: size, height: size + 3) + .blendMode(.destinationOut) + .allowsHitTesting(false) + } + .compositingGroup() + .padding(.top, -3) // to fit unread badge + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(theme.colors.secondary) + .padding(.trailing, 4) + .onTapGesture { + activeSheet = .chatProfiles + } + } + } + + private func userView(_ u: UserInfo, size: CGFloat) -> some View { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground)) + .padding([.top, .trailing], 3) + if (u.unreadCount > 0) { + unreadBadge(u) + } + } + .frame(width: size) + .onTapGesture { + switchingProfile = true + Task { + do { + try await changeActiveUserAsync_(u.user.userId, viewPwd: nil) + await MainActor.run { + switchingProfile = false + dismiss() + } + } catch { + await MainActor.run { + switchingProfile = false + AlertManager.shared.showAlertMsg( + title: "Error switching profile!", + message: "Error: \(responseError(error))" + ) + } } } - .padding(.trailing) - .padding([.leading, .vertical], 12) - }) - .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill))) + } } - - private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 0) { - Text(title) - .overlay(DetermineWidth()) - Spacer() - Image(systemName: icon) + + private func openSheetOnTap(title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { + openSheetOnTap(label: { + ZStack(alignment: .leading) { + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center) .symbolRenderingMode(.monochrome) .foregroundColor(theme.colors.secondary) + Text(title) + .foregroundColor(.primary) + .padding(.leading, 36) } - .padding(.horizontal) - .padding(.vertical, 22) - .frame(height: menuButtonHeight) - } - .buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill))) + }, action: action) + } + + private func openSheetOnTap(label: () -> V, action: @escaping () -> Void) -> some View { + Button(action: action, label: label) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + + private func unreadBadge(_ u: UserInfo) -> some View { + let size = dynamicSize(userFont).chatInfoSize + return unreadCountText(u.unreadCount) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: size, minHeight: size) + .background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary) + .cornerRadius(dynamicSize(userFont).unreadCorner) } -} - -private func unreadCounter(_ unread: Int, color: Color) -> some View { - unreadCountText(unread) - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 4) - .frame(minWidth: 18, minHeight: 18) - .background(color) - .cornerRadius(10) } struct UserPicker_Previews: PreviewProvider { static var previews: some View { + @State var activeSheet: UserPickerSheet? + let m = ChatModel() m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( - showSettings: Binding.constant(false), - showConnectDesktop: Binding.constant(false), - userPickerVisible: Binding.constant(true) + activeSheet: $activeSheet ) .environmentObject(m) } diff --git a/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift new file mode 100644 index 0000000000..dca413dafe --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/InvertedForegroundStyle.swift @@ -0,0 +1,21 @@ +// +// Test.swift +// SimpleX (iOS) +// +// Created by Levitating Pineapple on 31/08/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder + func invertedForegroundStyle(enabled: Bool = true) -> some View { + if enabled { + foregroundStyle(Material.ultraThin) + .environment(\.colorScheme, .dark) + .grayscale(1) + .contrast(-20) + } else { self } + } +} diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 248504c59b..3eedd56441 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -20,7 +20,7 @@ struct ProfileImage: View { @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner var body: some View { - if let uiImage = UIImage(base64Encoded: imageStr) { + if let uiImage = imageFromBase64(imageStr) { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) } else { let c = color.asAnotherColorFromSecondaryVariant(theme) diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 936c6cb3ab..7b80dd1544 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -8,15 +8,47 @@ import SwiftUI -func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { +func getTopViewController() -> UIViewController? { let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, - let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { + let rootViewController = keyWindow.rootViewController { + // Find the top-most presented view controller + var topController = rootViewController + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + return topController + } + return nil +} + +func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { + if let topController = getTopViewController() { let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) if let completed = completed { - let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() } - activityViewController.completionWithItemsHandler = handler - } - presentedViewController.present(activityViewController, animated: true) + activityViewController.completionWithItemsHandler = { _, _, _, _ in + completed() + } + } + topController.present(activityViewController, animated: true) + } +} + +func showAlert( + title: String, + message: String? = nil, + buttonTitle: String, + buttonAction: @escaping () -> Void, + cancelButton: Bool +) -> Void { + if let topController = getTopViewController() { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in + buttonAction() + }) + if cancelButton { + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel)) + } + topController.present(alert, animated: true) } } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 9cc229ba80..5f7b8a4534 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -24,6 +24,7 @@ private enum MigrationFromState: Equatable { } private enum MigrateFromDeviceViewAlert: Identifiable { + case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl) case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.") case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures") @@ -38,6 +39,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable { var id: String { switch self { + case .finishMigration: return "finishMigration" case let .deleteChat(title, text): return "\(title) \(text)" case let .startChat(title, text): return "\(title) \(text)" @@ -56,8 +58,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable { struct MigrateFromDevice: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme - @Environment(\.dismiss) var dismiss: DismissAction - @Binding var showSettings: Bool @Binding var showProgressOnSettings: Bool @State private var migrationState: MigrationFromState = .chatStopInProgress @State private var useKeychain = storeDBPassphraseGroupDefault.get() @@ -106,9 +106,6 @@ struct MigrateFromDevice: View { finishedView(chatDeletion) } } - .modifier(BackButton(label: "Back", disabled: $backDisabled) { - dismiss() - }) .onChange(of: migrationState) { state in backDisabled = switch migrationState { case .chatStopInProgress, .archiving, .linkShown, .finished: true @@ -138,6 +135,15 @@ struct MigrateFromDevice: View { } .alert(item: $alert) { alert in switch alert { + case let .finishMigration(fileId, ctrl): + return Alert( + title: Text("Remove archive?"), + message: Text("The uploaded database archive will be permanently removed from the servers."), + primaryButton: .destructive(Text("Continue")) { + finishMigration(fileId, ctrl) + }, + secondaryButton: .cancel() + ) case let .startChat(title, text): return Alert( title: Text(title), @@ -318,7 +324,7 @@ struct MigrateFromDevice: View { Text("Cancel migration").foregroundColor(.red) } } - Button(action: { finishMigration(fileId, ctrl) }) { + Button(action: { alert = .finishMigration(fileId, ctrl) }) { settingsRow("checkmark", color: theme.colors.secondary) { Text("Finalize migration").foregroundColor(theme.colors.primary) } @@ -590,7 +596,7 @@ struct MigrateFromDevice: View { } catch let error { fatalError("Error starting chat \(responseError(error))") } - showSettings = false + dismissAllSheets(animated: true) } } catch let error { alert = .error(title: "Error deleting database", error: responseError(error)) @@ -613,9 +619,7 @@ struct MigrateFromDevice: View { } // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered if dismiss || m.chatDbStatus != .ok { - await MainActor.run { - showSettings = false - } + dismissAllSheets(animated: true) } } @@ -767,6 +771,6 @@ private class MigrationChatReceiver { struct MigrateFromDevice_Previews: PreviewProvider { static var previews: some View { - MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) + MigrateFromDevice(showProgressOnSettings: Binding.constant(false)) } } diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 67ea1008cd..e2df68a0e4 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -93,7 +93,6 @@ struct MigrateToDevice: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction - @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var alert: MigrateToDeviceViewAlert? @@ -102,6 +101,7 @@ struct MigrateToDevice: View { // Prevent from hiding the view until migration is finished or app deleted @State private var backDisabled: Bool = false @State private var showQRCodeScanner: Bool = true + @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { VStack { @@ -197,10 +197,8 @@ struct MigrateToDevice: View { } } } - if developerTools { - Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) { - pasteLinkView() - } + Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) { + pasteLinkView() } } } @@ -218,7 +216,7 @@ struct MigrateToDevice: View { } label: { Text("Tap to paste link") } - .disabled(!ChatModel.shared.pasteboardHasStrings) + .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) } @@ -487,6 +485,9 @@ struct MigrateToDevice: View { do { if !hasChatCtrl() { chatInitControllerRemovingDatabases() + } else if ChatModel.shared.chatRunning == true { + // cannot delete storage if chat is running + try await apiStopChat() } try await apiDeleteStorage() try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) @@ -556,11 +557,22 @@ struct MigrateToDevice: View { do { try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) MigrationToDeviceState.save(nil) - appSettings.importIntoApp() - try SimpleX.startChat(refreshInvitations: true) - AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") + try ObjC.catchException { + appSettings.importIntoApp() + } + do { + try SimpleX.startChat(refreshInvitations: true) + AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") + } catch let error { + AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error)))) + } } catch let error { - AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error)))) + logger.error("Error importing settings: \(error.localizedDescription)") + AlertManager.shared.showAlert( + Alert( + title: Text("Error migrating settings"), + message: Text ("Not all settings were migrated. Repeat migration if you need them.") + Text("\n\n") + Text(responseError(error))) + ) } hideView() } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 78dc2be7b8..051b1158ec 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -196,7 +196,7 @@ func chatContactType(chat: Chat) -> ContactType { case .contactRequest: return .request case let .direct(contact): - if contact.activeConn == nil && contact.profile.contactLink != nil { + if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { return .card } else if contact.chatDeleted { return .chatDeleted diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index c8cfc84230..f07ddf1420 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -585,6 +585,7 @@ private struct ConnectView: View { @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? @State private var sheet: PlanAndConnectActionSheet? + @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { List { @@ -621,7 +622,7 @@ private struct ConnectView: View { } label: { Text("Tap to paste link") } - .disabled(!ChatModel.shared.pasteboardHasStrings) + .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) } else { linkTextView(pastedLink) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index be063334d3..b1f68c09f4 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -59,13 +59,6 @@ struct ConnectDesktopView: View { var body: some View { if viaSettings { viewBody - .modifier(BackButton(label: "Back", disabled: Binding.constant(false)) { - if m.activeRemoteCtrl { - alert = .disconnectDesktop(action: .back) - } else { - dismiss() - } - }) } else { NavigationView { viewBody diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 6247777bf2..bd829552f4 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -52,10 +52,10 @@ extension AppSettings { profileImageCornerRadiusGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) } - if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) } - if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) } - if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) } - if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) } + if let val = uiColorScheme { currentThemeDefault.set(val) } + if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) } + if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) } + if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) } if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) } } diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index 0c10da2103..bd8171623a 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -32,6 +32,17 @@ struct PreferencesView: View { .disabled(currentPreferences == preferences) } } + .onDisappear { + if currentPreferences != preferences { + showAlert( + title: NSLocalizedString("Your chat preferences", comment: "alert title"), + message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: savePreferences, + cancelButton: true + ) + } + } } private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding) -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d9c83803dd..463ac4ae07 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -262,7 +262,9 @@ struct SettingsView: View { var body: some View { ZStack { - settingsView() + NavigationView { + settingsView() + } if showProgress { progressView() } @@ -274,63 +276,7 @@ struct SettingsView: View { @ViewBuilder func settingsView() -> some View { let user = chatModel.currentUser - NavigationView { List { - Section(header: Text("You").foregroundColor(theme.colors.secondary)) { - if let user = user { - NavigationLink { - UserProfile() - .navigationTitle("Your current profile") - .modifier(ThemedBackground()) - } label: { - ProfilePreview(profileOf: user) - .padding(.leading, -8) - } - } - - NavigationLink { - UserProfilesView(showSettings: $showSettings) - } label: { - settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") } - } - - - if let user = user { - NavigationLink { - UserAddressView(shareViaProfile: user.addressShared) - .navigationTitle("SimpleX address") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") } - } - - NavigationLink { - PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences) - .navigationTitle("Your preferences") - .modifier(ThemedBackground(grouped: true)) - } label: { - settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") } - } - } - - NavigationLink { - ConnectDesktopView(viaSettings: true) - } label: { - settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") } - } - - NavigationLink { - MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) - .navigationTitle("Migrate device") - .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) - } label: { - settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } - } - } - .disabled(chatModel.chatRunning != true) - Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { NavigationLink { NotificationsView() @@ -381,10 +327,20 @@ struct SettingsView: View { } .disabled(chatModel.chatRunning != true) } - - chatDatabaseRow() } + Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) { + chatDatabaseRow() + NavigationLink { + MigrateFromDevice(showProgressOnSettings: $showProgress) + .navigationTitle("Migrate device") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") } + } + } + Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { if let user = user { NavigationLink { @@ -462,11 +418,10 @@ struct SettingsView: View { } .navigationTitle("Your settings") .modifier(ThemedBackground(grouped: true)) - } - .onDisappear { - chatModel.showingTerminal = false - chatModel.terminalItems = [] - } + .onDisappear { + chatModel.showingTerminal = false + chatModel.terminalItems = [] + } } private func chatDatabaseRow() -> some View { @@ -549,17 +504,18 @@ struct ProfilePreview: View { HStack { ProfileImage(imageStr: profileOf.image, size: 44, color: color) .padding(.trailing, 6) - .padding(.vertical, 6) - VStack(alignment: .leading) { - Text(profileOf.displayName) - .fontWeight(.bold) - .font(.title2) - if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { - Text(profileOf.fullName) - } - } + profileName().lineLimit(1) } } + + private func profileName() -> Text { + var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) + if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { + t = t + Text(" (" + profileOf.fullName + ")") +// .font(.callout) + } + return t + } } struct SettingsView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index fa95c51d36..7efc8a46f5 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -14,7 +14,6 @@ struct UserAddressView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme - @State var viaCreateLinkView = false @State var shareViaProfile = false @State private var aas = AutoAcceptState() @State private var savedAAS = AutoAcceptState() @@ -22,7 +21,6 @@ struct UserAddressView: View { @State private var showMailView = false @State private var mailViewResult: Result? = nil @State private var alert: UserAddressAlert? - @State private var showSaveDialogue = false @State private var progressIndicator = false @FocusState private var keyboardVisible: Bool @@ -44,26 +42,19 @@ struct UserAddressView: View { var body: some View { ZStack { - if viaCreateLinkView { - userAddressScrollView() - } else { - userAddressScrollView() - .modifier(BackButton(disabled: Binding.constant(false)) { - if savedAAS == aas { - dismiss() - } else { - keyboardVisible = false - showSaveDialogue = true - } - }) - .confirmationDialog("Save settings?", isPresented: $showSaveDialogue) { - Button("Save auto-accept settings") { - saveAAS() - dismiss() - } - Button("Exit without saving") { dismiss() } + userAddressScrollView() + .onDisappear { + if savedAAS != aas { + showAlert( + title: NSLocalizedString("Auto-accept settings", comment: "alert title"), + message: NSLocalizedString("Settings were changed.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save", comment: "alert button"), + buttonAction: saveAAS, + cancelButton: true + ) } - } + } + if progressIndicator { ZStack { if chatModel.userAddress != nil { @@ -238,7 +229,7 @@ struct UserAddressView: View { } } } label: { - Label("Create SimpleX address", systemImage: "qrcode") + Label("Create public address", systemImage: "qrcode") } } @@ -342,7 +333,7 @@ struct UserAddressView: View { } } } - + private struct AutoAcceptState: Equatable { var enable = false var incognito = false @@ -447,6 +438,8 @@ struct UserAddressView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") + + return Group { UserAddressView() .environmentObject(chatModel) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 06342db529..330ce56e0b 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -9,7 +9,6 @@ import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @EnvironmentObject private var theme: AppTheme - @Binding var showSettings: Bool @Environment(\.editMode) private var editMode @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @@ -96,8 +95,7 @@ struct UserProfilesView: View { } label: { Label("Add profile", systemImage: "plus") } - .frame(height: 44) - .padding(.vertical, 4) + .frame(height: 38) } } footer: { Text("Tap to activate profile.") @@ -285,7 +283,7 @@ struct UserProfilesView: View { await MainActor.run { onboardingStageDefault.set(.step1_SimpleXInfo) m.onboardingStage = .step1_SimpleXInfo - showSettings = false + dismissAllSheets() } } } else { @@ -308,14 +306,14 @@ struct UserProfilesView: View { Task { do { try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user)) + dismissAllSheets() } catch { await MainActor.run { alert = .activateUserError(error: responseError(error)) } } } } label: { HStack { - ProfileImage(imageStr: user.image, size: 44) - .padding(.vertical, 4) + ProfileImage(imageStr: user.image, size: 38) .padding(.trailing, 12) Text(user.chatViewName) Spacer() @@ -415,6 +413,6 @@ public func correctPassword(_ user: User, _ pwd: String) -> Bool { struct UserProfilesView_Previews: PreviewProvider { static var previews: some View { - UserProfilesView(showSettings: Binding.constant(true)) + UserProfilesView() } } diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index f43548f676..e73aeee13c 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -104,7 +104,7 @@ class ShareModel: ObservableObject { // 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) { + let uiImage = imageFromBase64(profileImage) { dict[chatData.id] = uiImage } } diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index 1f502ffcff..f2b9de9f72 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -147,8 +147,8 @@ struct ShareView: View { } } - @ViewBuilder private func imagePreview(_ img: String) -> some View { - if let img = UIImage(base64Encoded: img) { + @ViewBuilder private func imagePreview(_ imgStr: String) -> some View { + if let img = imageFromBase64(imgStr) { previewArea { Image(uiImage: img) .resizable() @@ -163,7 +163,7 @@ struct ShareView: View { @ViewBuilder private func linkPreview(_ linkPreview: LinkPreview) -> some View { previewArea { HStack(alignment: .center, spacing: 8) { - if let uiImage = UIImage(base64Encoded: linkPreview.image) { + if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3700fbbf16..5d777dd158 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,6 +167,11 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */; }; + 64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085E2C8F2CCB00AE9D01 /* libffi.a */; }; + 64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */; }; + 64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */; }; + 64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; @@ -177,6 +182,8 @@ 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; + 8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 8C01E9C02C8EFC33008A4B0A /* objc.m */; }; + 8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C01E9BF2C8EFBB6008A4B0A /* objc.h */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; 8C74C3E52C1B900600039E77 /* ThemeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7E3CE32C0DEAC400BFF63A /* ThemeTypes.swift */; }; 8C74C3E72C1B901900039E77 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C852B072C1086D100BA61E8 /* Color.swift */; }; @@ -194,6 +201,7 @@ 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; + CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.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 */; }; @@ -214,11 +222,6 @@ D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; - E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84522C832BF9008C24D1 /* libgmpxx.a */; }; - E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */; }; - E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */; }; - E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84552C832BF9008C24D1 /* libgmp.a */; }; - E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84562C832BF9008C24D1 /* libffi.a */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; @@ -505,6 +508,11 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; + 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 64A2085E2C8F2CCB00AE9D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a"; sourceTree = ""; }; + 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a"; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -516,6 +524,8 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; + 8C01E9BF2C8EFBB6008A4B0A /* objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = objc.h; sourceTree = ""; }; + 8C01E9C02C8EFC33008A4B0A /* objc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = objc.m; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8C74C3EB2C1B92A900039E77 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWallpaper.swift; sourceTree = ""; }; @@ -532,6 +542,7 @@ 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; + CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.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 = ""; }; @@ -550,11 +561,6 @@ D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = ""; }; - E5BD84522C832BF9008C24D1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a"; sourceTree = ""; }; - E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a"; sourceTree = ""; }; - E5BD84552C832BF9008C24D1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - E5BD84562C832BF9008C24D1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -645,13 +651,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */, - E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */, + 64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */, + 64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */, + 64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */, - E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */, - E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */, + 64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -729,11 +735,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - E5BD84562C832BF9008C24D1 /* libffi.a */, - E5BD84552C832BF9008C24D1 /* libgmp.a */, - E5BD84522C832BF9008C24D1 /* libgmpxx.a */, - E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */, - E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */, + 64A2085E2C8F2CCB00AE9D01 /* libffi.a */, + 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */, + 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */, + 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */, + 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */, ); path = Libraries; sourceTree = ""; @@ -789,6 +795,7 @@ 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */, CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */, CE7548092C622630009579B7 /* SwipeLabel.swift */, + CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */, ); path = Helpers; sourceTree = ""; @@ -976,6 +983,8 @@ 5CE2BA96284537A800EC33A6 /* dummy.m */, 5CD67B8D2B0E858A00C510B1 /* hs_init.h */, 5CD67B8E2B0E858A00C510B1 /* hs_init.c */, + 8C01E9BF2C8EFBB6008A4B0A /* objc.h */, + 8C01E9C02C8EFC33008A4B0A /* objc.m */, ); path = SimpleXChat; sourceTree = ""; @@ -1113,6 +1122,7 @@ files = ( 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */, 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */, + 8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */, 5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1489,6 +1499,7 @@ 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, + CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */, 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, @@ -1539,6 +1550,7 @@ 8C74C3E72C1B901900039E77 /* Color.swift in Sources */, 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */, 5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */, + 8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */, 5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */, 5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */, 5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d4998762d7..7f030cb838 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -129,6 +129,7 @@ public enum ChatCommand { // WebRTC calls / case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) + case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) @@ -293,6 +294,7 @@ public enum ChatCommand { case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" + case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" @@ -434,6 +436,7 @@ public enum ChatCommand { case .apiCallStatus: return "apiCallStatus" case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" + case .apiChatItemsRead: return "apiChatItemsRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" case .setFileToReceive: return "setFileToReceive" @@ -462,6 +465,10 @@ public enum ChatCommand { "\(type.rawValue)\(id)" } + func joinedIds(_ ids: [Int64]) -> String { + ids.map { "\($0)" }.joined(separator: ",") + } + func protoServersStr(_ servers: [ServerCfg]) -> String { encodeJSON(ProtoServersConfig(servers: servers)) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 07340bb963..84bf445601 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2713,7 +2713,7 @@ public struct CIMeta: Decodable, Hashable { public var deletable: Bool public var editable: Bool - public var timestampText: Text { get { formatTimestampText(itemTs) } } + public var timestampText: Text { Text(formatTimestampMeta(itemTs)) } public var recent: Bool { updatedAt + 10 > .now } public var isLive: Bool { itemLive == true } public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil } @@ -2723,10 +2723,6 @@ public struct CIMeta: Decodable, Hashable { return false } - public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color)? { - itemStatus.statusIcon(metaColor, primaryColor) - } - public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> CIMeta { CIMeta( itemId: id, @@ -2765,8 +2761,30 @@ public struct CITimed: Decodable, Hashable { public var deleteAt: Date? } +let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() +let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) + public func formatTimestampText(_ date: Date) -> Text { - Text(verbatim: date.formatted(date: .omitted, time: .shortened)) + Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat)) +} + +public func formatTimestampMeta(_ date: Date) -> String { + date.formatted(date: .omitted, time: .shortened) +} + +private func recent(_ date: Date) -> Bool { + let now = Date() + let calendar = Calendar.current + + guard let previousDay = calendar.date(byAdding: DateComponents(day: -1), to: now), + let previousDay18 = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: previousDay), + let currentDay00 = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: now), + let currentDay12 = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: now) else { + return false + } + + let isSameDay = calendar.isDate(date, inSameDayAs: now) + return isSameDay || (now < currentDay12 && date >= previousDay18 && date < currentDay00) } public enum CIStatus: Decodable, Hashable { @@ -2794,21 +2812,22 @@ public enum CIStatus: Decodable, Hashable { } } - public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color)? { + public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? { switch self { - case .sndNew: return nil - case .sndSent: return ("checkmark", metaColor) - case let .sndRcvd(msgRcptStatus, _): + case .sndNew: nil + case let .sndSent(sndProgress): + (Image("checkmark.wide"), sndProgress == .partial ? paleMetaColor : metaColor) + case let .sndRcvd(msgRcptStatus, sndProgress): switch msgRcptStatus { - case .ok: return ("checkmark", metaColor) - case .badMsgHash: return ("checkmark", .red) + case .ok: (Image("checkmark.2"), sndProgress == .partial ? paleMetaColor : metaColor) + case .badMsgHash: (Image("checkmark.2"), .red) } - case .sndErrorAuth: return ("multiply", .red) - case .sndError: return ("multiply", .red) - case .sndWarning: return ("exclamationmark.triangle.fill", .orange) - case .rcvNew: return ("circlebadge.fill", primaryColor) - case .rcvRead: return nil - case .invalid: return ("questionmark", metaColor) + case .sndErrorAuth: (Image(systemName: "multiply"), .red) + case .sndError: (Image(systemName: "multiply"), .red) + case .sndWarning: (Image(systemName: "exclamationmark.triangle.fill"), .orange) + case .rcvNew: (Image(systemName: "circlebadge.fill"), primaryColor) + case .rcvRead: nil + case .invalid: (Image(systemName: "questionmark"), metaColor) } } @@ -2903,20 +2922,20 @@ public enum GroupSndStatus: Decodable, Hashable { case warning(agentError: SndError) case invalid(text: String) - public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color) { + public func statusIcon(_ metaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color) { switch self { - case .new: return ("ellipsis", metaColor) - case .forwarded: return ("chevron.forward.2", metaColor) - case .inactive: return ("person.badge.minus", metaColor) - case .sent: return ("checkmark", metaColor) + case .new: (Image(systemName: "ellipsis"), metaColor) + case .forwarded: (Image(systemName: "chevron.forward.2"), metaColor) + case .inactive: (Image(systemName: "person.badge.minus"), metaColor) + case .sent: (Image("checkmark.wide"), metaColor) case let .rcvd(msgRcptStatus): switch msgRcptStatus { - case .ok: return ("checkmark", metaColor) - case .badMsgHash: return ("checkmark", .red) + case .ok: (Image("checkmark.2"), metaColor) + case .badMsgHash: (Image("checkmark.2"), .red) } - case .error: return ("multiply", .red) - case .warning: return ("exclamationmark.triangle.fill", .orange) - case .invalid: return ("questionmark", metaColor) + case .error: (Image(systemName: "multiply"), .red) + case .warning: (Image(systemName: "exclamationmark.triangle.fill"), .orange) + case .invalid: (Image(systemName: "questionmark"), metaColor) } } diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index 67218a781e..36148d4324 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -383,16 +383,34 @@ extension UIImage { } return self } +} - public convenience init?(base64Encoded: String?) { - if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) { - self.init(data: data) +public func imageFromBase64(_ base64Encoded: String?) -> UIImage? { + if let base64Encoded { + if let img = imageCache.object(forKey: base64Encoded as NSString) { + return img + } else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)), + let img = UIImage(data: data) { + imageCacheQueue.async { + imageCache.setObject(img, forKey: base64Encoded as NSString) + } + return img } else { return nil } + } else { + return nil } } +private let imageCacheQueue = DispatchQueue.global(qos: .background) + +private var imageCache: NSCache = { + var cache = NSCache() + cache.countLimit = 1000 + return cache +}() + public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { logger.debug("getLinkMetadata: fetching URL preview") LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 153365424e..92dfafca21 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -10,6 +10,7 @@ #define SimpleX_h #include "hs_init.h" +#include "objc.h" extern void hs_init(int argc, char **argv[]); diff --git a/apps/ios/SimpleXChat/objc.h b/apps/ios/SimpleXChat/objc.h new file mode 100644 index 0000000000..a75a6dc5e4 --- /dev/null +++ b/apps/ios/SimpleXChat/objc.h @@ -0,0 +1,20 @@ +// +// objc.h +// SimpleX (iOS) +// +// Created by Stanislav Dmitrenko on 09.09.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +#ifndef objc_h +#define objc_h + +#import + +@interface ObjC : NSObject + ++ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error; + +@end + +#endif /* objc_h */ diff --git a/apps/ios/SimpleXChat/objc.m b/apps/ios/SimpleXChat/objc.m new file mode 100644 index 0000000000..c6952578ab --- /dev/null +++ b/apps/ios/SimpleXChat/objc.m @@ -0,0 +1,25 @@ +// +// objc.m +// SimpleXChat +// +// Created by Stanislav Dmitrenko on 09.09.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +#import "objc.h" + +@implementation ObjC + +// https://stackoverflow.com/a/36454808 ++ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error { + @try { + tryBlock(); + return YES; + } + @catch (NSException *exception) { + *error = [[NSError alloc] initWithDomain: exception.name code: 0 userInfo: exception.userInfo]; + return NO; + } +} + +@end diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt index 64b6639a58..b18204d905 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt @@ -5,9 +5,8 @@ import android.util.Log import androidx.work.* import chat.simplex.app.SimplexService.Companion.showPassphraseNotification import chat.simplex.common.model.ChatController +import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.DBMigrationResult -import chat.simplex.common.platform.chatModel -import chat.simplex.common.platform.initChatControllerOnStart import chat.simplex.common.views.helpers.DatabaseUtils import kotlinx.coroutines.* import java.util.Date @@ -30,12 +29,12 @@ object MessagesFetcherWorker { .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .build() - WorkManager.getInstance(SimplexApp.context).enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest) + SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest) } fun cancelAll() { Log.d(TAG, "Worker: canceled all tasks") - WorkManager.getInstance(SimplexApp.context).cancelUniqueWork(UNIQUE_WORK_TAG) + SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG) } } 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 a8b91e261b..49edde55bb 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 @@ -7,7 +7,7 @@ import chat.simplex.common.platform.Log import android.content.Intent import android.content.pm.ActivityInfo import android.os.* -import android.view.View +import androidx.compose.animation.core.* import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.graphics.Color @@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit const val TAG = "SIMPLEX" -class SimplexApp: Application(), LifecycleEventObserver { +class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider { val chatModel: ChatModel get() = chatController.chatModel @@ -164,7 +164,7 @@ class SimplexApp: Application(), LifecycleEventObserver { .addTag(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC) .build() Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes") - WorkManager.getInstance(context)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) + getWorkManagerInstance().enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work) } fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch { @@ -260,7 +260,6 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidSetNightModeIfSupported() { if (Build.VERSION.SDK_INT < 31) return - val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) { null } else { @@ -275,6 +274,31 @@ class SimplexApp: Application(), LifecycleEventObserver { uiModeManager.setApplicationNightMode(mode) } + override fun androidSetDrawerStatusAndNavBarColor( + isLight: Boolean, + drawerShadingColor: Color, + toolbarOnTop: Boolean, + navBarColor: Color, + ) { + val window = mainActivity.get()?.window ?: return + + @Suppress("DEPRECATION") + val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView) + // Blend status bar color to the animated color + val colors = CurrentColors.value.colors + val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background + window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb() + val navBar = navBarColor.toArgb() + + if (window.navigationBarColor != navBar) { + window.navigationBarColor = navBar + } + + if (windowInsetController?.isAppearanceLightNavigationBars != isLight) { + windowInsetController?.isAppearanceLightNavigationBars = isLight + } + } + override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) { val window = mainActivity.get()?.window ?: return @Suppress("DEPRECATION") @@ -367,4 +391,9 @@ class SimplexApp: Application(), LifecycleEventObserver { } } } + + // Fix for an exception: + // WorkManager is not initialized properly. You have explicitly disabled WorkManagerInitializer in your manifest, have not manually called WorkManager#initialize at this point, and your Application does not implement Configuration.Provider. + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().build() } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 004d2bc7f1..ce3f0825b8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -292,7 +292,7 @@ class SimplexService: Service() { fun scheduleStart(context: Context) { Log.d(TAG, "Enqueuing work to start subscriber service") - val workManager = WorkManager.getInstance(context) + val workManager = context.getWorkManagerInstance() val startServiceRequest = OneTimeWorkRequest.Builder(ServiceStartWorker::class.java).build() workManager.enqueueUniqueWork(WORK_NAME_ONCE, ExistingWorkPolicy.KEEP, startServiceRequest) // Unique avoids races! } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 1670672753..1aaa061daa 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -99,10 +99,15 @@ kotlin { val desktopMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0") - implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") + implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") { + exclude("net.java.dev.jna") + } + // For jSystemThemeDetector only + implementation("net.java.dev.jna:jna-platform:5.14.0") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") + implementation("net.java.dev.jna:jna:5.14.0") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index d739a033f9..8cd51e8298 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -6,6 +6,8 @@ import android.net.LocalServerSocket import android.util.Log import androidx.activity.ComponentActivity import androidx.fragment.app.FragmentActivity +import androidx.work.Configuration +import androidx.work.WorkManager import java.io.* import java.lang.ref.WeakReference import java.util.* @@ -72,3 +74,16 @@ fun initHaskell() { initHS() } + +fun Context.getWorkManagerInstance(): WorkManager { + // https://github.com/OneSignal/OneSignal-Android-SDK/pull/2052/files + // https://github.com/OneSignal/OneSignal-Android-SDK/issues/1672 + if (!WorkManager.isInitialized()) { + try { + WorkManager.initialize(this, Configuration.Builder().build()) + } catch (e: IllegalStateException) { + Log.e(TAG, "Error initializing WorkManager: ${e.stackTraceToString()}") + } + } + return WorkManager.getInstance(this) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt new file mode 100644 index 0000000000..6c16a75874 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -0,0 +1,222 @@ +package chat.simplex.common.views.chatlist + +import SectionItemView +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.DrawerDefaults.ScrimOpacity +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.* +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.User +import chat.simplex.common.model.UserInfo +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun UserPickerInactiveUsersSection( + users: List, + stopped: Boolean, + onShowAllProfilesClicked: () -> Unit, + onUserClicked: (user: User) -> Unit, +) { + val scrollState = rememberScrollState() + + if (users.isNotEmpty()) { + SectionItemView( + padding = PaddingValues( + start = 16.dp, + top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF, + bottom = DEFAULT_PADDING_HALF), + disabled = stopped + ) { + Box { + Row( + modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState) + ) { + users.forEach { u -> + UserPickerInactiveUserBadge(u, stopped) { + onUserClicked(it) + withBGApi { + delay(500) + scrollState.scrollTo(0) + } + } + Spacer(Modifier.width(20.dp)) + } + Spacer(Modifier.width(60.dp)) + } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(end = DEFAULT_PADDING + 30.dp) + .height(60.dp) + ) { + Canvas(modifier = Modifier.size(60.dp)) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + CurrentColors.value.colors.surface, + ) + ), + ) + } + } + Row( + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(60.dp) + .fillMaxWidth() + .padding(end = DEFAULT_PADDING) + ) { + IconButton( + onClick = onShowAllProfilesClicked, + enabled = !stopped + ) { + Icon( + painterResource(MR.images.ic_chevron_right), + stringResource(MR.strings.your_chat_profiles), + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(34.dp) + ) + } + } + } + } + } else { + UserPickerOptionRow( + painterResource(MR.images.ic_manage_accounts), + stringResource(MR.strings.your_chat_profiles), + onShowAllProfilesClicked + ) + } +} + +private fun calculateFraction(pos: Float) = + (pos / 1f).coerceIn(0f, 1f) + +@Composable +actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow, content: @Composable () -> Unit) { + val pickerIsVisible = pickerState.collectAsState().value.isVisible() + val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) { + if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) { + pickerState.value = AnimatedViewState.HIDING + } + true + } + val height = remember { mutableIntStateOf(0) } + val heightValue = height.intValue + val clickableModifier = if (pickerIsVisible) { + Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }) + } else { + Modifier + } + Box( + Modifier + .fillMaxSize() + .then(clickableModifier) + .drawBehind { + val pos = when { + dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f + dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f + dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction + else -> 1 - dismissState.progress.fraction + } + val colors = CurrentColors.value.colors + val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f) + val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos) + val shadingColor = resultingColor.copy(alpha = adjustedAlpha) + + if (pickerState.value.isVisible()) { + platform.androidSetDrawerStatusAndNavBarColor( + isLight = colors.isLight, + drawerShadingColor = shadingColor, + toolbarOnTop = !appPrefs.oneHandUI.get(), + navBarColor = colors.surface + ) + } else if (ModalManager.start.modalCount.value == 0) { + platform.androidSetDrawerStatusAndNavBarColor( + isLight = colors.isLight, + drawerShadingColor = shadingColor, + toolbarOnTop = !appPrefs.oneHandUI.get(), + navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) { + colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f) + } else { + colors.background + }) + ) + } + drawRect( + if (pos != 0f) resultingColor else Color.Transparent, + alpha = calculateFraction(pos = pos) + ) + } + .graphicsLayer { + if (heightValue == 0) { + alpha = 0f + } + translationY = dismissState.offset.value + }, + contentAlignment = Alignment.BottomCenter + ) { + Box( + Modifier.onSizeChanged { height.intValue = it.height } + ) { + KeyChangeEffect(pickerIsVisible) { + if (pickerState.value.isVisible()) { + try { + dismissState.animateTo(DismissValue.Default, userPickerAnimSpec()) + } catch (e: CancellationException) { + Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}") + pickerState.value = AnimatedViewState.GONE + } + } else { + try { + dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec()) + } catch (e: CancellationException) { + Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}") + pickerState.value = AnimatedViewState.VISIBLE + } + } + } + val draggableModifier = if (height.intValue != 0) + Modifier.draggableBottomDrawerModifier( + state = dismissState, + swipeDistance = height.intValue.toFloat(), + ) + else Modifier + Box(draggableModifier.then(modifier)) { + content() + } + } + } +} + +private fun Modifier.draggableBottomDrawerModifier( + state: DismissState, + swipeDistance: Float, +): Modifier = this.swipeable( + state = state, + anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd), + thresholds = { _, _ -> FractionalThreshold(0.3f) }, + orientation = Orientation.Vertical, + resistance = null +) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 38818a123d..96b4a43e1a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -2,7 +2,6 @@ package chat.simplex.common.views.usersettings import SectionView import androidx.compose.runtime.Composable -import androidx.work.WorkManager import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* @@ -33,7 +32,7 @@ fun restartApp() { } private fun shutdownApp() { - WorkManager.getInstance(androidAppContext).cancelAllWork() + androidAppContext.getWorkManagerInstance().cancelAllWork() platform.androidServiceSafeStop() Runtime.getRuntime().exit(0) } 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 3cba89922d..b95aed45d2 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 @@ -6,14 +6,11 @@ import androidx.compose.animation.core.Animatable import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* -import androidx.compose.material.ripple.rememberRipple 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.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -42,12 +39,6 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlin.math.sqrt - -data class SettingsViewState( - val userPickerState: MutableStateFlow, - val scaffoldState: ScaffoldState -) @Composable fun AppScreen() { @@ -145,13 +136,11 @@ fun MainScreen() { userPickerState.value = AnimatedViewState.VISIBLE } } - val scaffoldState = rememberScaffoldState() - val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) } SetupClipboardListener() if (appPlatform.isAndroid) { - AndroidScreen(settingsState) + AndroidScreen(userPickerState) } else { - DesktopScreen(settingsState) + DesktopScreen(userPickerState) } } } @@ -249,7 +238,7 @@ fun MainScreen() { val ANDROID_CALL_TOP_PADDING = 40.dp @Composable -fun AndroidScreen(settingsState: SettingsViewState) { +fun AndroidScreen(userPickerState: MutableStateFlow) { BoxWithConstraints { val call = remember { chatModel.activeCall} .value val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted @@ -262,7 +251,7 @@ fun AndroidScreen(settingsState: SettingsViewState) { } .padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp) ) { - StartPartOfScreen(settingsState) + StartPartOfScreen(userPickerState) } val scope = rememberCoroutineScope() val onComposed: suspend (chatId: String?) -> Unit = { chatId -> @@ -318,15 +307,15 @@ fun AndroidScreen(settingsState: SettingsViewState) { } @Composable -fun StartPartOfScreen(settingsState: SettingsViewState) { +fun StartPartOfScreen(userPickerState: MutableStateFlow) { if (chatModel.setDeliveryReceipts.value) { SetDeliveryReceiptsView(chatModel) } else { val stopped = chatModel.chatRunning.value == false if (chatModel.sharedContent.value == null) - ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped) + ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped) else - ShareListView(chatModel, settingsState, stopped) + ShareListView(chatModel, stopped) } } @@ -367,49 +356,41 @@ fun EndPartOfScreen() { } @Composable -fun DesktopScreen(settingsState: SettingsViewState) { - Box { - // 56.dp is a size of unused space of settings drawer - Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) { - StartPartOfScreen(settingsState) - } - Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { - ModalManager.start.showInView() - SwitchingUsersView() - } - Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) { - Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { - CenterPartOfScreen() - } - if (ModalManager.end.hasModalsOpen()) { - VerticalDivider() - } - Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) { - EndPartOfScreen() - } - } - val (userPickerState, scaffoldState ) = settingsState - val scope = rememberCoroutineScope() - if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) { - Box( - Modifier - .fillMaxSize() - .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - ModalManager.start.closeModals() - scope.launch { settingsState.scaffoldState.drawerState.close() } - }) - ) - } - VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) +fun DesktopScreen(userPickerState: MutableStateFlow) { + Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { + StartPartOfScreen(userPickerState) tryOrShowError("UserPicker", error = {}) { - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE - } + UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA) } - ModalManager.fullscreen.showInView() } + Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { + ModalManager.start.showInView() + SwitchingUsersView() + } + Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) { + Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { + CenterPartOfScreen() + } + if (ModalManager.end.hasModalsOpen()) { + VerticalDivider() + } + Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) { + EndPartOfScreen() + } + } + if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) { + Box( + Modifier + .fillMaxSize() + .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { + ModalManager.start.closeModals() + userPickerState.value = AnimatedViewState.HIDING + }) + ) + } + VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) + ModalManager.fullscreen.showInView() } @Composable 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 8f3b29cb6a..5671322d17 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 @@ -411,7 +411,9 @@ object ChatModel { // remove from current chat if (chatId.value == cInfo.id) { chatItems.removeAll { - val remove = it.id == cItem.id + // We delete taking into account meta.createdAt to make sure we will not be in situation when two items with the same id will be deleted + // (it can happen if already deleted chat item in backend still in the list and new one came with the same (re-used) chat item id) + val remove = it.id == cItem.id && it.meta.createdAt == cItem.meta.createdAt if (remove) { AudioPlayer.stop(it) } remove } 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 e7a79bef0e..c9de86c86a 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 @@ -1480,6 +1480,13 @@ object ChatController { return false } + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List): Boolean { + val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds)) + if (r is CR.CmdOk) return true + Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") + return false + } + suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) if (r is CR.CmdOk) return true @@ -2967,6 +2974,7 @@ sealed class CC { class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() + class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() @@ -3123,6 +3131,7 @@ sealed class CC { is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" + is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId" + @@ -3266,6 +3275,7 @@ sealed class CC { is ApiCallStatus -> "apiCallStatus" is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" + is ApiChatItemsRead -> "apiChatItemsRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" is CancelFile -> "cancelFile" @@ -5348,6 +5358,7 @@ abstract class TerminalItem { val date: Instant = Clock.System.now() abstract val label: String abstract val details: String + val createdAtNanos: Long = System.nanoTime() class Cmd(override val id: Long, override val remoteHostId: Long?, val cmd: CC): TerminalItem() { override val label get() = "> ${cmd.cmdString}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 44fcddb54c..5dfa5aa200 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -1,7 +1,6 @@ package chat.simplex.common.platform -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.* import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.* @@ -22,6 +21,7 @@ interface PlatformInterface { fun androidIsBackgroundCallAllowed(): Boolean = true fun androidSetNightModeIfSupported() {} fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {} + fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {} fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {} fun androidPictureInPictureAllowed(): Boolean = true fun androidCallEnded() {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 1afbb0bdcb..e44a174b53 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -109,7 +109,6 @@ fun TerminalLayout( } }, contentColor = LocalContentColor.current, - drawerContentColor = LocalContentColor.current, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Surface( @@ -132,7 +131,7 @@ fun TerminalLog() { } val clipboard = LocalClipboardManager.current LazyColumnWithScrollBar(reverseLayout = true) { - items(reversedTerminalItems) { item -> + items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item -> val rhId = item.remoteHostId val rhIdStr = if (rhId == null) "" else "$rhId " Text( 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 bd67a72af5..9de9860f10 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 @@ -105,6 +105,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null } val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null + val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) } SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) { ChatLayout( remoteHostId = remoteHostId, @@ -142,10 +143,15 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - chatInfo = chatInfo, deleteItems = { canDeleteForAll -> val itemIds = selectedChatItems.value + val questionText = + if (!canDeleteForAll || fullDeleteAllowed || chatInfo is ChatInfo.Local) + generalGetString(MR.strings.delete_messages_cannot_be_undone_warning) + else + generalGetString(MR.strings.delete_messages_mark_deleted_warning) if (itemIds != null) { deleteMessagesAlertDialog( itemIds.sorted(), - generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning), + questionText = questionText, forAll = canDeleteForAll, deleteMessages = { ids, forAll -> deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) { @@ -654,7 +660,6 @@ fun ChatLayout( modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, contentColor = LocalContentColor.current, - drawerContentColor = LocalContentColor.current, backgroundColor = Color.Unspecified ) { contentPadding -> val wallpaperImage = MaterialTheme.wallpaper.type.image @@ -998,7 +1003,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } ) LazyColumnWithScrollBar(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { - itemsIndexed(reversedChatItems, key = { _, item -> item.id }) { i, cItem -> + itemsIndexed(reversedChatItems, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem -> CompositionLocalProvider( // Makes horizontal and vertical scrolling to coexist nicely. // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view 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 81a0de7bb9..a14d227074 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 @@ -40,7 +40,7 @@ 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, onSearchClicked: () -> Unit) { +fun ModalData.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.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId } @@ -249,9 +249,8 @@ fun AddGroupMembersButton( ) } - @Composable -fun GroupChatInfoLayout( +fun ModalData.GroupChatInfoLayout( chat: Chat, groupInfo: GroupInfo, currentUser: User, @@ -272,12 +271,12 @@ fun GroupChatInfoLayout( close: () -> Unit = { ModalManager.closeAllModalsEverywhere()}, onSearchClicked: () -> Unit ) { - val listState = rememberLazyListState() + val listState = remember { appBarHandler.listState } val scope = rememberCoroutineScope() KeyChangeEffect(chat.id) { scope.launch { listState.scrollToItem(0) } } - val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + val searchText = remember { stateGetOrPut("searchText") { TextFieldValue() } } val filteredMembers = remember(members) { derivedStateOf { val s = searchText.value.text.trim().lowercase() @@ -674,7 +673,7 @@ private fun SearchRowView( @Composable fun PreviewGroupChatInfoLayout() { SimpleXTheme { - GroupChatInfoLayout( + ModalData().GroupChatInfoLayout( chat = Chat( remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 29717e3ecf..516e47e7ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -135,7 +135,7 @@ fun ChatItemView( } fun deleteMessageQuestionText(): String { - return if (!sent || fullDeleteAllowed) { + return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) { generalGetString(MR.strings.delete_message_cannot_be_undone_warning) } else { generalGetString(MR.strings.delete_message_mark_deleted_warning) @@ -637,7 +637,7 @@ fun DeleteItemAction( } deleteMessagesAlertDialog( itemIds, - generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning), + generalGetString(MR.strings.delete_messages_cannot_be_undone_warning), forAll = false, deleteMessages = { ids, _ -> deleteMessages(ids) } ) 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 e39adcca3b..3c7f1e781f 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 @@ -186,7 +186,7 @@ fun ErrorChatListItem() { suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { - contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) + contact.activeConn == null && contact.profile.contactLink != null && contact.active -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) else -> openChat(rhId, ChatInfo.Direct(contact), chatModel) } } 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 11b1006f41..54c67674ad 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 @@ -23,7 +23,7 @@ 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.unit.* -import chat.simplex.common.SettingsViewState +import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts @@ -135,7 +135,7 @@ fun ToggleChatListCard() { } @Composable -fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { +fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { @@ -153,18 +153,15 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf VideoPlayerHolder.stopAll() } } - val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } - val scope = rememberCoroutineScope() - val (userPickerState, scaffoldState ) = settingsState Scaffold( topBar = { if (!oneHandUI.value) { - Column(Modifier.padding(end = endPadding)) { + Column { ChatListToolbar( - scaffoldState.drawerState, userPickerState, stopped, + setPerformLA, ) Divider() } @@ -172,33 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf }, bottomBar = { if (oneHandUI.value) { - Column(Modifier.padding(end = endPadding)) { + Column { Divider() ChatListToolbar( - scaffoldState.drawerState, userPickerState, stopped, - ) - } - } - }, - scaffoldState = scaffoldState, - drawerContent = { - tryOrShowError("Settings", error = { ErrorSettingsView() }) { - val handler = remember { AppBarHandler() } - CompositionLocalProvider( - LocalAppBarHandler provides handler - ) { - ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) { - SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) - } + setPerformLA, + ) } } }, contentColor = LocalContentColor.current, - drawerContentColor = LocalContentColor.current, - drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), - drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { FloatingActionButton( @@ -208,7 +189,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } }, Modifier - .padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp) + .padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp) .size(AppBarHeight * fontSizeSqrtMultiplier), elevation = FloatingActionButtonDefaults.elevation( defaultElevation = 0.dp, @@ -224,7 +205,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } ) { - Box(Modifier.padding(it).padding(end = endPadding)) { + Box(Modifier.padding(it)) { Box( modifier = Modifier .fillMaxSize() @@ -252,11 +233,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf UserPicker( chatModel = chatModel, userPickerState = userPickerState, - contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart - ) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE - } + setPerformLA = AppLock::setPerformLA + ) } } } @@ -278,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { +private fun ChatListToolbar(userPickerState: MutableStateFlow, stopped: Boolean, setPerformLA: (Boolean) -> Unit) { val serversSummary: MutableState = remember { mutableStateOf(null) } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val updatingProgress = remember { chatModel.updatingProgress }.value @@ -344,23 +322,22 @@ private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableSt } } } - val scope = rememberCoroutineScope() val clipboard = LocalClipboardManager.current DefaultTopAppBar( navigationButton = { if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { - NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + NavigationButtonMenu { + ModalManager.start.showModalCloseable { close -> + SettingsView(chatModel, setPerformLA, close) + } + } } else { val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { - if (users.size == 1 && chatModel.remoteHosts.isEmpty()) { - scope.launch { drawerState.open() } - } else { userPickerState.value = AnimatedViewState.VISIBLE - } } } }, 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 886b82de7d..b4d0b05584 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 @@ -11,31 +11,24 @@ import androidx.compose.ui.graphics.Color 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.unit.dp -import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.newchat.ActiveProfilePicker import chat.simplex.res.MR -import kotlinx.coroutines.flow.MutableStateFlow @Composable -fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { +fun ShareListView(chatModel: ChatModel, stopped: Boolean) { var searchInList by rememberSaveable { mutableStateOf("") } - val (userPickerState, scaffoldState) = settingsState - val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val oneHandUI = remember { appPrefs.oneHandUI.state } Scaffold( - Modifier.padding(end = endPadding), contentColor = LocalContentColor.current, - drawerContentColor = LocalContentColor.current, - scaffoldState = scaffoldState, topBar = { if (!oneHandUI.value) { Column { - ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } + ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } Divider() } } @@ -44,7 +37,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe if (oneHandUI.value) { Column { Divider() - ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } + ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } } } @@ -92,21 +85,6 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe } } } - if (appPlatform.isAndroid) { - tryOrShowError("UserPicker", error = {}) { - UserPicker( - chatModel, - userPickerState, - showSettings = false, - showCancel = true, - contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart, - cancelClicked = { - chatModel.sharedContent.value = null - userPickerState.value = AnimatedViewState.GONE - } - ) - } - } } private fun hasSimplexLink(msg: String): Boolean { @@ -122,7 +100,7 @@ private fun EmptyList() { } @Composable -private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { +private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { var showSearch by rememberSaveable { mutableStateOf(false) } val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } if (showSearch) { @@ -138,7 +116,24 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { - userPickerState.value = AnimatedViewState.VISIBLE + ModalManager.start.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } + }, + content = { + ActiveProfilePicker( + search = search, + rhId = chatModel.remoteHostId, + close = close, + contactConnection = null, + showIncognito = false + ) + } + ) + } } } else -> NavigationButtonBack(onButtonClicked = { 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 e15bc3863e..338690c8e8 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 @@ -1,53 +1,51 @@ package chat.simplex.common.views.chatlist import SectionItemView -import androidx.compose.animation.core.* +import SectionView +import TextIconSpaced import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.TextStyle import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.CreateProfile +import chat.simplex.common.views.localauth.VerticalDivider +import chat.simplex.common.views.newchat.* import chat.simplex.common.views.remote.* -import chat.simplex.common.views.usersettings.doWithAuth +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlin.math.roundToInt @Composable fun UserPicker( chatModel: ChatModel, userPickerState: MutableStateFlow, - showSettings: Boolean = true, - contentAlignment: Alignment = Alignment.TopStart, - showCancel: Boolean = false, - cancelClicked: () -> Unit = {}, - useFromDesktopClicked: () -> Unit = {}, - settingsClicked: () -> Unit = {}, + setPerformLA: (Boolean) -> Unit, ) { - val scope = rememberCoroutineScope() var newChat by remember { mutableStateOf(userPickerState.value) } if (newChat.isVisible()) { BackHandler { @@ -67,18 +65,32 @@ fun UserPicker( .sortedBy { it.hostDeviceName } } } - val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } + + val view = LocalMultiplatformView() LaunchedEffect(Unit) { launch { userPickerState.collect { newChat = it + if (it.isVisible()) { + hideKeyboard(view) + } launch { - animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE } } } } + + LaunchedEffect(Unit) { + launch { + snapshotFlow { ModalManager.start.modalCount.value } + .filter { it > 0 } + .collect { + closePicker(userPickerState) + } + } + } + LaunchedEffect(Unit) { snapshotFlow { newChat.isVisible() } .distinctUntilChanged() @@ -124,110 +136,143 @@ fun UserPicker( } } } - val UsersView: @Composable ColumnScope.() -> Unit = { - users.forEach { u -> - UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) { - userPickerState.value = AnimatedViewState.HIDING - if (!u.user.activeUser) { - withBGApi { - controller.showProgressIfNeeded { - ModalManager.closeAllModalsEverywhere() - chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) - } - } - } - } - Divider(Modifier.requiredHeight(1.dp)) - if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp)) - } - } - val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } - val maxWidth = with(LocalDensity.current) { windowWidth() * density } - Box(Modifier - .fillMaxSize() - .offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) } - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING }) - .padding(bottom = 10.dp, top = 10.dp) - .graphicsLayer { - alpha = animatedFloat.value - translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset - }, - contentAlignment = contentAlignment + + PlatformUserPicker( + modifier = Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth() + .then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier) + .background(MaterialTheme.colors.surface) + .padding(vertical = DEFAULT_PADDING), + pickerState = userPickerState ) { - Column( - Modifier - .widthIn(min = 260.dp) - .width(IntrinsicSize.Min) - .height(IntrinsicSize.Min) - .shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true) - .background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp))) - .clip(RoundedCornerShape(corner = CornerSize(25.dp))) - ) { - val currentRemoteHost = remember { chatModel.currentRemoteHost }.value - Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { - if (remoteHosts.isNotEmpty()) { - if (currentRemoteHost == null && chatModel.localUserCreated.value == true) { - LocalDevicePickerItem(true) { - userPickerState.value = AnimatedViewState.HIDING - switchToLocalDevice() - } - Divider(Modifier.requiredHeight(1.dp)) - } else if (currentRemoteHost != null) { - val connecting = rememberSaveable { mutableStateOf(false) } - RemoteHostPickerItem(currentRemoteHost, - actionButtonClick = { - userPickerState.value = AnimatedViewState.HIDING - stopRemoteHostAndReloadHosts(currentRemoteHost, true) - }) { - userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(currentRemoteHost, connecting) - } - Divider(Modifier.requiredHeight(1.dp)) - } - } + @Composable + fun FirstSection() { + if (remoteHosts.isNotEmpty()) { + val currentRemoteHost = remember { chatModel.currentRemoteHost }.value + val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true - UsersView() - - if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) { - LocalDevicePickerItem(false) { + DevicePickerRow( + localDeviceActive = localDeviceActive, + remoteHosts = remoteHosts, + onRemoteHostClick = { h, connecting -> + userPickerState.value = AnimatedViewState.HIDING + switchToRemoteHost(h, connecting) + }, + onLocalDeviceClick = { userPickerState.value = AnimatedViewState.HIDING switchToLocalDevice() + }, + onRemoteHostActionButtonClick = { h -> + userPickerState.value = AnimatedViewState.HIDING + stopRemoteHostAndReloadHosts(h, true) + } + ) + } + ActiveUserSection( + chatModel = chatModel, + userPickerState = userPickerState, + ) + } + + @Composable + fun SecondSection() { + GlobalSettingsSection( + chatModel = chatModel, + userPickerState = userPickerState, + setPerformLA = setPerformLA, + onUserClicked = { user -> + userPickerState.value = AnimatedViewState.HIDING + if (!user.activeUser) { + withBGApi { + controller.showProgressIfNeeded { + ModalManager.closeAllModalsEverywhere() + chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null) + } + } + } + }, + onShowAllProfilesClicked = { + doWithAuth( + generalGetString(MR.strings.auth_open_chat_profiles), + generalGetString(MR.strings.auth_log_in_using_credential) + ) { + ModalManager.start.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + val profileHidden = rememberSaveable { mutableStateOf(false) } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } + }, + content = { UserProfilesView(chatModel, search, profileHidden) }) + } } - Divider(Modifier.requiredHeight(1.dp)) } - remoteHosts.filter { !it.activeHost }.forEach { h -> - val connecting = rememberSaveable { mutableStateOf(false) } - RemoteHostPickerItem(h, - actionButtonClick = { - userPickerState.value = AnimatedViewState.HIDING - stopRemoteHostAndReloadHosts(h, false) - }) { - userPickerState.value = AnimatedViewState.HIDING - switchToRemoteHost(h, connecting) - } - Divider(Modifier.requiredHeight(1.dp)) + ) + } + + if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) { + Column { + FirstSection() + Divider(Modifier.padding(DEFAULT_PADDING)) + SecondSection() + } + } else { + Row { + Box(Modifier.weight(1f)) { + FirstSection() + } + VerticalDivider() + Box(Modifier.weight(1f)) { + SecondSection() } } - if (appPlatform.isAndroid) { - UseFromDesktopPickerItem { - ModalManager.start.showCustomModal { close -> - ConnectDesktopView(close) - } - userPickerState.value = AnimatedViewState.GONE - } - Divider(Modifier.requiredHeight(1.dp)) - } else { - if (remoteHosts.isEmpty()) { - LinkAMobilePickerItem { - ModalManager.start.showModal { - ConnectMobileView() - } - userPickerState.value = AnimatedViewState.GONE - } - Divider(Modifier.requiredHeight(1.dp)) - } - if (chatModel.desktopNoUserNoRemote) { - CreateInitialProfile { + } + } +} + +@Composable +private fun ActiveUserSection( + chatModel: ChatModel, + userPickerState: MutableStateFlow, +) { + val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView -> + { + ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } + } + } + val currentUser = remember { chatModel.currentUser }.value + val stopped = chatModel.chatRunning.value == false + + if (currentUser != null) { + SectionView { + SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { + ProfilePreview(currentUser.profile, stopped = stopped) + } + UserPickerOptionRow( + painterResource(MR.images.ic_qr_code), + if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_public_contact_address) else generalGetString(MR.strings.create_public_contact_address), + showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped + ) + UserPickerOptionRow( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + showCustomModal { m, close -> + PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) + }() + }), + disabled = stopped + ) + } + } else { + SectionView { + if (chatModel.desktopNoUserNoRemote) { + UserPickerOptionRow( + painterResource(MR.images.ic_manage_accounts), + generalGetString(MR.strings.create_chat_profile), + { doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close -> LaunchedEffect(Unit) { @@ -237,15 +282,76 @@ fun UserPicker( } } } - Divider(Modifier.requiredHeight(1.dp)) + ) + } + } + } +} + +@Composable +private fun GlobalSettingsSection( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit, + onUserClicked: (user: User) -> Unit, + onShowAllProfilesClicked: () -> Unit +) { + val stopped = chatModel.chatRunning.value == false + val users by remember { + derivedStateOf { + chatModel.users + .filter { u -> !u.user.hidden && !u.user.activeUser } + } + } + + SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) { + UserPickerInactiveUsersSection( + users = users, + onShowAllProfilesClicked = onShowAllProfilesClicked, + onUserClicked = onUserClicked, + stopped = stopped + ) + + if (appPlatform.isAndroid) { + val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) + + UserPickerOptionRow( + painterResource(MR.images.ic_desktop), + text, + click = { + ModalManager.start.showCustomModal { close -> + ConnectDesktopView(close) + } } - } - if (showSettings) { - SettingsPickerItem(settingsClicked) - } - if (showCancel) { - CancelPickerItem(cancelClicked) - } + ) + } else { + UserPickerOptionRow( + icon = painterResource(MR.images.ic_smartphone_300), + text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), + click = { + userPickerState.value = AnimatedViewState.HIDING + ModalManager.start.showModal { + ConnectMobileView() + } + }, + disabled = stopped + ) + } + + SectionItemView( + click = { + ModalManager.start.showModalCloseable { close -> + SettingsView(chatModel, setPerformLA, close) + } + }, + padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp) + ) { + val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current) + Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary) + TextIconSpaced() + Text(text, color = Color.Unspecified) + Spacer(Modifier.weight(1f)) + ColorModeSwitcher() } } } @@ -296,7 +402,7 @@ fun UserProfilePickerItem( } } else if (!u.showNtfs) { Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) - } else { + } else { Box(Modifier.size(20.dp)) } } @@ -325,136 +431,157 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr } @Composable -fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) { - Row( - Modifier - .fillMaxWidth() - .background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) - .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ) - .onRightClick { onLongClick() } - .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - RemoteHostRow(h) - if (h.sessionState is RemoteHostSessionState.Connected) { - HostDisconnectButton(actionButtonClick) - } else { - Box(Modifier.size(20.dp)) +fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) { + SectionItemView(click, disabled = disabled, extraPadding = true) { + Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary) + TextIconSpaced() + Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified) + } +} + +@Composable +fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) { + Box { + IconButton( + onClick = { onClick(userInfo.user) }, + enabled = !stopped + ) { + Box { + ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant) + + if (userInfo.unreadCount > 0) { + unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs) + } + } } } } @Composable -fun RemoteHostRow(h: RemoteHostInfo) { - Row( - Modifier - .widthIn(max = windowWidth() * 0.7f) - .padding(start = 17.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Text( - h.hostDeviceName, - modifier = Modifier.padding(start = 26.dp, end = 8.dp), - color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor, - fontSize = 14.sp, - ) - } -} - -@Composable -fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) { +private fun DevicePickerRow( + localDeviceActive: Boolean, + remoteHosts: List, + onLocalDeviceClick: () -> Unit, + onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState) -> Unit, + onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit, +) { Row( Modifier .fillMaxWidth() - .background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) - .combinedClickable( - onClick = if (active) {{}} else onClick, - onLongClick = onLongClick, - interactionSource = remember { MutableInteractionSource() }, - indication = if (!active) LocalIndication.current else null - ) - .onRightClick { onLongClick() } - .padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - LocalDeviceRow(active) - Box(Modifier.size(20.dp)) + val activeHost = remoteHosts.firstOrNull { h -> h.activeHost } + + if (activeHost != null) { + val connecting = rememberSaveable { mutableStateOf(false) } + + DevicePill( + active = true, + icon = painterResource(MR.images.ic_smartphone_300), + text = activeHost.hostDeviceName, + actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected, + onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) } + ) { + onRemoteHostClick(activeHost, connecting) + } + } + + DevicePill( + active = localDeviceActive, + icon = painterResource(MR.images.ic_desktop), + text = stringResource(MR.strings.this_device), + actionButtonVisible = false + ) { + onLocalDeviceClick() + } + + remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h -> + val connecting = rememberSaveable { mutableStateOf(false) } + + DevicePill( + active = h.activeHost, + icon = painterResource(MR.images.ic_smartphone_300), + text = h.hostDeviceName, + actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected, + onActionButtonClick = { onRemoteHostActionButtonClick(h) } + ) { + onRemoteHostClick(h, connecting) + } + } } } @Composable -fun LocalDeviceRow(active: Boolean) { +expect fun UserPickerInactiveUsersSection( + users: List, + stopped: Boolean, + onShowAllProfilesClicked: () -> Unit, + onUserClicked: (user: User) -> Unit, +) + +@Composable +expect fun PlatformUserPicker( + modifier: Modifier, + pickerState: MutableStateFlow, + content: @Composable () -> Unit +) + +@Composable +fun DevicePill( + active: Boolean, + icon: Painter, + text: String, + actionButtonVisible: Boolean, + onActionButtonClick: (() -> Unit)? = null, + onClick: () -> Unit) { Row( Modifier - .widthIn(max = windowWidth() * 0.7f) - .padding(start = 17.dp, end = DEFAULT_PADDING), + .clip(RoundedCornerShape(8.dp)) + .border( + BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant), + shape = RoundedCornerShape(8.dp) + ) + .background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent) + .clickable( + enabled = !active, + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ), verticalAlignment = Alignment.CenterVertically ) { - Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Text( - stringResource(MR.strings.this_device), - modifier = Modifier.padding(start = 26.dp, end = 8.dp), - color = if (active) MaterialTheme.colors.onBackground else MenuTextColor, - fontSize = 14.sp, - ) - } -} - -@Composable -private fun UseFromDesktopPickerItem(onClick: () -> Unit) { - SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { - val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) - Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text(text, color = MenuTextColor) - } -} - -@Composable -private fun LinkAMobilePickerItem(onClick: () -> Unit) { - SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { - val text = generalGetString(MR.strings.link_a_mobile) - Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text(text, color = MenuTextColor) - } -} - -@Composable -private fun CreateInitialProfile(onClick: () -> Unit) { - SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { - val text = generalGetString(MR.strings.create_chat_profile) - Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text(text, color = MenuTextColor) - } -} - -@Composable -private fun SettingsPickerItem(onClick: () -> Unit) { - SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { - val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current) - Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text(text, color = MenuTextColor) - } -} - -@Composable -private fun CancelPickerItem(onClick: () -> Unit) { - SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { - val text = generalGetString(MR.strings.cancel_verb) - Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) - Text(text, color = MenuTextColor) + Row( + Modifier.padding(horizontal = 6.dp, vertical = 4.dp) + ) { + Icon( + icon, + text, + Modifier.size(16.dp * fontSizeSqrtMultiplier), + tint = MaterialTheme.colors.onSurface + ) + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier)) + Text( + text, + color = MaterialTheme.colors.onSurface, + fontSize = 12.sp, + ) + if (onActionButtonClick != null && actionButtonVisible) { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier)) + IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) { + Icon( + painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi), + null, + Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource), + tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground + ) + } + } + } } } @@ -472,6 +599,29 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) { } } +@Composable +private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) { + Text( + if (unreadCount > 0) unreadCountStr(unreadCount) else "", + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 2.sp.toDp()) + .align(Alignment.TopEnd) + ) +} + + +private suspend fun closePicker(userPickerState: MutableStateFlow) { + delay(500) + userPickerState.value = AnimatedViewState.HIDING +} + private fun switchToLocalDevice() { withBGApi { chatController.switchUIRemoteHost(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 6a400295ed..4fdbd97d23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -7,3 +7,5 @@ fun chatListAnimationSpec() = tween(durationMillis = 250, easing = FastOu fun newChatSheetAnimSpec() = tween(256, 0, LinearEasing) fun audioProgressBarAnimationSpec() = tween(durationMillis = 30, easing = LinearEasing) + +fun userPickerAnimSpec() = tween(256, 0, FastOutSlowInEasing) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt index 8921685cd6..2941b748c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt @@ -356,7 +356,8 @@ sealed class WallpaperType { private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, size: Size, density: Float, layoutDirection: LayoutDirection): ImageBitmap { val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low val drawScope = CanvasDrawScope() - val bitmap = ImageBitmap(size.width.toInt(), size.height.toInt()) + // Don't allow to make zero size because it crashes the app when reducing a size of a window on desktop + val bitmap = ImageBitmap(size.width.toInt().coerceAtLeast(1), size.height.toInt().coerceAtLeast(1)) val canvas = Canvas(bitmap) drawScope.draw( density = Density(density), 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 90f8299404..104c05309c 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 @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.DevicePill import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue @@ -157,9 +158,13 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection? @Composable private fun HostDeviceTitle(hostDevice: Pair, extraPadding: Boolean = false) { Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) - Spacer(Modifier.width(10.dp)) - Text(hostDevice.second, color = MaterialTheme.colors.secondary) + DevicePill( + active = true, + onClick = {}, + actionButtonVisible = false, + icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), + text = hostDevice.second + ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index c6d5d6ad16..60dceab4ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -71,12 +71,6 @@ fun SearchTextField( } } - DisposableEffect(Unit) { - onDispose { - if (searchText.value.text.isNotEmpty()) onValueChange("") - } - } - val colors = TextFieldDefaults.textFieldColors( backgroundColor = Color.Unspecified, textColor = MaterialTheme.colors.onBackground, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 7512cf872e..7b504116cc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -588,9 +588,11 @@ fun KeyChangeEffect( var anyChange by remember { mutableStateOf(false) } LaunchedEffect(key1) { if (anyChange || key1 != prevKey) { - block(prevKey) + val prev = prevKey prevKey = key1 anyChange = true + // Call it as the last statement because the coroutine can be cancelled earlier + block(prev) } } } @@ -610,8 +612,8 @@ fun KeyChangeEffect( var anyChange by remember { mutableStateOf(false) } LaunchedEffect(key1, key2) { if (anyChange || key1 != initialKey || key2 != initialKey2) { - block() anyChange = true + block() } } } @@ -633,8 +635,8 @@ fun KeyChangeEffect( var anyChange by remember { mutableStateOf(false) } LaunchedEffect(key1, key2, key3) { if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) { - block() anyChange = true + block() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index dbb805971e..a71503e315 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -349,7 +349,15 @@ private fun MutableState.LinkShownView(fileId: Long, link: S text = stringResource(MR.strings.migrate_from_device_finalize_migration), textColor = MaterialTheme.colors.primary, click = { - finishMigration(fileId, ctrl) + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.migrate_from_device_remove_archive_question), + text = generalGetString(MR.strings.migrate_from_device_uploaded_archive_will_be_removed), + confirmText = generalGetString(MR.strings.continue_to_next_step), + destructive = true, + onConfirm = { + finishMigration(fileId, ctrl) + } + ) } ) {} SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 4fa36b06f0..8312c213ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -199,10 +199,8 @@ private fun MutableState.PasteOrScanLinkView() { SectionSpacer() } - if (appPlatform.isDesktop || appPreferences.developerTools.get()) { - SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { - PasteLinkView() - } + SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { + PasteLinkView() } } @@ -561,7 +559,7 @@ private fun MutableState.startDownloading( ) state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) } else { - Log.d(TAG, "unsupported error: ${msg.responseType}") + Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}") } } else -> Log.d(TAG, "unsupported event: ${msg.responseType}") 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 99ba7be8d2..2a6c8838e4 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 @@ -36,11 +36,13 @@ 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.cancel import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import java.net.URI @Composable -fun NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { +fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { val oneHandUI = remember { appPrefs.oneHandUI.state } val keyboardState by getKeyboardState() val showToolbarInOneHandUI = remember { derivedStateOf { keyboardState == KeyboardState.Closed && oneHandUI.value } } @@ -97,7 +99,7 @@ fun chatContactType(chat: Chat): ContactType { val contact = cInfo.contact when { - contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD + contact.activeConn == null && contact.profile.contactLink != null && contact.active -> ContactType.CARD contact.chatDeleted -> ContactType.CHAT_DELETED contact.contactStatus == ContactStatus.Active -> ContactType.RECENT else -> ContactType.UNLISTED @@ -111,10 +113,8 @@ private fun filterContactTypes(c: List, contactTypes: List): return c.filter { chat -> contactTypes.contains(chatContactType(chat)) } } -private var lazyListState = 0 to 0 - @Composable -private fun NewChatSheetLayout( +private fun ModalData.NewChatSheetLayout( rh: RemoteHostInfo?, addContact: () -> Unit, scanPaste: () -> Unit, @@ -122,7 +122,23 @@ private fun NewChatSheetLayout( close: () -> Unit, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } - val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + val listState = remember { appBarHandler.listState } + // This is workaround of an issue when position of a list is not restored (when going back to that screen) when a header exists. + // Upon returning back, this code returns correct index and position if number of items is the same + LaunchedEffect(Unit) { + val prevIndex = listState.firstVisibleItemIndex + val prevOffset = listState.firstVisibleItemScrollOffset + val total = listState.layoutInfo.totalItemsCount + if (prevIndex == 0 && prevOffset == 0) return@LaunchedEffect + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .filter { it == 0 to 0 } + .collect { + if (total <= listState.layoutInfo.totalItemsCount) { + listState.scrollToItem(prevIndex, prevOffset) + } + cancel() + } + } val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } @@ -536,7 +552,7 @@ private fun contactTypesSearchTargets(baseContactTypes: List, searc } @Composable -private fun DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats: () -> Unit, close: () -> Unit) { +private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats: () -> Unit, close: () -> Unit) { val oneHandUI = remember { appPrefs.oneHandUI.state } val keyboardState by getKeyboardState() val showToolbarInOneHandUI = remember { derivedStateOf { keyboardState == KeyboardState.Closed && oneHandUI.value } } @@ -558,7 +574,7 @@ private fun DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats: () -> Un } } ) { contentPadding -> - val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + val listState = remember { appBarHandler.listState } val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf(null) } @@ -723,6 +739,6 @@ fun ActionButton( @Composable private fun PreviewNewChatSheet() { SimpleXTheme { - NewChatSheet(rh = null, close = {}) + ModalData().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 544f5f72bb..a05de0e8b3 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 @@ -2,7 +2,6 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionItemView -import SectionSpacer import SectionTextFooter import SectionView import TextIconSpaced @@ -267,24 +266,13 @@ private fun ProfilePickerOption( ) } -private fun filteredProfiles(users: List, searchTextOrPassword: String): List { - val s = searchTextOrPassword.trim() - val lower = s.lowercase() - return users.filter { u -> - if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) { - true - } else { - correctPassword(u, s) - } - } -} - @Composable -private fun ActiveProfilePicker( +fun ActiveProfilePicker( search: MutableState, contactConnection: PendingContactConnection?, close: () -> Unit, - rhId: Long? + rhId: Long?, + showIncognito: Boolean = true ) { val switchingProfile = remember { mutableStateOf(false) } val incognito = remember { @@ -292,11 +280,9 @@ private fun ActiveProfilePicker( } val selectedProfile by remember { chatModel.currentUser } val searchTextOrPassword = rememberSaveable { search } - val profiles = remember { - chatModel.users.map { it.user }.sortedBy { !it.activeUser } - } - val filteredProfiles by remember { - derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) } + // Intentionally don't use derivedStateOf in order to NOT change an order after user was selected + val filteredProfiles = remember(searchTextOrPassword.value) { + filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value) } var progressByTimeout by rememberSaveable { mutableStateOf(false) } @@ -322,32 +308,38 @@ private fun ActiveProfilePicker( switchingProfile.value = true withApi { try { + var updatedConn: PendingContactConnection? = null; + if (contactConnection != null) { - val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) - if (conn != null) { + updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) + if (updatedConn != null) { withChats { - updateContactConnection(rhId, conn) - updateShownConnection(conn) + updateContactConnection(rhId, updatedConn) + updateShownConnection(updatedConn) } - controller.changeActiveUser_( - rhId = user.remoteHostId, - toUserId = user.userId, - viewPwd = if (user.hidden) searchTextOrPassword.value else null - ) - - if (chatModel.currentUser.value?.userId != user.userId) { - AlertManager.shared.showAlertMsg(generalGetString( - MR.strings.switching_profile_error_title), - String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName) - ) - } - - withChats { - updateContactConnection(user.remoteHostId, conn) - } - close.invoke() } } + + controller.changeActiveUser_( + rhId = user.remoteHostId, + toUserId = user.userId, + viewPwd = if (user.hidden) searchTextOrPassword.value else null + ) + + if (chatModel.currentUser.value?.userId != user.userId) { + AlertManager.shared.showAlertMsg(generalGetString( + MR.strings.switching_profile_error_title), + String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName) + ) + } + + if (updatedConn != null) { + withChats { + updateContactConnection(user.remoteHostId, updatedConn) + } + } + + close() } finally { switchingProfile.value = false } @@ -364,24 +356,21 @@ private fun ActiveProfilePicker( title = stringResource(MR.strings.incognito), selected = incognito, onSelected = { - if (!incognito) { - switchingProfile.value = true - withApi { - try { - if (contactConnection != null) { - val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) + if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption - if (conn != null) { - withChats { - updateContactConnection(rhId, conn) - updateShownConnection(conn) - } - close.invoke() - } + switchingProfile.value = true + withApi { + try { + val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) + if (conn != null) { + withChats { + updateContactConnection(rhId, conn) + updateShownConnection(conn) } - } finally { - switchingProfile.value = false + close() } + } finally { + switchingProfile.value = false } } }, @@ -413,20 +402,18 @@ private fun ActiveProfilePicker( if (activeProfile != null) { val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId } - - if (incognito) { - item { - IncognitoUserOption() - } - item { - ProfilePickerUserOption(activeProfile) - } - } else { - item { - ProfilePickerUserOption(activeProfile) - } - item { - IncognitoUserOption() + item { + when { + !showIncognito -> + ProfilePickerUserOption(activeProfile) + incognito -> { + IncognitoUserOption() + ProfilePickerUserOption(activeProfile) + } + else -> { + ProfilePickerUserOption(activeProfile) + IncognitoUserOption() + } } } @@ -434,8 +421,10 @@ private fun ActiveProfilePicker( ProfilePickerUserOption(p) } } else { - item { - IncognitoUserOption() + if (showIncognito) { + item { + IncognitoUserOption() + } } itemsIndexed(filteredProfiles) { _, p -> ProfilePickerUserOption(p) @@ -641,6 +630,18 @@ fun LinkTextView(link: String, share: Boolean) { } } +private fun filteredProfiles(users: List, searchTextOrPassword: String): List { + val s = searchTextOrPassword.trim() + val lower = s.lowercase() + return users.filter { u -> + if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) { + true + } else { + correctPassword(u, s) + } + } +} + private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean { if (text != null && strIsSimplexLink(text)) { connect(rhId, text, close) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 3747ae047a..bef837ba94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -9,6 +9,7 @@ import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.MaterialTheme.colors @@ -606,6 +607,39 @@ object AppearanceScope { } } + @Composable + fun ColorModeSwitcher() { + val currentTheme by CurrentColors.collectAsState() + val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) { + if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + } else { + currentTheme.base.mode + } + + val onLongClick = { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) + showToast(generalGetString(MR.strings.system_mode_toast)) + + saveThemeToDatabase(null) + } + Box( + modifier = Modifier + .clip(CircleShape) + .combinedClickable( + onClick = { + ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName) + saveThemeToDatabase(null) + }, + onLongClick = onLongClick + ) + .onRightClick(onLongClick) + .size(44.dp), + contentAlignment = Alignment.Center + ) { + Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary) + } + } + private var updateBackendJob: Job = Job() private fun saveThemeToDatabase(themeUserDestination: Pair?) { val remoteHostId = chatModel.remoteHostId() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 2f6c0395ec..0229e7da2a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -73,10 +73,7 @@ private fun SetDeliveryReceiptsLayout( skip: () -> Unit, userCount: Int, ) { - // This view located in the left panel which means it has to have a padding from right side in order - // to see scroll bar. And this padding should be applied to upper element, not scrollable column modifier - val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp - Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) { + Box(Modifier.padding(top = DEFAULT_PADDING)) { ColumnWithScrollBar( Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 3e1522b288..bb4a0b61b0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -31,13 +31,11 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView -import chat.simplex.common.views.remote.ConnectDesktopView -import chat.simplex.common.views.remote.ConnectMobileView import chat.simplex.res.MR import kotlinx.coroutines.* @Composable -fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) { +fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false SettingsLayout( @@ -71,10 +69,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt } }, withAuth = ::doWithAuth, - drawerState = drawerState, ) KeyChangeEffect(chatModel.updatingProgress.value != null) { - drawerState.close() + close() } } @@ -96,18 +93,11 @@ fun SettingsLayout( showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showVersion: () -> Unit, withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, - drawerState: DrawerState, ) { val scope = rememberCoroutineScope() - val closeSettings: () -> Unit = { scope.launch { drawerState.close() } } val view = LocalMultiplatformView() - if (drawerState.isOpen) { - BackHandler { - closeSettings() - } - LaunchedEffect(Unit) { - hideKeyboard(view) - } + LaunchedEffect(Unit) { + hideKeyboard(view) } val theme = CurrentColors.collectAsState() val uriHandler = LocalUriHandler.current @@ -118,46 +108,22 @@ fun SettingsLayout( ) { AppBarTitle(stringResource(MR.strings.your_settings)) - SectionView(stringResource(MR.strings.settings_section_title_you)) { - val profileHidden = rememberSaveable { mutableStateOf(false) } - if (profile != null) { - SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(profile, stopped = stopped) - } - SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped) - ChatPreferencesItem(showCustomModal, stopped = stopped) - } else if (chatModel.localUserCreated.value == false) { - SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { - withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { - ModalManager.center.showModalCloseable { close -> - LaunchedEffect(Unit) { - closeSettings() - } - CreateProfile(chatModel, close) - } - } - }, disabled = stopped) - } - if (appPlatform.isDesktop) { - SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped) - } else { - SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped) - } - SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped) - } - SectionDividerSpaced() - SectionView(stringResource(MR.strings.settings_section_title_settings)) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) - DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } SectionDividerSpaced() + SectionView(stringResource(MR.strings.settings_section_title_chat_database)) { + DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped) + } + + SectionDividerSpaced() + SectionView(stringResource(MR.strings.settings_section_title_help)) { SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped) @@ -535,7 +501,6 @@ fun PreviewSettingsLayout() { showCustomModal = { {} }, showVersion = {}, withAuth = { _, _, _ -> }, - drawerState = DrawerState(DrawerValue.Closed), ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index b357272e16..1f546fb863 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -174,7 +174,7 @@ private fun UserAddressLayout( saveAas: (AutoAcceptState, MutableState) -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + AppBarTitle(stringResource(MR.strings.public_address), hostDevice(user?.remoteHostId)) Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, @@ -230,7 +230,7 @@ private fun UserAddressLayout( private fun CreateAddressButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_qr_code), - stringResource(MR.strings.create_simplex_address), + stringResource(MR.strings.create_public_address), onClick, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index e3636ec9c5..10acaffe1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -34,6 +34,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { KeyChangeEffect(u.value?.remoteHostId, u.value?.userId) { close() } + if (user != null) { var profile by remember { mutableStateOf(user.profile.toProfile()) } UserProfileLayout( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index d4334dfed2..dcf8351166 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -36,11 +36,10 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @Composable -fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: MutableState, drawerState: DrawerState) { +fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: MutableState) { val searchTextOrPassword = rememberSaveable { search } val users by remember { derivedStateOf { m.users.map { it.user } } } val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } } - val scope = rememberCoroutineScope() UserProfilesLayout( users = users, filteredUsers = filteredUsers, @@ -51,12 +50,6 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: addUser = { ModalManager.center.showModalCloseable { close -> CreateProfile(m, close) - if (appPlatform.isDesktop) { - // Hide settings to allow clicks to pass through to CreateProfile view - DisposableEffectOnGone(always = { scope.launch { drawerState.close() } }) { - // Show settings again to allow intercept clicks to close modals after profile creation finishes - scope.launch(NonCancellable) { drawerState.open() } } - } } }, activateUser = { user -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 7fb812ade9..8f4b0158a7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -22,8 +22,8 @@ ينطبق هذا الإعداد على الرسائل الموجودة في ملف تعريف الدردشة الحالي الخاص بك منصة الرسائل والتطبيقات تحمي خصوصيتك وأمنك. يتم مشاركة ملف التعريف مع جهات اتصالك فقط. - سيتم تغيير الدور إلى \"%s\". سيتم إبلاغ كل فرد في المجموعة. - سيتم تغيير الدور إلى \"%s\". سيستلم العضو دعوة جديدة. + سيتم تغيير الدور إلى "%s". سيتم إبلاغ كل فرد في المجموعة. + سيتم تغيير الدور إلى "%s". سيستلم العضو دعوة جديدة. خوادم الاتصالات الجديدة لملف تعريف الدردشة الحالي الخاص بك سيتم تغيير عنوان الاستلام إلى خادم مختلف. سيتم إكمال تغيير العنوان بعد اتصال المرسل بالإنترنت. هذا الرابط ليس رابط اتصال صالح! @@ -767,7 +767,7 @@ لم تٌحدد جهات اتصال يمكّن للمشرف الآن: \n- حذف رسائل الأعضاء. -\n- تعطيل الأعضاء (دور \"المراقب\") +\n- تعطيل الأعضاء (دور "المراقب") خدمة الإشعار غير مفعّل` مفعل @@ -1076,7 +1076,7 @@ إيقاف الملف التوقف عن إرسال الملف؟ عنوان SimpleX - استخدم مضيفي .onion إلى \"لا\" إذا كان وكيل SOCKS لا يدعمها.]]> + استخدم مضيفي .onion إلى "لا" إذا كان وكيل SOCKS لا يدعمها.]]> مشاركة مع جهات الاتصال إيقاف التشغيل؟ إعدادات وكيل SOCKS @@ -1406,7 +1406,7 @@ سطح المكتب متصل اتصل تلقائيًا عنوان سطح المكتب - وضّع علامة \"محذوفة\" على %d من الرسائل + وضّع علامة "محذوفة" على %d من الرسائل اكتشف عبر الشبكة المحلية اتصل عبر الرابط؟ اتصل بنفسك؟ 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 9e9beef86b..704c6533ae 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -318,6 +318,7 @@ Delete message? Delete %d messages? Message will be deleted - this cannot be undone! + Messages will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. Messages will be marked for deletion. The recipient(s) will be able to reveal these messages. Delete member message? @@ -700,6 +701,10 @@ %s is verified %s is not verified + + Create public address + Your public address + Your settings Your SimpleX address @@ -848,6 +853,8 @@ Notifications will stop working until you re-launch the app + Public address + Create public address Create address Delete address? Your contacts will remain connected. @@ -1149,6 +1156,7 @@ YOU SETTINGS + CHAT DATABASE HELP SUPPORT SIMPLEX CHAT APP @@ -1560,8 +1568,8 @@ Change Switch Change group role? - The role will be changed to \"%s\". Everyone in the group will be notified. - The role will be changed to \"%s\". The member will receive a new invitation. + The role will be changed to "%s". Everyone in the group will be notified. + The role will be changed to "%s". The member will receive a new invitation. Connect directly? Сonnection request will be sent to this group member. Error removing member @@ -1713,6 +1721,7 @@ Remove image Font size Zoom + System mode Good afternoon! @@ -1897,7 +1906,7 @@ Audio and video calls Support bluetooth and other improvements. Group moderation - Now admins can:\n- delete members\' messages.\n- disable members (\"observer\" role) + Now admins can:\n- delete members\' messages.\n- disable members ("observer" role) Group welcome message Set the message shown to new members! Further reduced battery usage @@ -2178,6 +2187,8 @@ Creating archive link Cancel migration Finalize migration + Remove archive? + The uploaded database archive will be permanently removed from the servers. Migrate from another device on the new device and scan QR code.]]> Or securely share this file link Delete database from this device diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index d43aee7c8a..54eb0e9034 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -482,7 +482,7 @@ Изпращането на потвърждениe за доставка ще бъде активирано за всички контакти във всички видими чат профили. Изпращане на потвърждениe за доставка Можете да активирате по-късно през Настройки - Можете да ги активирате по-късно през настройките за \"Поверителност и сигурност\" на приложението. + Можете да ги активирате по-късно през настройките за "Поверителност и сигурност" на приложението. Предупреждение: Може да загубите някои данни! Въведи правилна парола. активирано за вас @@ -517,7 +517,7 @@ криптирането е съгласувано за %s Редактирай групов профил Изчезва в: %s - Ролята ще бъде променена на \"%s\". Членът ще получи нова покана. + Ролята ще бъде променена на "%s". Членът ще получи нова покана. директна Предоговори криптирането Въведи име на групата: @@ -918,7 +918,7 @@ Членът ще бъде премахнат от групата - това не може да бъде отменено! няма текст Острани член - Ролята ще бъде променена на \"%s\". Всички в групата ще бъдат уведомени. + Ролята ще бъде променена на "%s". Всички в групата ще бъдат уведомени. Само данни за локален профил Профилни и сървърни връзки Без звук @@ -936,7 +936,7 @@ Защитете чат профилите с парола! Сега администраторите могат: \n- да изтриват съобщения на членове. -\n- да деактивират членове (роля \"наблюдател\") +\n- да деактивират членове (роля "наблюдател") Очаквайте скоро още подобрения! Полски интерфейс Реакции на съобщения @@ -1155,7 +1155,7 @@ Този линк не е валиден линк за връзка! Вашите чат профили Сървърите за нови връзки на текущия ви чат профил - За да покажете скрития профил, въведете пълната парола в полето за търсене на страницата \"Вашите чат профили\". + За да покажете скрития профил, въведете пълната парола в полето за търсене на страницата "Вашите чат профили". Профилът се споделя само с вашите контакти. Това действие не може да бъде отменено - всички получени и изпратени файлове и медия ще бъдат изтрити. Снимките с ниска разделителна способност ще бъдат запазени. Докосни за активиране на профил. @@ -1646,7 +1646,7 @@ Квантово устойчиво криптиране Миграция на данните от приложението Мигрирайте към друго устройство чрез QR код. - Обаждания \"картина в картина\" + Обаждания "картина в картина" По-безопасни групи Администраторите могат да блокират член за всички. Подробности за линка се изтеглят diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 591643a4da..0c4e78a269 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -462,7 +462,7 @@ odešel Vyčistit Přepnout - Role bude změněna na \"%s\". Všichni ve skupině budou informováni. + Role bude změněna na "%s". Všichni ve skupině budou informováni. Chyba při odebrání člena Chyba při ukládání profilu skupiny vteřiny @@ -857,7 +857,7 @@ Role Změnit roli Změnit - Role bude změněna na \"%s\". Člen obdrží novou pozvánku. + Role bude změněna na "%s". Člen obdrží novou pozvánku. Chyba při změně role přímo Odesíláno přes @@ -972,7 +972,7 @@ Další vylepšení již brzy! Nyní mohou správci: \n- mazat zprávy členů. -\n- zakázat členy (role \"pozorovatel\") +\n- zakázat členy (role "pozorovatel") Uložit heslo profilu Ztlumit Chraňte své chat profily heslem! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 64b7f9d8f7..897ea64be8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -792,8 +792,8 @@ Ändern Wechseln Die Mitgliederrolle ändern? - Die Mitgliederrolle wird auf \"%s\" geändert. Alle Mitglieder der Gruppe werden benachrichtigt. - Die Mitgliederrolle wird auf \"%s\" geändert. Das Mitglied wird eine neue Einladung erhalten. + Die Mitgliederrolle wird auf "%s" geändert. Alle Mitglieder der Gruppe werden benachrichtigt. + Die Mitgliederrolle wird auf "%s" geändert. Das Mitglied wird eine neue Einladung erhalten. Fehler beim Entfernen des Mitglieds Fehler beim Ändern der Rolle Gruppe @@ -1074,7 +1074,7 @@ Sie können Anrufe und Benachrichtigungen auch von stummgeschalteten Profilen empfangen, solange diese aktiv sind. Begrüßungsmeldung Sie können ein Benutzerprofil verbergen oder stummschalten – für das Menü gedrückt halten. - Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite \"Ihre Chat-Profile\" ein, um Ihr verborgenes Profil zu sehen. + Geben Sie ein vollständiges Passwort in das Suchfeld auf der Seite "Ihre Chat-Profile" ein, um Ihr verborgenes Profil zu sehen. Migrations-Bestätigung ungültig Aktualisieren und den Chat öffnen Datenbank-Aktualisierungen bestätigen @@ -1151,7 +1151,7 @@ SOCKS-Proxy nutzen SimpleX-Sperrmodus SimpleX-Sperre ist nicht aktiviert! - Verwende .onion-Hosts auf \"Nein\", wenn der SOCKS-Proxy sie nicht unterstützt.]]> + Verwende .onion-Hosts auf "Nein", wenn der SOCKS-Proxy sie nicht unterstützt.]]> Bestätigen System Sie können nicht überprüft werden – bitte versuchen Sie es nochmal. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index bc27b0a29f..9ba8c60f87 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -726,7 +726,7 @@ envío no autorizado Escribe un nombre para el contacto Error desconocido - El rol del miembro cambiará a \"%s\" y se notificará al grupo. + El rol del miembro cambiará a "%s" y se notificará al grupo. La seguridad de SimpleX Chat ha sido auditada por Trail of Bits. Los mensajes enviados se eliminarán una vez transcurrido el tiempo establecido. Mensajes de chat SimpleX @@ -814,7 +814,7 @@ Actualizar contraseña base de datos Pulsa para unirte en modo incógnito Cambiar - El rol del miembro cambiará a \"%s\" y recibirá una invitación nueva. + El rol del miembro cambiará a "%s" y recibirá una invitación nueva. Actualizar ¿Actualizar la configuración de red\? Intentando conectar con el servidor para recibir mensajes de este contacto. @@ -992,7 +992,7 @@ Seguirás recibiendo llamadas y notificaciones de los perfiles silenciados cuando estén activos. Ahora los administradores pueden: \n- eliminar mensajes de los miembros. -\n- desactivar el rol miembro (a rol \"observador\") +\n- desactivar el rol miembro (a rol "observador") Para hacer visible tu perfil oculto, introduce la contraseña completa en el campo de búsqueda del menú Mis perfiles. Actualización de la base de datos Volviendo a versión anterior de la base de datos diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 8200d8a0ff..c3ea7d89f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -757,7 +757,7 @@ Lisää parannuksia on tulossa pian! Nyt järjestelmänvalvojat voivat: \n- poistaa jäsenten viestit. -\n- poista jäsenet käytöstä (\"tarkkailija\" rooli) +\n- poista jäsenet käytöstä ("tarkkailija" rooli) Tallenna ja ilmoita ryhmän jäsenille Lopeta Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty. @@ -1108,8 +1108,8 @@ Viestintä- ja sovellusalusta, joka suojaa yksityisyyttäsi ja tietoturvaasi. videopuhelu %1$s JÄSENET - Rooli muuttuu muotoon \"%s\". Kaikille ryhmän jäsenille ilmoitetaan asiasta. - Rooli muuttuu muotoon \"%s\". Jäsen saa uuden kutsun. + Rooli muuttuu muotoon "%s". Kaikille ryhmän jäsenille ilmoitetaan asiasta. + Rooli muuttuu muotoon "%s". Jäsen saa uuden kutsun. Ääniviestit ovat kiellettyjä tässä keskustelussa. Ääniviestit Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 02487f8ba7..553f70e31a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -972,7 +972,7 @@ Profils de chat cachés Désormais, les administrateurs peuvent : \n- supprimer les messages des membres. -\n- désactiver des membres (rôle \"observateur\") +\n- désactiver des membres (rôle "observateur") Enregistrer le message d\'accueil ? Choisissez un message à l\'attention des nouveaux membres ! Masquer le profil diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg new file mode 100644 index 0000000000..158f4cfab0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg new file mode 100644 index 0000000000..ed5bc12d4a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 34ee1721f0..1bc9f6283f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -822,8 +822,8 @@ Sistema Scadenza connessione TCP Completamente decentralizzato: visibile solo ai membri. - Il ruolo verrà cambiato in \"%s\". Tutti i membri del gruppo riceveranno una notifica. - Il ruolo verrà cambiato in \"%s\". Il membro riceverà un nuovo invito. + Il ruolo verrà cambiato in "%s". Tutti i membri del gruppo riceveranno una notifica. + Il ruolo verrà cambiato in "%s". Il membro riceverà un nuovo invito. Aggiorna Aggiornare le impostazioni di rete\? L\'aggiornamento delle impostazioni riconnetterà il client a tutti i server. @@ -978,11 +978,11 @@ Grazie agli utenti – contribuite via Weblate! Password del profilo nascosta Salva la password del profilo - Per rivelare il tuo profilo nascosto, inserisci una password completa in un campo di ricerca nella pagina \"I tuoi profili di chat\". + Per rivelare il tuo profilo nascosto, inserisci una password completa in un campo di ricerca nella pagina "I tuoi profili di chat". Password per mostrare Ora gli amministratori possono: \n- eliminare i messaggi dei membri. -\n- disattivare i membri (ruolo \"osservatore\") +\n- disattivare i membri (ruolo "osservatore") Nascondi il profilo Conferma password Errore nell\'aggiornamento della privacy dell\'utente diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index e4901dd875..d207057d0c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1015,7 +1015,7 @@ הקישו כדי להצטרף תום זמן חיבור TCP האפליקציה בודקת הודעות חדשות מעת לעת - היא משתמשת בכמה אחוזים מהסוללה ביום. האפליקציה לא משתמשת בהתראות דחיפה - נתונים מהמכשיר שלך לא נשלחים לשרתים. - התפקיד ישתנה ל־\"%s\". כל חברי הקבוצה יקבלו הודעה על כך. + התפקיד ישתנה ל־"%s". כל חברי הקבוצה יקבלו הודעה על כך. כדי להתחבר באמצעות קישור פלטפורמת ההודעות והיישומים המגנה על הפרטיות והאבטחה שלך. המזהה של ההודעה הבאה שגוי (קטן או שווה להודעה הקודמת). @@ -1027,7 +1027,7 @@ תודה שהתקנתם את SimpleX Chat! קישור זה אינו קישור חיבור תקין! צבעי ערכת נושא - התפקיד ישתנה ל־\"%s\". החבר יקבל הזמנה חדשה. + התפקיד ישתנה ל־"%s". החבר יקבל הזמנה חדשה. השרתים לחיבורים חדשים של פרופיל הצ׳אט הנוכחי שלך הפלטפורמה הראשונה ללא כל מזהי משתמש - פרטית בעיצובה. הדור הבא של תקשורת פרטית @@ -1836,7 +1836,7 @@ מערכת שגיאה בהצגת התראה, צור קשר עם המפתחים שגיאה בהתחברות לשרת %1$s, אנא נסה מאוחר יותר - אין עדיין חיבור ישיר, ההודעה תעובר ע\"י מנהל. + אין עדיין חיבור ישיר, ההודעה תעובר ע"י מנהל. חבר לא פעיל שרתי XFTP אחרים הראה אחוזים @@ -1920,11 +1920,11 @@ קבצים אין מידע, נסה לרענן מידע על השרתים - התקבל סה\"כ + התקבל סה"כ התקבלו שגיאות התחבר מחדש שלח הודעות - נשלח בסה\"כ + נשלח בסה"כ שרת SMP שרת XFTP חלש diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index ffd62b4755..639a888bc9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -634,7 +634,7 @@ 메시지 이 설정은 현재 내 프로필의 메시지에 적용되어요. 멤버 - 역할이 \"%s\"(으)로 변경되고, 회원은 새로운 초대를 받게 될 거예요. + 역할이 "%s"(으)로 변경되고, 회원은 새로운 초대를 받게 될 거예요. 이 채팅에서는 메시지 영구 삭제가 허용되지 않았어요. 나가기 큰 파일! @@ -675,11 +675,11 @@ 그룹에서 나갈까요\? 데이터베이스 버전이 앱보다 최신이지만, 다음에 대한 다운 마이그레이션 없음: %s 멤버가 그룹에서 제거되어요. 이 작업은 되돌릴 수 없어요! - 역할이 \"%s\"(으)로 변경되어요. 그룹의 모든 멤버에게 알림이 전송됩니다. + 역할이 "%s"(으)로 변경되어요. 그룹의 모든 멤버에게 알림이 전송됩니다. 기본값으로 재설정 메시지 내용 대화 상대 이름 및 메시지 표시 - 나만 메시지를 영구 삭제할 수 있어요(대화 상대는 \"삭제됨\" 표시만 할 수 있음). + 나만 메시지를 영구 삭제할 수 있어요(대화 상대는 "삭제됨" 표시만 할 수 있음). 이 링크를 보낸 상대에게 프로필이 전송될 거예요. 파일 수신은 아직 지원되지 않아요. 올바른 링크를 사용했는지 확인하거나 상대에게 다른 링크를 보내달라고 말해 주세요 @@ -714,7 +714,7 @@ 대화 상대만 음성 메시지를 보낼 수 있어요. 메시지 영구 삭제 허용되지 않음. 음성 메시지 허용되지 않음. - 상대만 메시지를 영구 삭제할 수 있어요(나는 \"삭제됨\"으로 표시만 할 수 있음). + 상대만 메시지를 영구 삭제할 수 있어요(나는 "삭제됨"으로 표시만 할 수 있음). 그룹 소유자만 음성 메시지를 사용 가능하도록 설정할 수 있어요. 일회성 초대 링크 붙여넣기 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index 6139eb5133..8d8d73b206 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -600,7 +600,7 @@ Nepavyko pakviesti kontakto! Pagal pokalbių profilį (numatytieji nustatymai) arba pagal ryšį (BETA). Naudoja daugiau baterijos! Fono paslauga veikia visada - pranešimai rodomi, kai tik atsiranda žinučių.]]> - Negalima pasiekti \"Keystore\", kad išsaugotumėte duomenų bazės slaptažodį + Negalima pasiekti "Keystore", kad išsaugotumėte duomenų bazės slaptažodį Atšaukti failo peržiūrą Atšaukti vaizdo peržiūrą Duomenų bazės ID: %d @@ -1484,7 +1484,7 @@ Nėra pristatymo informacijos Dabar administratoriai gali: \n- ištrinti narių žinutes. -\n- išjungti narius (\"stebėtojas\" rolė) +\n- išjungti narius ("stebėtojas" rolė) įj. Nebemėgti Nustatymų atnaujinimas perjungs klientą iš naujo prie visų serverių. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index d97cf4b7ad..dc692c1968 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -280,7 +280,7 @@ ആഴ്ചകൾ അനധികൃത അയക്കുക സ്വീകരിക്കുന്ന വിലാസം മാറണോ\? - കര്‍ത്തവ്യം \"%s\" ആയി മാറ്റും. അംഗത്തിന് പുതിയ ക്ഷണം ലഭിക്കും. + കര്‍ത്തവ്യം "%s" ആയി മാറ്റും. അംഗത്തിന് പുതിയ ക്ഷണം ലഭിക്കും. സംവിധാനം പ്രാമാണീകരണം അംഗങ്ങളെ ക്ഷണിക്കുന്നത് ഒഴിവാക്കുക സ്വാഗത സന്ദേശം സംരക്ഷിക്കണോ\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 97b6586844..9a8810de84 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -767,7 +767,7 @@ %1$s LEDEN U kunt een link of een QR-code delen. Iedereen kan lid worden van de groep. U verliest geen leden van de groep als u deze later verwijdert. Wijzig - De rol wordt gewijzigd in \"%s\". Iedereen in de groep wordt op de hoogte gebracht. + De rol wordt gewijzigd in "%s". Iedereen in de groep wordt op de hoogte gebracht. Ontvang via Groep profiel opslaan Volledig gedecentraliseerd – alleen zichtbaar voor leden. @@ -800,7 +800,7 @@ Lid verwijderen Rol Direct bericht sturen - De rol wordt gewijzigd in \"%s\". De gebruiker ontvangt een nieuwe uitnodiging. + De rol wordt gewijzigd in "%s". De gebruiker ontvangt een nieuwe uitnodiging. Verzenden via SERVERS Resetten naar standaardwaarden @@ -973,7 +973,7 @@ Profiel privé maken! Nu kunnen beheerders: \n- berichten van leden verwijderen. -\n- schakel leden uit (\"waarnemer\" rol) +\n- schakel leden uit ("waarnemer" rol) Bescherm je chat profielen met een wachtwoord! Wachtwoord om weer te geven Groep profiel opslaan en bijwerken diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index d0b0ca0d1c..62ff44f6db 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -739,7 +739,7 @@ Przełącz Zmień adres odbioru W pełni zdecentralizowana – widoczna tylko dla członków. - Rola zostanie zmieniona na \"%s\". Członek otrzyma nowe zaproszenie. + Rola zostanie zmieniona na "%s". Członek otrzyma nowe zaproszenie. Wiadomość powitalna Usunąć profil czatu\? Usuń profil czatu dla @@ -944,7 +944,7 @@ Nie udało się załadować czatu Teraz administratorzy mogą: \n- usuwać wiadomości członków. -\n- wyłączyć członków (rola \"obserwatora\") +\n- wyłączyć członków (rola "obserwatora") Z Galerii Obraz Wideo @@ -993,7 +993,7 @@ Niektóre serwery nie przeszły testu: Dziękujemy za zainstalowanie SimpleX Chat! Wiadomość zostanie oznaczona jako zmoderowana dla wszystkich członków. - Rola zostanie zmieniona na \"%s\". Wszyscy w grupie zostaną powiadomieni. + Rola zostanie zmieniona na "%s". Wszyscy w grupie zostaną powiadomieni. Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. Ten link nie jest prawidłowym linkiem połączenia! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 2fa9f85599..3c639cf777 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -695,14 +695,14 @@ não Agora administradores podem: \n- excluir mensagens de membros. -\n- desativar membros (cargo de \"observador\") +\n- desativar membros (cargo de "observador") Moderação do grupo Mensagem de boas-vindas do grupo Desatualizar banco de dados migração diferente no aplicativo/banco de dados: %s / %s Convidar para o grupo Sem contatos para adicionar - O cargo será alterada para \"%s\". Todos no grupo serão notificados. + O cargo será alterada para "%s". Todos no grupo serão notificados. Mutar Somente você pode enviar mensagens de voz. Somente seu contato pode enviar mensagens de voz. @@ -813,7 +813,7 @@ EXPERIMENTAL você alterou o endereço Atualização do banco de dados - O cargo será alterado para \"%s\". O membro receberá um novo convite. + O cargo será alterado para "%s". O membro receberá um novo convite. Somente os proprietários do grupo podem alterar as preferências do grupo. Adicionar mensagem de boas-vindas Mensagem de boas-vindas diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index e652ab27e5..b38348b30f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -631,7 +631,7 @@ ligado Agora os administradores podem: \n- eliminar mensagens de membros. -\n- desativar membros (função de \"observador\") +\n- desativar membros (função de "observador") Ligação do grupo SimpleX Apenas 10 imagens podem ser enviadas ao mesmo tempo Apenas 10 vídeos podem ser enviados ao mesmo tempo diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 8c5adae3e3..2e9725913a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -796,8 +796,8 @@ Поменять Переключить Поменять роль в группе? - Роль будет изменена на \"%s\". Все в группе получат сообщение. - Роль будет изменена на \"%s\". Будет отправлено новое приглашение. + Роль будет изменена на "%s". Все в группе получат сообщение. + Роль будет изменена на "%s". Будет отправлено новое приглашение. Ошибка при удалении члена группы Ошибка при изменении роли Группа @@ -964,7 +964,7 @@ Исчезающие сообщения Отправленные сообщения будут удалены через заданное время. Улучшенная конфигурация серверов - \"Живые\" сообщения + "Живые" сообщения Получатели видят их в то время как Вы их набираете. Проверить безопасность соединения Сравните код безопасности с Вашими контактами. @@ -1065,7 +1065,7 @@ Дополнительные улучшения скоро! Теперь админы могут: \n- удалять сообщения членов. -\n- приостанавливать членов (роль \"наблюдатель\") +\n- приостанавливать членов (роль "наблюдатель") Защитите Ваши профили чата паролем! Раскрыть Поддержка bluetooth и другие улучшения. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index 058f0305a8..03bf9f0f27 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -742,7 +742,7 @@ การปรับปรุงเพิ่มเติมกำลังจะมาเร็ว ๆ นี้! ขณะนี้ผู้ดูแลระบบสามารถ: \n- ลบข้อความของสมาชิก -\n- ปิดการใช้งานสมาชิก (บทบาท \"ผู้สังเกตการณ์\") +\n- ปิดการใช้งานสมาชิก (บทบาท "ผู้สังเกตการณ์") ปฏิกิริยาต่อข้อความ นาที เดือน @@ -1020,7 +1020,7 @@ ขอบคุณผู้ใช้ – มีส่วนร่วมผ่าน Weblate! แพลตฟอร์มการส่งข้อความและแอปพลิเคชันที่ปกป้องความเป็นส่วนตัวและความปลอดภัยของคุณ การส่งข้อความส่วนตัวรุ่นต่อไป - บทบาทจะถูกเปลี่ยนเป็น \"%s\" ทุกคนในกลุ่มจะได้รับแจ้ง + บทบาทจะถูกเปลี่ยนเป็น "%s" ทุกคนในกลุ่มจะได้รับแจ้ง การดำเนินการนี้ไม่สามารถยกเลิกได้ ไฟล์และสื่อที่ได้รับและส่งทั้งหมดจะถูกลบ รูปภาพความละเอียดต่ำจะยังคงอยู่ หากต้องการเปิดเผยโปรไฟล์ที่ซ่อนอยู่ของคุณ ให้ป้อนรหัสผ่านแบบเต็มในช่องค้นหาในหน้าโปรไฟล์แชทของคุณ การแยกการขนส่ง @@ -1198,7 +1198,7 @@ ข้อความต้อนรับ คุณสามารถแชร์ลิงก์หรือคิวอาร์โค้ดได้ ทุกคนจะสามารถเข้าร่วมกลุ่มได้ คุณจะไม่สูญเสียสมาชิกของกลุ่มหากคุณลบในภายหลัง คุณสามารถแบ่งปันที่อยู่นี้กับผู้ติดต่อของคุณเพื่อให้พวกเขาเชื่อมต่อกับ %s - บทบาทจะถูกเปลี่ยนเป็น \"%s\" สมาชิกจะได้รับคำเชิญใหม่ + บทบาทจะถูกเปลี่ยนเป็น "%s" สมาชิกจะได้รับคำเชิญใหม่ ข้อความต้อนรับ โปรไฟล์การแชทของคุณจะถูกส่งไปยังสมาชิกในกลุ่ม อัปเดต diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 5413afa82b..864b5ae3df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -138,14 +138,14 @@ Kaydet ve kişiyi bilgilendir Parolayı, Keystore\'a kaydet. Ses açık - Yetki, \"%s\" olarak değiştirelecek. Gruptaki herkes bilgilendirilecek. + Yetki, "%s" olarak değiştirelecek. Gruptaki herkes bilgilendirilecek. Sesli/görüntülü aramalar yasaktır. Kimlik doğrulama başarısız SimpleX Adresi Mesaj, tüm üyeler için silinecek. Gizliliğinizi ve güvenliğinizi koruyan mesajlaşma ve uygulama platformu. Gizlilik kipi - Yetki, \"%s\" olarak değiştirilecek. Üye, yeni bir davet alacak. + Yetki, "%s" olarak değiştirilecek. Üye, yeni bir davet alacak. Yetki Sesli mesajlara izin verilsin mi? Profil ekle @@ -360,7 +360,7 @@ %d saat Yeni yöneticiler artık: \n- üyelerin mesajlarını silebilir. -\n- üyeleri etkisizleştirebilir (\"gözlemci\" yetkisi verir) +\n- üyeleri etkisizleştirebilir ("gözlemci" yetkisi verir) Konuşmalar Grup bağlantısı oluştur Konuşma isteğini onaylarken hata oluştu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index ee77905611..f6b3b12431 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -726,8 +726,8 @@ Зникає о: %s (поточне) Вилучити учасника - Роль буде змінено на \"%s\". Всі учасники групи будуть сповіщені. - Роль буде змінено на \"%s\". Учасник отримає нове запрошення. + Роль буде змінено на "%s". Всі учасники групи будуть сповіщені. + Роль буде змінено на "%s". Учасник отримає нове запрошення. Група Ласкаво просимо Профіль групи зберігається на пристроях учасників, а не на серверах. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 9e038995b3..469ce9b5b1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1327,7 +1327,7 @@ 启用 该群组成员超过 %1$d ,未发送送达回执。 修复连接? - 我们错过的第二个\"√\"!✅ + 我们错过的第二个"√"!✅ 设定数据库密码 为群组禁用回执吗? %s、%s 和 %s 已连接 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 48efa52563..17cc45334e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -599,8 +599,8 @@ 建立連結 刪除連結? 移除成員 - 成員的身份會修改為 \"%s\"。所有在群組內的成員都接收到通知。 - 成員的身份會修改為 \"%s\"。該成員將接收到新的邀請。 + 成員的身份會修改為 "%s"。所有在群組內的成員都接收到通知。 + 成員的身份會修改為 "%s"。該成員將接收到新的邀請。 網路狀態 重置為預設值 當你與某人分享已啟用匿名聊天模式的個人檔案時,此個人檔案將用於他們邀請你參加的群組。 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt new file mode 100644 index 0000000000..583d5437c3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -0,0 +1,86 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.User +import chat.simplex.common.model.UserInfo +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun UserPickerInactiveUsersSection( + users: List, + stopped: Boolean, + onShowAllProfilesClicked: () -> Unit, + onUserClicked: (user: User) -> Unit, +) { + if (users.isNotEmpty()) { + val userRows = users.chunked(5) + val rowsToDisplay = if (userRows.size > 2) 2 else userRows.size + val horizontalPadding = DEFAULT_PADDING_HALF + 8.dp + + Column(Modifier + .padding(horizontal = horizontalPadding, vertical = DEFAULT_PADDING_HALF) + .height(55.dp * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp)) + ) { + ColumnWithScrollBar( + verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING) + ) { + val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5 + + userRows.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spaceBetween), + ) { + row.forEach { u -> + UserPickerInactiveUserBadge(u, stopped, size = 55.dp) { + onUserClicked(u.user) + } + } + } + } + } + } + } + + UserPickerOptionRow( + painterResource(MR.images.ic_manage_accounts), + stringResource(MR.strings.your_chat_profiles), + onShowAllProfilesClicked + ) +} + +@Composable +actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow, content: @Composable () -> Unit) { + AnimatedVisibility( + visible = pickerState.value.isVisible(), + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + Modifier + .fillMaxSize() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }), + contentAlignment = Alignment.TopStart + ) { + ColumnWithScrollBar(modifier) { + content() + } + } + } +} \ No newline at end of file diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 58a167e4f5..5351565946 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -23,7 +23,7 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Options -import Simplex.Messaging.Transport.Server (runTCPServer) +import Simplex.Messaging.Transport.Server (runLocalTCPServer) import Simplex.Messaging.Util (raceAny_) import UnliftIO.Exception import UnliftIO.STM @@ -68,7 +68,7 @@ newChatServerClient qSize = do runChatServer :: ChatServerConfig -> ChatController -> IO () runChatServer ChatServerConfig {chatPort, clientQSize} cc = do started <- newEmptyTMVarIO - runTCPServer started chatPort $ \sock -> do + runLocalTCPServer started chatPort $ \sock -> do ws <- liftIO $ getConnection sock c <- atomically $ newChatServerClient clientQSize putStrLn "client connected" diff --git a/blog/20220404-simplex-chat-instant-notifications.md b/blog/20220404-simplex-chat-instant-notifications.md index 73bf68a10b..ce7dfd613c 100644 --- a/blog/20220404-simplex-chat-instant-notifications.md +++ b/blog/20220404-simplex-chat-instant-notifications.md @@ -66,7 +66,7 @@ This service continues running when the app is switched off, and it is restarted So, for Android we can now deliver instant message notifications without compromising users' privacy in any way. The app version 1.5 that includes private instant notifications is now available on [Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app), in our [F-Droid repo](https://app.simplex.chat/) and via direct [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) downloads! -Please let us what needs to be improved - it's only the first version of instant notifications for Android! +Please let us know what needs to be improved - it's only the first version of instant notifications for Android! ## Our iOS approach has one trade-off diff --git a/cabal.project b/cabal.project index d7f6b67eb1..fc958929af 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: d559a66145cf7b4cd367c09974ed1ce8393940b2 + tag: 946e16339e16e026f51185ebfb48c3a0c5a5b2e1 source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 06850e76d2..df22ff64a2 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,14 +1,12 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 03.07.2024 +revision: 09.09.2024 --- -| Updated 03.07.2024 | Languages: EN | +| Updated 09.09.2024 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.8. - You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). - [desktop](#desktop-app) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 4ac5f88e14..2d48dc4dc3 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,41 @@ + + https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html + +

New in v6.0.1-4:

+
    +
  • reduce traffic and battery usage on unstable networks.
  • +
  • only offer the next versions for update, based on SerVer ordering.
  • +
  • UI fixes.
  • +
  • reduce app memory usage and start time.
  • +
  • faster sending of files to groups.
  • +
  • fix rare delivery bug.
  • +
+

New in v6.0:

+

New chat experience:

+
    +
  • connect to your friends faster.
  • +
  • archive contacts to chat later.
  • +
  • delete up to 20 messages at once.
  • +
  • increase font size.
  • +
+

New media options:

+
    +
  • play from the chat list.
  • +
  • blur for better privacy.
  • +
+

Private routing:

+
    +
  • it protects your IP address and connections and is now enabled by default.
  • +
+

Connection and servers information:

+
    +
  • to control your network status and usage.
  • +
+
+
https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 0f6592086f..fa3c6d4839 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."d559a66145cf7b4cd367c09974ed1ce8393940b2" = "1jav7jmriims6vlkxg8gmal03f9mbgrwc8v6g0rp95ivkx8gfjyw"; + "https://github.com/simplex-chat/simplexmq.git"."946e16339e16e026f51185ebfb48c3a0c5a5b2e1" = "1jkx2f14h5krmy467zyifsc58dys89pkpn08cyf1q9v78in7nwfd"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d9f4ce0db3..fb5c4b4962 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -55,9 +55,9 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) import Data.Time.Clock.System (systemToUTCTime) -import Data.Word (Word32) import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 +import Data.Word (Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call @@ -115,7 +115,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM @@ -843,9 +843,7 @@ processChatCommand' vr = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of CTDirect -> withContactLock "deleteChatItem" chatId $ do - ct <- withStore $ \db -> getContact db vr user chatId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + (ct, items) <- getCommandDirectChatItems user chatId itemIds case mode of CIDMInternal -> deleteDirectCIs user ct items True False CIDMBroadcast -> do @@ -858,13 +856,9 @@ processChatCommand' vr = \case if featureAllowed SCFFullDelete forUser ct then deleteDirectCIs user ct items True False else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime - where - getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) - getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user chatId itemId CTGroup -> withGroupLock "deleteChatItem" chatId $ do - Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + (gInfo, items) <- getCommandGroupChatItems user chatId itemIds + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo case mode of CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do @@ -874,17 +868,9 @@ processChatCommand' vr = \case events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds mapM_ (sendGroupMessages user gInfo ms) events delGroupChatItems user gInfo items Nothing - where - getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) - getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user chatId itemId CTLocal -> do - nf <- withStore $ \db -> getNoteFolder db user chatId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + (nf, items) <- getCommandLocalChatItems user chatId itemIds deleteLocalCIs user nf items True False - where - getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) - getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user chatId itemId CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where @@ -902,9 +888,8 @@ processChatCommand' vr = \case itemsMsgIds :: [CChatItem c] -> [SharedMsgId] itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId) APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do - Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db user) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs + (gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo assertDeletable gInfo items assertUserGroupRole gInfo GRAdmin let msgMemIds = itemsMsgMemIds gInfo items @@ -912,8 +897,6 @@ processChatCommand' vr = \case mapM_ (sendGroupMessages user gInfo ms) events delGroupChatItems user gInfo items (Just membership) where - getGroupCI :: DB.Connection -> User -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) - getGroupCI db user itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items = unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete @@ -980,6 +963,51 @@ processChatCommand' vr = \case throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ throwChatError (CECommandError "too many reactions") + APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of + CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds + CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds + CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + where + planForward :: User -> [CChatItem c] -> CM ChatResponse + planForward user items = do + (itemIds', forwardErrors) <- unzip <$> mapM planItemForward items + let forwardConfirmation = case catMaybes forwardErrors of + [] -> Nothing + errs -> Just $ case mainErr of + FFENotAccepted _ -> FCFilesNotAccepted fileIds + FFEInProgress -> FCFilesInProgress filesCount + FFEMissing -> FCFilesMissing filesCount + FFEFailed -> FCFilesFailed filesCount + where + mainErr = minimum errs + fileIds = catMaybes $ map (\case FFENotAccepted ftId -> Just ftId; _ -> Nothing) errs + filesCount = length $ filter (mainErr ==) errs + pure CRForwardPlan {user, itemsCount = length itemIds, chatItemIds = catMaybes itemIds', forwardConfirmation} + where + planItemForward :: CChatItem c -> CM (Maybe ChatItemId, Maybe ForwardFileError) + planItemForward (CChatItem _ ci) = forwardMsgContent ci >>= maybe (pure (Nothing, Nothing)) (forwardContentPlan ci) + forwardContentPlan :: ChatItem c d -> MsgContent -> CM (Maybe ChatItemId, Maybe ForwardFileError) + forwardContentPlan ChatItem {file, meta = CIMeta {itemId}} mc = case file of + Nothing -> pure (Just itemId, Nothing) + Just CIFile {fileId, fileStatus, fileSource} -> case ciFileForwardError fileId fileStatus of + Just err -> pure $ itemIdWithoutFile err + Nothing -> case fileSource of + Just CryptoFile {filePath} -> do + exists <- doesFileExist . maybe filePath ( filePath) =<< chatReadVar filesFolder + pure $ if exists then (Just itemId, Nothing) else itemIdWithoutFile FFEMissing + Nothing -> pure $ itemIdWithoutFile FFEMissing + where + itemIdWithoutFile err = (if hasContent then Just itemId else Nothing, Just err) + hasContent = case mc of + MCText _ -> True + MCLink {} -> True + MCImage {} -> True + MCVideo {text} -> text /= "" + MCVoice {text} -> text /= "" + MCFile t -> t /= "" + MCUnknown {} -> True APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user @@ -987,96 +1015,76 @@ processChatCommand' vr = \case Just cmrs' -> withContactLock "forwardChatItem, to contact" toChatId $ sendContactContentMessages user toChatId False itemTTL cmrs' - Nothing -> throwChatError $ CEInternalError "no chat items to forward" + Nothing -> pure $ CRNewChatItems user [] CTGroup -> do cmrs <- prepareForward user case L.nonEmpty cmrs of Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ sendGroupContentMessages user toChatId False itemTTL cmrs' - Nothing -> throwChatError $ CEInternalError "no chat items to forward" + Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user case L.nonEmpty cmrs of Just cmrs' -> createNoteFolderContentItems user toChatId cmrs' - Nothing -> throwChatError $ CEInternalError "no chat items to forward" + Nothing -> pure $ CRNewChatItems user [] CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where prepareForward :: User -> CM [ComposeMessageReq] prepareForward user = case fromCType of CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do - ct <- withFastStore $ \db -> getContact db vr user fromChatId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - mapM (ciComposeMsgReq ct) items + (ct, items) <- getCommandDirectChatItems user fromChatId itemIds + catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items where - getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) - getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user fromChatId itemId - ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> CM ComposeMessageReq - ciComposeMsgReq ct (CChatItem _ ci) = do - (mc, mDir) <- forwardMC ci - file <- forwardCryptoFile ci + ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq ct (CChatItem md ci) (mc', file) = let itemId = chatItemId' ci - ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) mDir (Just fromChatId) (Just itemId)) - pure (ComposedMessage file Nothing mc, ciff) + ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId)) + in (ComposedMessage file Nothing mc', ciff) where forwardName :: Contact -> ContactName forwardName Contact {profile = LocalProfile {displayName, localAlias}} | localAlias /= "" = localAlias | otherwise = displayName CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do - gInfo <- withFastStore $ \db -> getGroupInfo db vr user fromChatId - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - mapM (ciComposeMsgReq gInfo) items + (gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds + catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where - getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) - getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user fromChatId itemId - ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> CM ComposeMessageReq - ciComposeMsgReq gInfo (CChatItem _ ci) = do - (mc, mDir) <- forwardMC ci - file <- forwardCryptoFile ci + ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do let itemId = chatItemId' ci - ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) mDir (Just fromChatId) (Just itemId)) - pure (ComposedMessage file Nothing mc, ciff) + ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) + in (ComposedMessage file Nothing mc', ciff) where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName CTLocal -> do - (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) - unless (null errs) $ toView $ CRChatErrors (Just user) errs - mapM ciComposeMsgReq items + (_, items) <- getCommandLocalChatItems user fromChatId itemIds + catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items where - getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) - getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user fromChatId itemId - ciComposeMsgReq :: CChatItem 'CTLocal -> CM ComposeMessageReq - ciComposeMsgReq (CChatItem _ ci) = do - (mc, _) <- forwardMC ci - file <- forwardCryptoFile ci + ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq + ciComposeMsgReq (CChatItem _ ci) (mc', file) = let ciff = forwardCIFF ci Nothing - pure (ComposedMessage file Nothing mc, ciff) + in (ComposedMessage file Nothing mc', ciff) CTContactRequest -> throwChatError $ CECommandError "not supported" CTContactConnection -> throwChatError $ CECommandError "not supported" where - forwardMC :: ChatItem c d -> CM (MsgContent, MsgDirection) - forwardMC ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidForward - forwardMC ChatItem {content = CISndMsgContent fmc} = pure (fmc, MDSnd) - forwardMC ChatItem {content = CIRcvMsgContent fmc} = pure (fmc, MDRcv) - forwardMC _ = throwChatError CEInvalidForward + prepareMsgReq :: CChatItem c -> CM (Maybe (MsgContent, Maybe CryptoFile)) + prepareMsgReq (CChatItem _ ci) = forwardMsgContent ci $>>= forwardContent ci forwardCIFF :: ChatItem c d -> Maybe CIForwardedFrom -> Maybe CIForwardedFrom forwardCIFF ChatItem {meta = CIMeta {itemForwarded}} ciff = case itemForwarded of Nothing -> ciff Just CIFFUnknown -> ciff Just prevCIFF -> Just prevCIFF - forwardCryptoFile :: ChatItem c d -> CM (Maybe CryptoFile) - forwardCryptoFile ChatItem {file = Nothing} = pure Nothing - forwardCryptoFile ChatItem {file = Just ciFile} = case ciFile of + forwardContent :: ChatItem c d -> MsgContent -> CM (Maybe (MsgContent, Maybe CryptoFile)) + forwardContent ChatItem {file = Nothing} mc = pure $ Just (mc, Nothing) + forwardContent ChatItem {file = Just ciFile} mc = case ciFile of CIFile {fileName, fileSource = Just fromCF@CryptoFile {filePath}} -> chatReadVar filesFolder >>= \case Nothing -> - ifM (doesFileExist filePath) (pure $ Just fromCF) (pure Nothing) + ifM (doesFileExist filePath) (pure $ Just (mc, Just fromCF)) (pure contentWithoutFile) Just filesFolder -> do let fsFromPath = filesFolder filePath ifM @@ -1089,10 +1097,17 @@ processChatCommand' vr = \case let toCF = CryptoFile fsNewPath cfArgs -- to keep forwarded file in case original is deleted liftIOEither $ runExceptT $ withExceptT (ChatError . CEInternalError . show) $ copyCryptoFile (fromCF {filePath = fsFromPath} :: CryptoFile) toCF - pure $ Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile) + pure $ Just (mc, Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile)) ) - (pure Nothing) - _ -> pure Nothing + (pure contentWithoutFile) + _ -> pure contentWithoutFile + where + contentWithoutFile = case mc of + MCImage {} -> Just (mc, Nothing) + MCLink {} -> Just (mc, Nothing) + _ | contentText /= "" -> Just (MCText contentText, Nothing) + _ -> Nothing + contentText = msgContentText mc copyCryptoFile :: CryptoFile -> CryptoFile -> ExceptT CF.FTCryptoError IO () copyCryptoFile fromCF@CryptoFile {filePath = fsFromPath, cryptoArgs = fromArgs} toCF@CryptoFile {cryptoArgs = toArgs} = do fromSizeFull <- getFileSize fsFromPath @@ -1114,26 +1129,24 @@ processChatCommand' vr = \case when (size' > 0) $ copyChunks r w size' APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId - APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId - timedItems <- withFastStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime - forM_ timedItems $ \(itemId, ttl) -> do - let deleteAt = addUTCTime (realToFrac ttl) ts - withFastStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt - startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt - withFastStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds + timedItems <- withFastStore' $ \db -> do + timedItems <- getDirectUnreadTimedItems db user chatId fromToIds + updateDirectChatItemsRead db user chatId fromToIds + setDirectChatItemsDeleteAt db user chatId timedItems ts + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user@User {userId} <- withFastStore $ \db -> getUserByGroupId db chatId - timedItems <- withFastStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds + user <- withFastStore $ \db -> getUserByGroupId db chatId ts <- liftIO getCurrentTime - forM_ timedItems $ \(itemId, ttl) -> do - let deleteAt = addUTCTime (realToFrac ttl) ts - withFastStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt - startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt - withFastStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds + timedItems <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId fromToIds + updateGroupChatItemsRead db user chatId fromToIds + setGroupChatItemsDeleteAt db user chatId timedItems ts + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTLocal -> do user <- withFastStore $ \db -> getUserByNoteFolderId db chatId @@ -1141,6 +1154,24 @@ processChatCommand' vr = \case ok user CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" + APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of + CTDirect -> do + user <- withFastStore $ \db -> getUserByContactId db chatId + timedItems <- withFastStore' $ \db -> do + timedItems <- updateDirectChatItemsReadList db user chatId itemIds + setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + ok user + CTGroup -> do + user <- withFastStore $ \db -> getUserByGroupId db chatId + timedItems <- withFastStore' $ \db -> do + timedItems <- updateGroupChatItemsReadList db user chatId itemIds + setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + ok user + CTLocal -> pure $ chatCmdError Nothing "not supported" + CTContactRequest -> pure $ chatCmdError Nothing "not supported" + CTContactConnection -> pure $ chatCmdError Nothing "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withFastStore $ \db -> do @@ -2791,7 +2822,10 @@ processChatCommand' vr = \case filesInfo <- withFastStore' (`getUserFileInfo` user) cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo - withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues + withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) + `catchChatError` \case + e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e + e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing ok_ @@ -3068,6 +3102,38 @@ processChatCommand' vr = \case | (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <- zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_) ] + getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect]) + getCommandDirectChatItems user ctId itemIds = do + ct <- withFastStore $ \db -> getContact db vr user ctId + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds)) + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure (ct, items) + where + getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect)) + getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId + getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup]) + getCommandGroupChatItems user gId itemIds = do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds)) + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure (gInfo, items) + where + getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup)) + getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId + getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal]) + getCommandLocalChatItems user nfId itemIds = do + nf <- withStore $ \db -> getNoteFolder db user nfId + (errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds)) + unless (null errs) $ toView $ CRChatErrors (Just user) errs + pure (nf, items) + where + getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal)) + getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user nfId itemId + forwardMsgContent :: ChatItem c d -> CM (Maybe MsgContent) + forwardMsgContent ChatItem {meta = CIMeta {itemDeleted = Just _}} = pure Nothing -- this can be deleted after selection + forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc + forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc + forwardMsgContent _ = throwChatError CEInvalidForward createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse createNoteFolderContentItems user folderId cmrs = do assertNoQuotes @@ -7869,10 +7935,12 @@ chatCommandP = "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), + "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), + "/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode), "/_clear chat " *> (APIClearChat <$> chatRefP), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9d92ee8193..99739cc6bf 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -298,10 +298,12 @@ data ChatCommand | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode | APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId) | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} + | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} | APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int} | APIUserRead UserId | UserRead | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) + | APIChatItemsRead ChatRef (NonEmpty ChatItemId) | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef ChatDeleteMode -- currently delete mode settings are only applied to direct chats | APIClearChat ChatRef @@ -648,6 +650,7 @@ data ChatResponse | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} + | CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation} | CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr} | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} @@ -904,6 +907,13 @@ connectionPlanProceed = \case GLPConnectingConfirmReconnect -> True _ -> False +data ForwardConfirmation + = FCFilesNotAccepted {fileIds :: [FileTransferId]} + | FCFilesInProgress {filesCount :: Int} + | FCFilesMissing {filesCount :: Int} + | FCFilesFailed {filesCount :: Int} + deriving (Show) + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -1462,6 +1472,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) +$(JQ.deriveJSON defaultJSON ''ForwardConfirmation) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index f6c59cbcb5..2b21857408 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -595,6 +595,27 @@ ciFileLoaded = \case CIFSRcvWarning {} -> False CIFSInvalid {} -> False +data ForwardFileError = FFENotAccepted FileTransferId | FFEInProgress | FFEFailed | FFEMissing + deriving (Eq, Ord) + +ciFileForwardError :: FileTransferId -> CIFileStatus d -> Maybe ForwardFileError +ciFileForwardError fId = \case + CIFSSndStored -> Nothing + CIFSSndTransfer {} -> Nothing + CIFSSndComplete -> Nothing + CIFSSndCancelled -> Nothing + CIFSSndError {} -> Nothing + CIFSSndWarning {} -> Nothing + CIFSRcvInvitation -> Just $ FFENotAccepted fId + CIFSRcvAccepted -> Just FFEInProgress + CIFSRcvTransfer {} -> Just FFEInProgress + CIFSRcvAborted -> Just $ FFENotAccepted fId + CIFSRcvCancelled -> Just FFEFailed + CIFSRcvComplete -> Nothing + CIFSRcvError {} -> Just FFEFailed + CIFSRcvWarning {} -> Just FFEFailed + CIFSInvalid {} -> Just FFEFailed + data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) deriving instance Show ACIFileStatus diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 6dbd9124c5..f6f9588f66 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -60,10 +60,12 @@ module Simplex.Chat.Store.Messages deleteLocalChatItem, updateDirectChatItemsRead, getDirectUnreadTimedItems, - setDirectChatItemDeleteAt, + updateDirectChatItemsReadList, + setDirectChatItemsDeleteAt, updateGroupChatItemsRead, getGroupUnreadTimedItems, - setGroupChatItemDeleteAt, + updateGroupChatItemsReadList, + setGroupChatItemsDeleteAt, updateLocalChatItemsRead, getChatRefViaItemId, getChatItemVersions, @@ -126,7 +128,9 @@ import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight, rights) import Data.Int (Int64) import Data.List (sortBy) -import Data.Maybe (fromMaybe, isJust, mapMaybe) +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe) import Data.Ord (Down (..), comparing) import Data.Text (Text) import qualified Data.Text as T @@ -1339,15 +1343,27 @@ getDirectUnreadTimedItems db User {userId} contactId itemsRange_ = case itemsRan |] (userId, contactId, CISRcvNew) -setDirectChatItemDeleteAt :: DB.Connection -> User -> ContactId -> ChatItemId -> UTCTime -> IO () -setDirectChatItemDeleteAt db User {userId} contactId chatItemId deleteAt = +updateDirectChatItemsReadList :: DB.Connection -> User -> ContactId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] +updateDirectChatItemsReadList db user contactId itemIds = do + catMaybes . L.toList <$> mapM getUpdateDirectItem itemIds + where + getUpdateDirectItem chatItemId = do + let itemsRange = Just (chatItemId, chatItemId) + timedItem <- maybeFirstRow id $ getDirectUnreadTimedItems db user contactId itemsRange + updateDirectChatItemsRead db user contactId itemsRange + pure timedItem + +setDirectChatItemsDeleteAt :: DB.Connection -> User -> ContactId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] +setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do + let deleteAt = addUTCTime (realToFrac ttl) currentTs DB.execute db "UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (deleteAt, userId, contactId, chatItemId) + pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> UserId -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO () -updateGroupChatItemsRead db userId groupId itemsRange_ = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO () +updateGroupChatItemsRead db User {userId} groupId itemsRange_ = do currentTs <- getCurrentTime case itemsRange_ of Just (fromItemId, toItemId) -> @@ -1392,12 +1408,24 @@ getGroupUnreadTimedItems db User {userId} groupId itemsRange_ = case itemsRange_ |] (userId, groupId, CISRcvNew) -setGroupChatItemDeleteAt :: DB.Connection -> User -> GroupId -> ChatItemId -> UTCTime -> IO () -setGroupChatItemDeleteAt db User {userId} groupId chatItemId deleteAt = +updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] +updateGroupChatItemsReadList db user groupId itemIds = do + catMaybes . L.toList <$> mapM getUpdateGroupItem itemIds + where + getUpdateGroupItem chatItemId = do + let itemsRange = Just (chatItemId, chatItemId) + timedItem <- maybeFirstRow id $ getGroupUnreadTimedItems db user groupId itemsRange + updateGroupChatItemsRead db user groupId itemsRange + pure timedItem + +setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] +setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do + let deleteAt = addUTCTime (realToFrac ttl) currentTs DB.execute db "UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (deleteAt, userId, groupId, chatItemId) + pure (chatItemId, deleteAt) updateLocalChatItemsRead :: DB.Connection -> User -> NoteFolderId -> Maybe (ChatItemId, ChatItemId) -> IO () updateLocalChatItemsRead db User {userId} noteFolderId itemsRange_ = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index b80e2ce805..a4c2d1da39 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -383,7 +383,6 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] - type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, Bool, Maybe UIThemeEntityOverrides, Bool, Maybe CustomData) type ContactRow = Only ContactId :. ContactRow' diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2158599c4b..cb686ef2b0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -203,6 +203,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] + CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc CRRcvFileDescrReady _ _ _ _ -> [] CRRcvFileProgressXFTP {} -> [] CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci @@ -930,6 +931,20 @@ viewUserContactLinkDeleted = "To create a new chat address use " <> highlight' "/ad" ] +viewForwardPlan :: Int -> [ChatItemId] -> Maybe ForwardConfirmation -> [StyledString] +viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, forwardCount] + where + confirmation = \case + FCFilesNotAccepted fileIds -> plain $ "Files can be received: " <> intercalate ", " (map show fileIds) + FCFilesInProgress cnt -> plain $ "Still receiving " <> show cnt <> " file(s)" + FCFilesMissing cnt -> plain $ show cnt <> " file(s) are missing" + FCFilesFailed cnt -> plain $ "Receiving " <> show cnt <> " file(s) failed" + forwardCount + | count == len = "all messages can be forwarded" + | len == 0 = "nothing to forward" + | otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded" + len = length itemIds + connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, @@ -2034,7 +2049,7 @@ viewChatError isCmd logLevel testView = \case CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"] CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."] CEInvalidQuote -> ["cannot reply to this message"] - CEInvalidForward -> ["cannot forward this message"] + CEInvalidForward -> ["cannot forward message(s)"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] CEHasCurrentCall -> ["call already in progress"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 9980b3b723..97a9d89200 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -3,6 +3,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE RankNTypes #-} +{-# LANGUAGE ScopedTypeVariables #-} module ChatTests.Direct where @@ -22,6 +23,7 @@ import Simplex.Chat.AppSettings (defaultAppSettings) import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) @@ -38,6 +40,7 @@ chatDirectTests :: SpecWith FilePath chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact + it "mark multiple messages as read" testMarkReadDirect it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile it "delete contact keeping conversation" testDeleteContactKeepConversation @@ -212,6 +215,22 @@ testAddContact = versionTestMatrix2 runTestAddContact then chatFeatures else (0, e2eeInfoNoPQStr) : tail chatFeatures +testMarkReadDirect :: HasCallStack => FilePath -> IO () +testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + alice #> "@bob 1" + alice #> "@bob 2" + alice #> "@bob 3" + alice #> "@bob 4" + bob <# "alice> 1" + bob <# "alice> 2" + bob <# "alice> 3" + bob <# "alice> 4" + bob ##> "/last_item_id" + i :: ChatItemId <- read <$> getTermLine bob + let itemIds = intercalate "," $ map show [i - 3 .. i] + bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () testDuplicateContactsSeparate = testChat2 aliceProfile bobProfile $ diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index b339053abf..8f1595fd6c 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -7,7 +7,11 @@ import ChatClient import ChatTests.Utils import Control.Concurrent (threadDelay) import qualified Data.ByteString.Char8 as B -import System.Directory (copyFile, doesFileExist) +import Data.List (intercalate) +import qualified Data.Text as T +import System.Directory (copyFile, doesFileExist, removeFile) +import Simplex.Chat (fixedImagePreview) +import Simplex.Chat.Types (ImageData (..)) import Test.Hspec hiding (it) chatForwardTests :: SpecWith FilePath @@ -613,6 +617,8 @@ testForwardContactToContactMulti = alice <# "bob> hey" msgId2 <- lastItemId alice + alice ##> ("/_forward plan @2 " <> msgId1 <> "," <> msgId2) + alice <## "all messages can be forwarded" alice ##> ("/_forward @3 @2 " <> msgId1 <> "," <> msgId2) alice <# "@cath <- you @bob" alice <## " hi" @@ -642,6 +648,8 @@ testForwardGroupToGroupMulti = alice <# "#team bob> hey" msgId2 <- lastItemId alice + alice ##> ("/_forward plan #1 " <> msgId1 <> "," <> msgId2) + alice <## "all messages can be forwarded" alice ##> ("/_forward #2 #1 " <> msgId1 <> "," <> msgId2) alice <# "#club <- you #team" alice <## " hi" @@ -672,6 +680,7 @@ testMultiForwardFiles = setRelativePaths alice "./tests/tmp/alice_app_files" "./tests/tmp/alice_xftp" copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf" + copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf" setRelativePaths bob "./tests/tmp/bob_app_files" "./tests/tmp/bob_xftp" setRelativePaths cath "./tests/tmp/cath_app_files" "./tests/tmp/cath_xftp" connectUsers alice bob @@ -686,32 +695,46 @@ testMultiForwardFiles = -- send original files let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}" - cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}" - cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}" - alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]") + ImageData img = fixedImagePreview + cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"image\", \"image\":\"" <> T.unpack img <> "\", \"text\": \"\"}}" + cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}" + cm4 = "{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"message with large file\"}}" + alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "," <> cm4 <> "]") alice <# "@bob message without file" - alice <# "@bob sending file 1" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" - alice <# "@bob sending file 2" alice <# "/f @bob test.pdf" alice <## "use /fc 2 to cancel sending" + alice <# "@bob message with large file" + alice <# "/f @bob test_1MB.pdf" + alice <## "use /fc 3 to cancel sending" + bob <# "alice> message without file" - bob <# "alice> sending file 1" bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" bob <## "use /fr 1 [/ | ] to receive it" - bob <# "alice> sending file 2" bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" bob <## "use /fr 2 [/ | ] to receive it" + bob <# "alice> message with large file" + bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)" + bob <## "use /fr 3 [/ | ] to receive it" + alice <## "completed uploading file 1 (test.jpg) for bob" alice <## "completed uploading file 2 (test.pdf) for bob" + alice <## "completed uploading file 3 (test_1MB.pdf) for bob" + + -- IDs to forward + let msgId1 = (read msgIdZero :: Int) + 1 + msgIds = intercalate "," $ map show [msgId1, msgId1 + 1, msgId1 + 2, msgId1 + 3, msgId1 + 4] + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "Files can be received: 1, 2, 3" + bob <## "4 message(s) out of 5 can be forwarded" bob ##> "/fr 1" bob @@ -720,6 +743,10 @@ testMultiForwardFiles = ] bob <## "completed receiving file 1 (test.jpg) from alice" + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "Files can be received: 2, 3" + bob <## "4 message(s) out of 5 can be forwarded" + bob ##> "/fr 2" bob <### [ "saving file 2 from alice to test.pdf", @@ -736,8 +763,10 @@ testMultiForwardFiles = dest2 `shouldBe` src2 -- forward file - let msgId1 = (read msgIdZero :: Int) + 1 - bob ##> ("/_forward @3 @2 " <> show msgId1 <> "," <> show (msgId1 + 1) <> "," <> show (msgId1 + 2) <> "," <> show (msgId1 + 3)) + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "Files can be received: 3" + bob <## "all messages can be forwarded" + bob ##> ("/_forward @3 @2 " <> msgIds) -- messages printed for bob bob <# "@cath <- you @alice" @@ -747,14 +776,17 @@ testMultiForwardFiles = bob <## " message without file" bob <# "@cath <- @alice" - bob <## " sending file 1" + bob <## " test_1.jpg" bob <# "/f @cath test_1.jpg" - bob <## "use /fc 3 to cancel sending" + bob <## "use /fc 4 to cancel sending" bob <# "@cath <- @alice" - bob <## " sending file 2" + bob <## " test_1.pdf" bob <# "/f @cath test_1.pdf" - bob <## "use /fc 4 to cancel sending" + bob <## "use /fc 5 to cancel sending" + + bob <# "@cath <- @alice" + bob <## " message with large file" -- messages printed for cath cath <# "bob> -> forwarded" @@ -764,18 +796,21 @@ testMultiForwardFiles = cath <## " message without file" cath <# "bob> -> forwarded" - cath <## " sending file 1" + cath <## " test_1.jpg" cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)" cath <## "use /fr 1 [/ | ] to receive it" cath <# "bob> -> forwarded" - cath <## " sending file 2" + cath <## " test_1.pdf" cath <# "bob> sends file test_1.pdf (266.0 KiB / 272376 bytes)" cath <## "use /fr 2 [/ | ] to receive it" + cath <# "bob> -> forwarded" + cath <## " message with large file" + -- file transfer - bob <## "completed uploading file 3 (test_1.jpg) for cath" - bob <## "completed uploading file 4 (test_1.pdf) for cath" + bob <## "completed uploading file 4 (test_1.jpg) for cath" + bob <## "completed uploading file 5 (test_1.pdf) for cath" cath ##> "/fr 1" cath @@ -801,6 +836,26 @@ testMultiForwardFiles = dest2C <- B.readFile "./tests/tmp/cath_app_files/test_1.pdf" dest2C `shouldBe` src2B + bob ##> "/fr 3" + bob + <### [ "saving file 3 from alice to test_1MB.pdf", + "started receiving file 3 (test_1MB.pdf) from alice" + ] + bob <## "completed receiving file 3 (test_1MB.pdf) from alice" + + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "all messages can be forwarded" + + removeFile "./tests/tmp/bob_app_files/test_1MB.pdf" + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "1 file(s) are missing" + bob <## "all messages can be forwarded" + + removeFile "./tests/tmp/bob_app_files/test.pdf" + bob ##> ("/_forward plan @2 " <> msgIds) + bob <## "2 file(s) are missing" + bob <## "4 message(s) out of 5 can be forwarded" + -- deleting original file doesn't delete forwarded file checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do bob ##> "/clear alice" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index d3e65ce5df..c65c7b8085 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -16,6 +16,7 @@ import Data.List (intercalate, isInfixOf) import qualified Data.Text as T import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Messages (ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) @@ -34,6 +35,7 @@ chatGroupTests :: SpecWith FilePath chatGroupTests = do describe "chat groups" $ do describe "add contacts, create group and send/receive messages" testGroupMatrix + it "mark multiple messages as read" testMarkReadGroup it "v1: add contacts, create group and send/receive messages" testGroup it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages it "send large message" testGroupLargeMessage @@ -355,6 +357,22 @@ testGroupShared alice bob cath checkMessages directConnections = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") +testMarkReadGroup :: HasCallStack => FilePath -> IO () +testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + alice #> "#team 1" + alice #> "#team 2" + alice #> "#team 3" + alice #> "#team 4" + bob <# "#team alice> 1" + bob <# "#team alice> 2" + bob <# "#team alice> 3" + bob <# "#team alice> 4" + bob ##> "/last_item_id" + i :: ChatItemId <- read <$> getTermLine bob + let itemIds = intercalate "," $ map show [i - 3 .. i] + bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + testGroupLargeMessage :: HasCallStack => FilePath -> IO () testGroupLargeMessage = testChat2 aliceProfile bobProfile $