diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6b6b0ac03f..2784551361 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -100,6 +100,94 @@ class ItemsModel: ObservableObject { } } +class ChatTagsModel: ObservableObject { + static let shared = ChatTagsModel() + + @Published var userTags: [ChatTag] = [] + @Published var activeFilter: ActiveFilter? = nil + @Published var presetTags: [PresetTag:Int] = [:] + @Published var unreadTags: [Int64:Int] = [:] + + func updateChatTags(_ chats: [Chat]) { + let tm = ChatTagsModel.shared + var newPresetTags: [PresetTag:Int] = [:] + var newUnreadTags: [Int64:Int] = [:] + for chat in chats { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chat.chatInfo) { + newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1 + } + } + if chat.isUnread, let tags = chat.chatInfo.chatTags { + for tag in tags { + newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1 + } + } + } + if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 { + activeFilter = nil + } + presetTags = newPresetTags + unreadTags = newUnreadTags + } + + func updateChatFavorite(favorite: Bool, wasFavorite: Bool) { + let count = presetTags[.favorites] + if favorite && !wasFavorite { + presetTags[.favorites] = (count ?? 0) + 1 + } else if !favorite && wasFavorite, let count { + presetTags[.favorites] = max(0, count - 1) + if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 { + activeFilter = nil + } + } + } + + func addPresetChatTags(_ chatInfo: ChatInfo) { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chatInfo) { + presetTags[tag] = (presetTags[tag] ?? 0) + 1 + } + } + } + + func removePresetChatTags(_ chatInfo: ChatInfo) { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chatInfo) { + if let count = presetTags[tag] { + presetTags[tag] = max(0, count - 1) + } + } + } + } + + func markChatTagRead(_ chat: Chat) -> Void { + if chat.isUnread, let tags = chat.chatInfo.chatTags { + markChatTagRead_(chat, tags) + } + } + + func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void { + guard let tags = chat.chatInfo.chatTags else { return } + let nowUnread = chat.isUnread + if nowUnread && !wasUnread { + for tag in tags { + unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 + } + } else if !nowUnread && wasUnread { + markChatTagRead_(chat, tags) + } + } + + private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void { + for tag in tags { + if let count = unreadTags[tag] { + unreadTags[tag] = max(0, count - 1) + } + } + } +} + class NetworkModel: ObservableObject { // map of connections network statuses, key is agent connection id @Published var networkStatuses: Dictionary = [:] @@ -344,6 +432,7 @@ final class ChatModel: ObservableObject { updateChatInfo(cInfo) } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) + ChatTagsModel.shared.addPresetChatTags(cInfo) } } @@ -566,6 +655,7 @@ final class ChatModel: ObservableObject { _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) self.updateFloatingButtons(unreadCount: 0) + ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() } // update current chat @@ -604,7 +694,9 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { + let wasUnread = chat.isUnread chat.chatStats.unreadCount -= markedCount + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } @@ -617,7 +709,9 @@ final class ChatModel: ObservableObject { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in + let wasUnread = chat.isUnread chat.chatStats.unreadChat = unreadChat + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } } @@ -626,6 +720,7 @@ final class ChatModel: ObservableObject { if let chat = getChat(cInfo.id) { self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatItems = [] + ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() chat.chatInfo = cInfo } @@ -752,7 +847,9 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatIndex: Int, by count: Int) { + let wasUnread = chats[chatIndex].isUnread chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count + ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) } @@ -857,7 +954,10 @@ final class ChatModel: ObservableObject { func removeChat(_ id: String) { withAnimation { - chats.removeAll(where: { $0.id == id }) + if let i = getChatIndex(id) { + let removed = chats.remove(at: i) + ChatTagsModel.shared.removePresetChatTags(removed.chatInfo) + } } } @@ -955,6 +1055,10 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } } + var isUnread: Bool { + chatStats.unreadCount > 0 || chatStats.unreadChat + } + var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 51be3191ec..7eb78edf74 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -318,6 +318,20 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { throw r } +func apiGetChatTags() throws -> [ChatTag] { + let userId = try currentUserId("apiGetChatTags") + let r = chatSendCmdSync(.apiGetChatTags(userId: userId)) + if case let .chatTags(_, tags) = r { return tags } + throw r +} + +func apiGetChatTagsAsync() async throws -> [ChatTag] { + let userId = try currentUserId("apiGetChatTags") + let r = await chatSendCmd(.apiGetChatTags(userId: userId)) + if case let .chatTags(_, tags) = r { return tags } + throw r +} + let loadItemsPerPage = 50 func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat { @@ -368,6 +382,34 @@ func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: Ch return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } +func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] { + let r = await chatSendCmd(.apiCreateChatTag(tag: tag)) + if case let .chatTags(_, userTags) = r { + return userTags + } + throw r +} + +func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) { + let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds)) + if case let .tagsUpdated(_, userTags, chatTags) = r { + return (userTags, chatTags) + } + throw r +} + +func apiDeleteChatTag(tagId: Int64) async throws { + try await sendCommandOkResp(.apiDeleteChatTag(tagId: tagId)) +} + +func apiUpdateChatTag(tagId: Int64, tag: ChatTagData) async throws { + try await sendCommandOkResp(.apiUpdateChatTag(tagId: tagId, tagData: tag)) +} + +func apiReorderChatTags(tagIds: [Int64]) async throws { + try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) +} + func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) @@ -1746,24 +1788,37 @@ func getUserChatData() throws { m.userAddress = try apiGetUserAddress() m.chatItemTTL = try getChatItemTTL() let chats = try apiGetChats() + let tags = try apiGetChatTags() m.updateChats(chats) + let tm = ChatTagsModel.shared + tm.activeFilter = nil + tm.userTags = tags + tm.updateChatTags(m.chats) } private func getUserChatDataAsync() async throws { let m = ChatModel.shared + let tm = ChatTagsModel.shared if m.currentUser != nil { let userAddress = try await apiGetUserAddressAsync() let chatItemTTL = try await getChatItemTTLAsync() let chats = try await apiGetChatsAsync() + let tags = try await apiGetChatTagsAsync() await MainActor.run { m.userAddress = userAddress m.chatItemTTL = chatItemTTL m.updateChats(chats) + tm.activeFilter = nil + tm.userTags = tags + tm.updateChatTags(m.chats) } } else { await MainActor.run { m.userAddress = nil m.updateChats([]) + tm.activeFilter = nil + tm.userTags = [] + tm.presetTags = [:] } } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 9b6b9b73e8..1c3203920a 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -332,7 +332,7 @@ struct ChatInfoView: View { .sheet(item: $sheet) { if #available(iOS 16.0, *) { $0.content - .presentationDetents([.fraction(0.4)]) + .presentationDetents([.fraction($0.fraction)]) } else { $0.content } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..ac4066d23e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2007,6 +2007,8 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { do { try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings) await MainActor.run { + let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false + ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite) switch chat.chatInfo { case var .direct(contact): contact.chatSettings = chatSettings diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 6c5dad1f74..6bf86840a8 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -8,6 +8,7 @@ import SwiftUI import SimpleXChat +import ElegantEmojiPicker typealias DynamicSizes = ( rowHeight: CGFloat, @@ -43,9 +44,11 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes { struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatTagsModel: ChatTagsModel @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false @ObservedObject var chat: Chat + @Binding var parentSheet: SomeSheet? @State private var showContactRequestDialog = false @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @@ -85,6 +88,7 @@ struct ChatListNavLink: View { progressByTimeout = false } } + .actionSheet(item: $actionSheet) { $0.actionSheet } } @ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { @@ -124,6 +128,7 @@ struct ChatListNavLink: View { toggleNtfsButton(chat: chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { + tagChatButton(chat) if !chat.chatItems.isEmpty { clearChatButton() } @@ -145,11 +150,10 @@ struct ChatListNavLink: View { } } .alert(item: $alert) { $0.alert } - .actionSheet(item: $actionSheet) { $0.actionSheet } .sheet(item: $sheet) { if #available(iOS 16.0, *) { $0.content - .presentationDetents([.fraction(0.4)]) + .presentationDetents([.fraction($0.fraction)]) } else { $0.content } @@ -185,6 +189,7 @@ struct ChatListNavLink: View { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) } .swipeActions(edge: .trailing) { + tagChatButton(chat) if (groupInfo.membership.memberCurrent) { leaveGroupChatButton(groupInfo) } @@ -206,14 +211,25 @@ struct ChatListNavLink: View { toggleNtfsButton(chat: chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { - if !chat.chatItems.isEmpty { + tagChatButton(chat) + let showClearButton = !chat.chatItems.isEmpty + let showDeleteGroup = groupInfo.canDelete + let showLeaveGroup = groupInfo.membership.memberCurrent + let totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) + + if showClearButton, totalNumberOfButtons <= 3 { clearChatButton() } - if (groupInfo.membership.memberCurrent) { + if (showLeaveGroup) { leaveGroupChatButton(groupInfo) } - if groupInfo.canDelete { - deleteGroupChatButton(groupInfo) + + if showDeleteGroup { + if totalNumberOfButtons <= 3 { + deleteGroupChatButton(groupInfo) + } else { + moreOptionsButton(chat, groupInfo) + } } } } @@ -306,7 +322,67 @@ struct ChatListNavLink: View { } .tint(Color.orange) } - + + private func tagChatButton(_ chat: Chat) -> some View { + Button { + setTagChatSheet(chat) + } label: { + SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI) + } + .tint(.mint) + } + + private func setTagChatSheet(_ chat: Chat) { + let screenHeight = UIScreen.main.bounds.height + let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag + let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44 + let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62) + + parentSheet = SomeSheet( + content: { + AnyView( + NavigationView { + if chatTagsModel.userTags.isEmpty { + ChatListTagEditor(chat: chat) + } else { + ChatListTag(chat: chat) + } + } + ) + }, + id: "lists sheet", + fraction: fraction + ) + } + + private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View { + Button { + var buttons: [Alert.Button] = [ + .default(Text("Clear")) { + AlertManager.shared.showAlert(clearChatAlert()) + } + ] + + if let gi = groupInfo, gi.canDelete { + buttons.append(.destructive(Text("Delete")) { + AlertManager.shared.showAlert(deleteGroupAlert(gi)) + }) + } + + buttons.append(.cancel()) + + actionSheet = SomeActionSheet( + actionSheet: ActionSheet( + title: Text("Clear or delete group?"), + buttons: buttons + ), + id: "other options" + ) + } label: { + SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI) + } + } + private func clearNoteFolderButton() -> some View { Button { AlertManager.shared.showAlert(clearNoteFolderAlert()) @@ -484,6 +560,389 @@ struct ChatListNavLink: View { } } +struct TagEditorNavParams { + let chat: Chat? + let chatListTag: ChatTagData? + let tagId: Int64? +} + +struct ChatListTag: View { + var chat: Chat? = nil + var showEditButton: Bool = false + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var m: ChatModel + @State private var editMode = EditMode.inactive + @State private var tagEditorNavParams: TagEditorNavParams? = nil + + var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] } + + var body: some View { + List { + Section { + ForEach(chatTagsModel.userTags, id: \.id) { tag in + let text = tag.chatTagText + let emoji = tag.chatTagEmoji + let tagId = tag.chatTagId + let selected = chatTagsIds.contains(tagId) + + HStack { + if let emoji { + Text(emoji) + } else { + Image(systemName: "tag") + } + Text(text) + .padding(.leading, 12) + Spacer() + if chat != nil { + radioButton(selected: selected) + } + } + .contentShape(Rectangle()) + .onTapGesture { + if let c = chat { + setTag(tagId: selected ? nil : tagId, chat: c) + } else { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + showAlert( + NSLocalizedString("Delete list?", comment: "alert title"), + message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ), + UIAlertAction( + title: NSLocalizedString("Delete", comment: "alert action"), + style: .destructive, + handler: { _ in + deleteTag(tagId) + } + ) + ]} + ) + } label: { + Label("Delete", systemImage: "trash.fill") + } + .tint(.red) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(theme.colors.primary) + } + .background( + // isActive required to navigate to edit view from any possible tag edited in swipe action + NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) { + if let params = tagEditorNavParams { + ChatListTagEditor( + chat: params.chat, + tagId: params.tagId, + emoji: params.chatListTag?.emoji, + name: params.chatListTag?.text ?? "" + ) + } + } label: { + EmptyView() + } + .opacity(0) + ) + } + .onMove(perform: moveItem) + + NavigationLink { + ChatListTagEditor(chat: chat) + } label: { + Label("Create list", systemImage: "plus") + } + } header: { + if showEditButton { + editTagsButton() + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + .modifier(ThemedBackground(grouped: true)) + .environment(\.editMode, $editMode) + } + + private func editTagsButton() -> some View { + if editMode.isEditing { + Button("Done") { + editMode = .inactive + dismiss() + } + } else { + Button("Edit") { + editMode = .active + } + } + } + + @ViewBuilder private func radioButton(selected: Bool) -> some View { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) + } + + private func moveItem(from source: IndexSet, to destination: Int) { + Task { + do { + var tags = chatTagsModel.userTags + tags.move(fromOffsets: source, toOffset: destination) + try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId }) + + await MainActor.run { + chatTagsModel.userTags = tags + } + } catch let error { + showAlert( + NSLocalizedString("Error reordering lists", comment: "alert title"), + message: responseError(error) + ) + } + } + } + + private func setTag(tagId: Int64?, chat: Chat) { + Task { + do { + let tagIds: [Int64] = if let t = tagId { [t] } else {[]} + let (userTags, chatTags) = try await apiSetChatTags( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + tagIds: tagIds + ) + + await MainActor.run { + chatTagsModel.userTags = userTags + if var contact = chat.chatInfo.contact { + contact.chatTags = chatTags + m.updateContact(contact) + } else if var group = chat.chatInfo.groupInfo { + group.chatTags = chatTags + m.updateGroup(group) + } + dismiss() + } + } catch let error { + showAlert( + NSLocalizedString("Error saving chat list", comment: "alert title"), + message: responseError(error) + ) + } + } + } + + private func deleteTag(_ tagId: Int64) { + Task { + try await apiDeleteChatTag(tagId: tagId) + + await MainActor.run { + chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId } + if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId { + chatTagsModel.activeFilter = nil + } + m.chats.forEach { c in + if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) { + contact.chatTags = contact.chatTags.filter({ $0 != tagId }) + m.updateContact(contact) + } else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) { + group.chatTags = group.chatTags.filter({ $0 != tagId }) + m.updateGroup(group) + } + } + } + } + } +} + +struct EmojiPickerView: UIViewControllerRepresentable { + @Binding var selectedEmoji: String? + @Binding var showingPicker: Bool + @Environment(\.presentationMode) var presentationMode + + class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate { + var parent: EmojiPickerView + + init(parent: EmojiPickerView) { + self.parent = parent + } + + func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) { + parent.selectedEmoji = emoji?.emoji + parent.showingPicker = false + picker.dismiss(animated: true) + } + + // Called when the picker is dismissed manually (without selection) + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + parent.showingPicker = false + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(parent: self) + } + + func makeUIViewController(context: Context) -> UIViewController { + let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false) + let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config) + + picker.presentationController?.delegate = context.coordinator + + let viewController = UIViewController() + DispatchQueue.main.async { + if let topVC = getTopViewController() { + topVC.present(picker, animated: true) + } + } + + return viewController + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // No need to update the controller after creation + } +} + +struct ChatListTagEditor: View { + var chat: Chat? = nil + var tagId: Int64? = nil + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var theme: AppTheme + var emoji: String? + var name: String = "" + @State private var newEmoji: String? + @State private var newName: String = "" + @State private var isPickerPresented = false + @State private var saving: Bool? + + var body: some View { + VStack { + List { + let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in + tag.chatTagId != tagId && + ((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName) + } + + Section { + HStack { + Button { + isPickerPresented = true + } label: { + if let newEmoji { + Text(newEmoji) + } else { + Image(systemName: "face.smiling") + .foregroundColor(.secondary) + } + } + TextField("List name...", text: $newName) + } + + Button { + saving = true + if let tId = tagId { + updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName)) + } else { + createChatTag() + } + } label: { + Text(NSLocalizedString(tagId == nil ? "Create list" : "Save list", comment: "list editor button")) + } + .disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName) + } footer: { + if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering + HStack { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + Text("List name and emoji should be different for all lists.") + .foregroundColor(theme.colors.secondary) + } + } + } + } + + if isPickerPresented { + EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented) + } + } + .modifier(ThemedBackground(grouped: true)) + .onAppear { + newEmoji = emoji + newName = name + } + } + + var trimmedName: String { + newName.trimmingCharacters(in: .whitespaces) + } + + private func createChatTag() { + Task { + do { + let userTags = try await apiCreateChatTag( + tag: ChatTagData(emoji: newEmoji , text: trimmedName) + ) + await MainActor.run { + saving = false + chatTagsModel.userTags = userTags + dismiss() + } + } catch let error { + await MainActor.run { + saving = nil + showAlert( + NSLocalizedString("Error creating list", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) { + Task { + do { + try await apiUpdateChatTag(tagId: tagId, tag: chatTagData) + await MainActor.run { + saving = false + for i in 0.. Alert { Alert( title: Text("Reject contact request"), @@ -585,15 +1044,15 @@ struct ChatListNavLink_Previews: PreviewProvider { ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), parentSheet: .constant(nil)) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] - )) + ), parentSheet: .constant(nil)) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.contactRequest, chatItems: [] - )) + ), parentSheet: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 82)) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b18e9295b9..9cb87a4b22 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -31,6 +31,29 @@ enum UserPickerSheet: Identifiable { } } +enum PresetTag: Int, Identifiable, CaseIterable, Equatable { + case favorites = 0 + case contacts = 1 + case groups = 2 + case business = 3 + + var id: Int { rawValue } +} + +enum ActiveFilter: Identifiable, Equatable { + case presetTag(PresetTag) + case userTag(ChatTag) + case unread + + var id: String { + switch self { + case let .presetTag(tag): "preset \(tag.id)" + case let .userTag(tag): "user \(tag.chatTagId)" + case .unread: "unread" + } + } +} + class SaveableSettings: ObservableObject { @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) } @@ -117,13 +140,14 @@ struct ChatListView: View { @State private var searchChatFilteredBySimplexLink: String? = nil @State private var scrollToSearchBar = false @State private var userPickerShown: Bool = false - - @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false + @State private var sheet: SomeSheet? = nil + @StateObject private var chatTagsModel = ChatTagsModel.shared + @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial - + var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -131,7 +155,7 @@ struct ChatListView: View { viewBody } } - + private var viewBody: some View { ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) { NavStackCompat( @@ -161,8 +185,9 @@ struct ChatListView: View { } } } + .environmentObject(chatTagsModel) } - + private var chatListView: some View { let tm = ToolbarMaterial.material(toolbarMaterial) return withToolbar(tm) { @@ -197,15 +222,22 @@ struct ChatListView: View { Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm) } } + .sheet(item: $sheet) { sheet in + if #available(iOS 16.0, *) { + sheet.content.presentationDetents([.fraction(sheet.fraction)]) + } else { + sheet.content + } + } } - + static var hasHomeIndicator: Bool = { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { window.safeAreaInsets.bottom > 0 } else { false } }() - + @ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View { if #available(iOS 16.0, *) { if oneHandUI { @@ -226,13 +258,13 @@ struct ChatListView: View { } } } - + @ToolbarContentBuilder var topToolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } ToolbarItem(placement: .principal) { SubsStatusIndicator() } ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem } } - + @ToolbarContentBuilder var bottomToolbar: some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItem(placement: .bottomBar) { @@ -247,7 +279,7 @@ struct ChatListView: View { .onTapGesture { scrollToSearchBar = true } } } - + @ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItemGroup(placement: .bottomBar) { @@ -258,7 +290,7 @@ struct ChatListView: View { trailingToolbarItem.padding(.bottom, padding) } } - + @ViewBuilder var leadingToolbarItem: some View { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { @@ -275,7 +307,7 @@ struct ChatListView: View { userPickerShown = true } } - + @ViewBuilder var trailingToolbarItem: some View { switch chatModel.chatRunning { case .some(true): NewChatMenuButton() @@ -283,7 +315,7 @@ struct ChatListView: View { case .none: EmptyView() } } - + @ViewBuilder private var chatList: some View { let cs = filteredChats() ZStack { @@ -295,7 +327,8 @@ struct ChatListView: View { searchFocussed: $searchFocussed, searchText: $searchText, searchShowingSimplexLink: $searchShowingSimplexLink, - searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink + searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink, + parentSheet: $sheet ) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) @@ -306,7 +339,7 @@ struct ChatListView: View { } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in - ChatListNavLink(chat: chat) + ChatListNavLink(chat: chat, parentSheet: $sheet) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .padding(.trailing, -16) .disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id)) @@ -318,7 +351,7 @@ struct ChatListView: View { VStack(spacing: .zero) { Divider() .padding(.leading, 16) - ChatListNavLink(chat: chat) + ChatListNavLink(chat: chat, parentSheet: $sheet) .padding(.horizontal, 8) .padding(.vertical, 6) } @@ -363,80 +396,97 @@ struct ChatListView: View { } } if cs.isEmpty && !chatModel.chats.isEmpty { - Text("No filtered chats") + noChatsView() .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .foregroundColor(.secondary) } } } + + @ViewBuilder private func noChatsView() -> some View { + if searchString().isEmpty { + switch chatTagsModel.activeFilter { + case .presetTag: Text("No filtered chats") // this should not happen + case let .userTag(tag): Text("No chats in list \(tag.chatTagText)") + case .unread: + Button { + chatTagsModel.activeFilter = nil + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease") + Text("No unread chats") + } + } + case .none: Text("No chats") + } + } else { + Text("No chats found") + } + } + private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) .foregroundColor(theme.colors.primary) } - + @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { ChatView(chat: chat) } } - + func stopAudioPlayer() { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } - + private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } } else { let s = searchString() - return s == "" && !showUnreadAndFavorites + return s == "" ? chatModel.chats.filter { chat in - !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card + !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat) } : chatModel.chats.filter { chat in let cInfo = chat.chatInfo - switch cInfo { + return switch cInfo { case let .direct(contact): - return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && ( - s == "" - ? filtered(chat) - : (viewNameContains(cInfo, s) || - contact.profile.displayName.localizedLowercase.contains(s) || - contact.fullName.localizedLowercase.contains(s)) + !contact.chatDeleted && !chat.chatInfo.contactCard && ( + ( viewNameContains(cInfo, s) || + contact.profile.displayName.localizedLowercase.contains(s) || + contact.fullName.localizedLowercase.contains(s) + ) ) - case let .group(gInfo): - return s == "" - ? (filtered(chat) || gInfo.membership.memberStatus == .memInvited) - : viewNameContains(cInfo, s) - case .local: - return s == "" || viewNameContains(cInfo, s) - case .contactRequest: - return s == "" || viewNameContains(cInfo, s) - case let .contactConnection(conn): - return s != "" && conn.localAlias.localizedLowercase.contains(s) - case .invalidJSON: - return false + case .group: viewNameContains(cInfo, s) + case .local: viewNameContains(cInfo, s) + case .contactRequest: viewNameContains(cInfo, s) + case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s) + case .invalidJSON: false } } } - - func searchString() -> String { - searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - } - + func filtered(_ chat: Chat) -> Bool { - (chat.chatInfo.chatSettings?.favorite ?? false) || - chat.chatStats.unreadChat || - (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) + switch chatTagsModel.activeFilter { + case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo) + case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true + case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 + case .none: true + } } - + func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { cInfo.chatViewName.localizedLowercase.contains(s) } } + + func searchString() -> String { + searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + } } struct SubsStatusIndicator: View { @@ -500,18 +550,20 @@ struct SubsStatusIndicator: View { struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatTagsModel: ChatTagsModel @Binding var searchMode: Bool @FocusState.Binding var searchFocussed: Bool @Binding var searchText: String @Binding var searchShowingSimplexLink: Bool @Binding var searchChatFilteredBySimplexLink: String? + @Binding var parentSheet: SomeSheet? @State private var ignoreSearchTextChange = false @State private var alert: PlanAndConnectAlert? @State private var sheet: PlanAndConnectActionSheet? - @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false var body: some View { VStack(spacing: 12) { + ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet) } HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") @@ -578,16 +630,21 @@ struct ChatListSearchBar: View { } private func toggleFilterButton() -> some View { - ZStack { + let showUnread = chatTagsModel.activeFilter == .unread + return ZStack { Color.clear .frame(width: 22, height: 22) - Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease") + Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease") .resizable() .scaledToFit() - .foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary) - .frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16) + .foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary) + .frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16) .onTapGesture { - showUnreadAndFavorites = !showUnreadAndFavorites + if chatTagsModel.activeFilter == .unread { + chatTagsModel.activeFilter = nil + } else { + chatTagsModel.activeFilter = .unread + } } } } @@ -605,6 +662,185 @@ struct ChatListSearchBar: View { } } +struct ChatTagsView: View { + @EnvironmentObject var chatTagsModel: ChatTagsModel + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @Binding var parentSheet: SomeSheet? + + var body: some View { + HStack { + tagsView() + } + } + + @ViewBuilder private func tagsView() -> some View { + if chatTagsModel.presetTags.count > 1 { + if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 { + expandedPresetTagsFiltersView() + } else { + collapsedTagsFilterView() + } + } + let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter { + tag + } else { + nil + } + ForEach(chatTagsModel.userTags, id: \.id) { tag in + let current = tag == selectedTag + let color: Color = current ? .accentColor : .secondary + ZStack { + HStack(spacing: 4) { + if let emoji = tag.chatTagEmoji { + Text(emoji) + } else { + Image(systemName: current ? "tag.fill" : "tag") + .foregroundColor(color) + } + ZStack { + let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? " ●" : "").font(.footnote) + (Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear) + Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary) + } + } + .onTapGesture { + setActiveFilter(filter: .userTag(tag)) + } + .onLongPressGesture { + let screenHeight = UIScreen.main.bounds.height + let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag + let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44 + let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62) + + parentSheet = SomeSheet( + content: { + AnyView( + NavigationView { + ChatListTag(chat: nil, showEditButton: true) + .modifier(ThemedBackground(grouped: true)) + } + ) + }, + id: "tag list", + fraction: fraction + ) + } + } + } + + Button { + parentSheet = SomeSheet( + content: { + AnyView( + NavigationView { + ChatListTagEditor() + } + ) + }, + id: "tag create" + ) + } label: { + if chatTagsModel.userTags.isEmpty { + HStack(spacing: 4) { + Image(systemName: "plus") + Text("Add list") + } + } else { + Image(systemName: "plus") + } + } + .foregroundColor(.secondary) + } + + @ViewBuilder private func expandedPresetTagsFiltersView() -> some View { + let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter { + tag + } else { + nil + } + ForEach(PresetTag.allCases, id: \.id) { tag in + if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + let active = tag == selectedPresetTag + let (icon, text) = presetTagLabel(tag: tag, active: active) + let color: Color = active ? .accentColor : .secondary + + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + ZStack { + Text(text).fontWeight(.semibold).foregroundColor(.clear) + Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) + } + } + .onTapGesture { + setActiveFilter(filter: .presetTag(tag)) + } + } + } + } + + @ViewBuilder private func collapsedTagsFilterView() -> some View { + let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter { + tag + } else { + nil + } + Menu { + if selectedPresetTag != nil { + Button { + chatTagsModel.activeFilter = nil + } label: { + HStack { + Image(systemName: "list.bullet") + Text("All") + } + } + } + ForEach(PresetTag.allCases, id: \.id) { tag in + if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + Button { + setActiveFilter(filter: .presetTag(tag)) + } label: { + let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag) + HStack { + Image(systemName: systemName) + Text(text) + } + } + } + } + } label: { + if let tag = selectedPresetTag { + let (systemName, _) = presetTagLabel(tag: tag, active: true) + Image(systemName: systemName) + .foregroundColor(.accentColor) + } else { + Image(systemName: "list.bullet") + .foregroundColor(.secondary) + } + } + .frame(minWidth: 28) + } + + private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) { + switch tag { + case .favorites: (active ? "star.fill" : "star", "Favorites") + case .contacts: (active ? "person.fill" : "person", "Contacts") + case .groups: (active ? "person.2.fill" : "person.2", "Groups") + case .business: (active ? "briefcase.fill" : "briefcase", "Businesses") + } + } + + private func setActiveFilter(filter: ActiveFilter) { + if filter != chatTagsModel.activeFilter { + chatTagsModel.activeFilter = filter + } else { + chatTagsModel.activeFilter = nil + } + } +} + func chatStoppedIcon() -> some View { Button { AlertManager.shared.showAlertMsg( @@ -616,6 +852,28 @@ func chatStoppedIcon() -> some View { } } +func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { + switch tag { + case .favorites: + chatInfo.chatSettings?.favorite == true + case .contacts: + switch chatInfo { + case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted + case .contactRequest: true + case .contactConnection: true + case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer + default: false + } + case .groups: + switch chatInfo { + case let .group(groupInfo): groupInfo.businessChat == nil + default: false + } + case .business: + chatInfo.groupInfo?.businessChat?.chatType == .business + } +} + struct ChatListView_Previews: PreviewProvider { @State static var userPickerSheet: UserPickerSheet? = .none diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index 898a47cc86..242b492e83 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -20,7 +20,7 @@ struct ContactListNavLink: View { @State private var showContactRequestDialog = false var body: some View { - let contactType = chatContactType(chat: chat) + let contactType = chatContactType(chat) Group { switch (chat.chatInfo) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 6f973983bf..39656c1534 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -186,7 +186,7 @@ struct NewChatSheet: View { } } -func chatContactType(chat: Chat) -> ContactType { +func chatContactType(_ chat: Chat) -> ContactType { switch chat.chatInfo { case .contactRequest: return .request @@ -207,7 +207,7 @@ func chatContactType(chat: Chat) -> ContactType { private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] { return chats.filter { chat in - contactTypes.contains(chatContactType(chat: chat)) + contactTypes.contains(chatContactType(chat)) } } @@ -279,8 +279,8 @@ struct ContactsList: View { } private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool { - let chat1Type = chatContactType(chat: chat1) - let chat2Type = chatContactType(chat: chat2) + let chat1Type = chatContactType(chat1) + let chat2Type = chatContactType(chat2) if chat1Type.rawValue < chat2Type.rawValue { return true diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index e18d932278..6e898f4cdf 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -25,6 +25,7 @@ struct SomeActionSheet: Identifiable { struct SomeSheet: Identifiable { @ViewBuilder var content: Content var id: String + var fraction = 0.4 } private enum NewChatViewAlert: Identifiable { diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift index 2069ca9487..c8d0faafa7 100644 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -21,7 +21,7 @@ struct AddressCreationCard: View { var body: some View { let addressExists = chatModel.userAddress != nil let chats = chatModel.chats.filter { chat in - !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card + !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard } ZStack(alignment: .topTrailing) { HStack(alignment: .top, spacing: 16) { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 14ad9dfb08..409cb859ea 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -174,7 +174,6 @@ struct CreateFirstProfile: View { } .onAppear() { focusDisplayName = true - setLastVersionDefault() } .padding(.horizontal, 25) .padding(.top, 10) diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index a8704e964b..40dd29db53 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -89,6 +89,9 @@ struct SimpleXInfo: View { ) } } + .onAppear() { + setLastVersionDefault() + } .frame(maxHeight: .infinity) .padding(.horizontal, 25) .padding(.top, 75) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 759b16b196..ef98ddc678 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -167,9 +167,9 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; }; - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */; }; + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; }; 649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; }; - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */; }; + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; }; 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; @@ -203,6 +203,7 @@ 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; + B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; }; @@ -516,9 +517,9 @@ 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a"; sourceTree = ""; }; + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = ""; }; 649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a"; sourceTree = ""; }; + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = ""; }; 649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; 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 = ""; }; @@ -636,6 +637,7 @@ buildActionMask = 2147483647; files = ( 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */, + B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */, 8C8118722C220B5B00E6FC94 /* Yams in Frameworks */, 8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */, D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */, @@ -671,9 +673,9 @@ 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a in Frameworks */, + 649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, - 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a in Frameworks */, + 649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */, 649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -754,8 +756,8 @@ 649B28D82CFE07CF00536B68 /* libffi.a */, 649B28DC2CFE07CF00536B68 /* libgmp.a */, 649B28DA2CFE07CF00536B68 /* libgmpxx.a */, - 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E-ghc9.6.3.a */, - 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-3p784Fmu4gOAiEiFcsHj1E.a */, + 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */, + 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */, ); path = Libraries; sourceTree = ""; @@ -1186,6 +1188,7 @@ D7197A1729AE89660055C05A /* WebRTC */, 8C8118712C220B5B00E6FC94 /* Yams */, 8CB3476B2CF5CFFA006787A5 /* Ink */, + B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */, ); productName = "SimpleX (iOS)"; productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; @@ -1330,6 +1333,7 @@ D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */, 8C73C1162C21E17B00892670 /* XCRemoteSwiftPackageReference "Yams" */, 8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */, + B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */, ); productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; projectDirPath = ""; @@ -2387,6 +2391,14 @@ version = 0.6.0; }; }; + B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Finalet/Elegant-Emoji-Picker"; + requirement = { + branch = main; + kind = branch; + }; + }; D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/simplex-chat/WebRTC.git"; @@ -2429,6 +2441,11 @@ package = 8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */; productName = Ink; }; + B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */ = { + isa = XCSwiftPackageProductDependency; + package = B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */; + productName = ElegantEmojiPicker; + }; CE38A29B2C3FCD72005ED185 /* SwiftyGif */ = { isa = XCSwiftPackageProductDependency; package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7fdbff38af..2bddf5b5b8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "33afc44be5f4225325b3cb940ed71b6cbf3ef97290d348d7b6803697bcd0637d", + "originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1", "pins" : [ { "identity" : "codescanner", @@ -10,6 +10,15 @@ "version" : "2.5.0" } }, + { + "identity" : "elegant-emoji-picker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Finalet/Elegant-Emoji-Picker", + "state" : { + "branch" : "main", + "revision" : "71d2d46092b4d550cc593614efc06438f845f6e6" + } + }, { "identity" : "ink", "kind" : "remoteSourceControl", diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 884993f542..66edb9f2b2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -40,10 +40,16 @@ public enum ChatCommand { case testStorageEncryption(key: String) case apiSaveSettings(settings: AppSettings) case apiGetSettings(settings: AppSettings) + case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiCreateChatTag(tag: ChatTagData) + case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) + case apiDeleteChatTag(tagId: Int64) + case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData) + case apiReorderChatTags(tagIds: [Int64]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) @@ -198,6 +204,7 @@ public enum ChatCommand { case let .testStorageEncryption(key): return "/db test key \(key)" case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" + case let .apiGetChatTags(userId): return "/_get tags \(userId)" case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") @@ -206,6 +213,11 @@ public enum ChatCommand { let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" + case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" + case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))" + case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" @@ -367,10 +379,16 @@ public enum ChatCommand { case .testStorageEncryption: return "testStorageEncryption" case .apiSaveSettings: return "apiSaveSettings" case .apiGetSettings: return "apiGetSettings" + case .apiGetChatTags: return "apiGetChatTags" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiSendMessages: return "apiSendMessages" + case .apiCreateChatTag: return "apiCreateChatTag" + case .apiSetChatTags: return "apiSetChatTags" + case .apiDeleteChatTag: return "apiDeleteChatTag" + case .apiUpdateChatTag: return "apiUpdateChatTag" + case .apiReorderChatTags: return "apiReorderChatTags" case .apiCreateChatItems: return "apiCreateChatItems" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" @@ -564,6 +582,7 @@ public enum ChatResponse: Decodable, Error { case chatSuspended case apiChats(user: UserRef, chats: [ChatData]) case apiChat(user: UserRef, chat: ChatData) + case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) @@ -590,6 +609,7 @@ public enum ChatResponse: Decodable, Error { case contactCode(user: UserRef, contact: Contact, connectionCode: String) case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) + case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64]) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) @@ -741,6 +761,7 @@ public enum ChatResponse: Decodable, Error { case .chatSuspended: return "chatSuspended" case .apiChats: return "apiChats" case .apiChat: return "apiChat" + case .chatTags: return "chatTags" case .chatItemInfo: return "chatItemInfo" case .serverTestResult: return "serverTestResult" case .serverOperatorConditions: return "serverOperators" @@ -767,6 +788,7 @@ public enum ChatResponse: Decodable, Error { case .contactCode: return "contactCode" case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" + case .tagsUpdated: return "tagsUpdated" case .invitation: return "invitation" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" case .connectionUserChanged: return "connectionUserChanged" @@ -914,6 +936,7 @@ public enum ChatResponse: Decodable, Error { case .chatSuspended: return noDetails case let .apiChats(u, chats): return withUser(u, String(describing: chats)) case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" @@ -942,6 +965,7 @@ public enum ChatResponse: Decodable, Error { case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))") case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))") @@ -1172,6 +1196,16 @@ public enum ChatPagination { } } +public struct ChatTagData: Encodable { + public var emoji: String? + public var text: String + + public init(emoji: String?, text: String) { + self.emoji = emoji + self.text = text + } +} + public struct ComposedMessage: Encodable { public var fileSource: CryptoFile? var quotedItemId: Int64? diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index da1ce24b73..b81b0b7662 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1334,6 +1334,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var contactCard: Bool { + switch self { + case let .direct(contact): contact.activeConn == nil && contact.profile.contactLink != nil && contact.active + default: false + } + } + public var groupInfo: GroupInfo? { switch self { case let .group(groupInfo): return groupInfo @@ -1444,6 +1451,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { default: return nil } } + + public var chatTags: [Int64]? { + switch self { + case let .direct(contact): return contact.chatTags + case let .group(groupInfo): return groupInfo.chatTags + default: return nil + } + } var createdAt: Date { switch self { @@ -1545,6 +1560,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { var chatTs: Date? var contactGroupMemberId: Int64? var contactGrpInvSent: Bool + public var chatTags: [Int64] public var uiThemes: ThemeModeOverrides? public var chatDeleted: Bool @@ -1615,6 +1631,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { createdAt: .now, updatedAt: .now, contactGrpInvSent: false, + chatTags: [], chatDeleted: false ) } @@ -1910,6 +1927,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } public var localAlias: String { "" } + public var chatTags: [Int64] public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -1932,7 +1950,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { hostConnCustomUserProfileId: nil, chatSettings: ChatSettings.defaults, createdAt: .now, - updatedAt: .now + updatedAt: .now, + chatTags: [] ) } @@ -4210,6 +4229,20 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable { } } +public struct ChatTag: Decodable, Hashable { + public var chatTagId: Int64 + public var chatTagText: String + public var chatTagEmoji: String? + + public var id: Int64 { chatTagId } + + public init(chatTagId: Int64, chatTagText: String, chatTagEmoji: String?) { + self.chatTagId = chatTagId + self.chatTagText = chatTagText + self.chatTagEmoji = chatTagEmoji + } +} + public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? diff --git a/blog/20241016-wired-attack-on-privacy.md b/blog/20241016-wired-attack-on-privacy.md index 3bc69c8176..1c2961e98a 100644 --- a/blog/20241016-wired-attack-on-privacy.md +++ b/blog/20241016-wired-attack-on-privacy.md @@ -10,6 +10,8 @@ permalink: "/blog/20241016-wired-attack-on-privacy.html" # Wired’s Attack on Privacy +**Published:** Oct 16, 2024 + The [Wired article](https://www.wired.com/story/neo-nazis-flee-telegram-encrypted-app-simplex/) by David Gilbert focusing on neo-Nazis moving to SimpleX Chat following the Telegram's changes in privacy policy is biased and misleading. By cherry-picking information from [the report](https://www.isdglobal.org/digital_dispatches/neo-nazi-accelerationists-seek-new-digital-refuge-amid-looming-telegram-crackdown/) by the Institute for Strategic Dialogue (ISD), Wired fails to mention that SimpleX network design prioritizes privacy in order to protect human rights defenders, journalists, and everyday users who value their privacy — many people feel safer using SimpleX than non-private apps, being protected from strangers contacting them. diff --git a/blog/20241218-oppose-digital-ids-they-break-law-lead-to-mass-scale-surveillance.md b/blog/20241218-oppose-digital-ids-they-break-law-lead-to-mass-scale-surveillance.md new file mode 100644 index 0000000000..8a8fffffb4 --- /dev/null +++ b/blog/20241218-oppose-digital-ids-they-break-law-lead-to-mass-scale-surveillance.md @@ -0,0 +1,53 @@ +--- +layout: layouts/article.html +title: "Oppose digital IDs – they break the law and lead to mass scale surveillance" +date: 2024-12-18 +preview: Starting next year, the UK government plans to introduce digital ID cards for the young people to prove their age when visiting pubs. +image: images/20241218-pub.jpg +imageWide: true +permalink: "/blog/20241218-oppose-digital-ids-they-break-law-lead-to-mass-scale-surveillance.html" +--- + +# Oppose digital IDs – they break the law and lead to mass scale surveillance + +**Published:** Dec 18, 2024 + + + +Starting next year, the UK government plans to introduce [digital ID cards](https://www.telegraph.co.uk/politics/2024/12/08/digital-id-to-be-introduced-for-pubs-and-clubs/) for the young people to prove their age when visiting pubs. While officials claim this system will remain optional, it's part of a broader government initiative to move more state functions online so that people can prove their identity for everything from paying taxes to opening a bank account using the government-backed app. This will be a step toward a society where every pub visit, purchase, and social interaction becomes a permanent digital record linked to a government-issued ID – a step to normalizing mass surveillance at scale. + +Digital IDs are promoted as a way to fight law violations, and some politicians support them as [a way to tackle illegal immigration](https://www.telegraph.co.uk/politics/2024/07/10/id-cards-inevitable-tackle-immigration-lord-blunkett-labour/). But digital IDs themselves break the law. Article 8 of the European Convention of Human Rights says: “Everyone has the right to respect for his private and family life”. It means that not only our right to privacy is enshrined in the law, but the right to have our privacy respected is also part of the law. Asking to present a digital ID when visiting a pub, even if it is optional, disrespects our privacy, and is therefore illegal. + +Digital IDs would not stop people who decide to break laws. Pubs already can refuse to serve alcohol to young people and require the ID in case the age is in doubt. And illegal immigration can also be reduced without any digital IDs. But introducing digital IDs and collecting our actions, names and locations in one government-controlled database will result in making this information easier to access for criminals, and being exploited for financial and identity crimes. + +What starts as a "convenient option" is likely to end as a mandatory requirement. The digital ID systems being pushed by governments and corporations aren't about making our lives easier. They're about tracking, monitoring, and controlling every move we make. And we can see [where this road leads in China](https://www.wired.com/story/china-social-credit-system-explained/), when IDs and social scores created for convenience are used to prevent access to basic services and bank accounts as a punishment for legal social media posts that the government disagrees with. What started as a convenience, is now trialed [to track the duration of public toilet visits](https://www.thesun.ie/news/13154812/china-installs-toilet-timers-to-broadcast-time-spent/). + +The United Kingdom is a democratic country, and the law protects our right to privacy and freedom of speech. If we accept digital IDs as something required for simple things, like buying a drink, it leaves the door wide open to a range of privacy violations. + +We call on everyone to oppose the digital ID systems. Do not use them. Do not install these systems in your pub, for as long as it is not legally required. Support local businesses that don’t use them. Protect your privacy and freedom by using software that respects them. Demand that your privacy is respected, as required by law. + +To make your voice heard, email your MP expressing your rejection of digital IDs as a violation of European Convention of Human Rights in three simple steps: + +1. **Copy the text below** or [click this link](mailto:?subject=Please%20oppose%20the%20plan%20for%20Digital%20IDs&body=Dear%20%E2%80%A6%2C%0A%0AI%20object%20to%20the%20introduction%20of%20digital%20IDs%20in%20pubs%20or%20any%20other%20public%20places%20for%20these%20reasons%3A%0A%0A1.%20It%20violates%20the%20European%20Convention%20of%20Human%20Rights%2C%20article%208%3A%20%E2%80%9CEveryone%20has%20the%20right%20to%20respect%20for%20his%20private%20and%20family%20life%E2%80%9D%20(https%3A%2F%2Ffra.europa.eu%2Fen%2Flaw-reference%2Feuropean-convention-human-rights-article-8-0).%0AAsking%20to%20present%20digital%20IDs%20when%20proof%20of%20identity%20is%20not%20legally%20required%2C%20even%20if%20it%20is%20optional%2C%20disrespects%20our%20privacy%2C%20and%20is%20therefore%20illegal.%0A%0A2.%20It%20will%20not%20be%20an%20effective%20measure%20in%20reducing%20the%20violations%20of%20the%20law.%20People%20who%20want%20to%20circumvent%20it%2C%20will%20find%20a%20way.%0A%0A3.%20It%20will%20increase%20crime%2C%20because%20combining%20a%20large%20amount%20of%20private%20information%20in%20a%20single%20system%20increases%20the%20risks%20of%20this%20information%20becoming%20available%20to%20criminals%2C%20who%20will%20exploit%20it%20for%20financial%20crimes%20and%20identity%20theft.%0A%0AI%20kindly%20ask%20you%20to%20oppose%20this%20plan%2C%20both%20publicly%20and%20during%20any%20discussions%20in%20the%20UK%20Parliament.%0A%0ASincerely%20yours%2C%0A%E2%80%A6) to copy it into email app: + +*Dear …,* + +*I object to the introduction of digital IDs in pubs or any other public places for these reasons:* + +1. *It violates the European Convention of Human Rights, article 8: “Everyone has the right to respect for his private and family life” (https://fra.europa.eu/en/law-reference/european-convention-human-rights-article-8-0).* +*Asking to present digital IDs when proof of identity is not legally required, even if it is optional, disrespects our privacy, and is therefore illegal.* +2. *It will not be an effective measure in reducing the violations of the law. People who want to circumvent it, will find a way.* +3. *It will increase crime, because combining a large amount of private information in a single system increases the risks of this information becoming available to criminals, who will exploit it for financial crimes and identity theft.* + +*I kindly ask you to oppose this plan, both publicly and during any discussions in the UK Parliament.* + +*Sincerely yours,* +*…* + +2. [**Find the email address of your MP**](https://members.parliament.uk/members/Commons) and copy it to the email. + +3. Fill in the blanks, edit the text if needed, and **send it**! + +Public opposition changed government decisions in many cases. + +It is your opportunity to tell the government which country you want to live in — please use it! diff --git a/blog/images/20241218-pub.jpg b/blog/images/20241218-pub.jpg new file mode 100644 index 0000000000..0acd5d7dc1 Binary files /dev/null and b/blog/images/20241218-pub.jpg differ diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 9570f4bbca..9b915532f7 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,25 @@ + + https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +

New in v6.2.1:

+
    +
  • fixes
  • +
  • offer to "fix" encryption when calling or making direct connection with member.
  • +
  • broken layout.
  • +
  • option to enable debug logs (disabled by default).
  • +
  • show who reacted in direct chats.
  • +
+

New in v6.2:

+
    +
  • SimpleX Chat and Flux made an agreement to include servers operated by Flux into the app – to improve metadata privacy.
  • +
  • Business chats – your customers privacy.
  • +
  • Improved user experience in chats: open on the first unread, jump to quoted messages, see who reacted.
  • +
+
+
https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 29e748c4e8..b72e84084b 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -155,6 +155,7 @@ library Simplex.Chat.Migrations.M20241125_indexes Simplex.Chat.Migrations.M20241128_business_chats Simplex.Chat.Migrations.M20241205_business_chat_members + Simplex.Chat.Migrations.M20241206_chat_tags Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index dfa05951ff..cd70dd05ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -847,6 +847,9 @@ processChatCommand' vr = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) + APIGetChatTags userId -> withUserId' userId $ \user -> do + tags <- withFastStore' (`getUserChatTags` user) + pure $ CRChatTags user tags APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do (errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) @@ -894,6 +897,26 @@ processChatCommand' vr = \case CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do + _ <- createChatTag db user emoji text + CRChatTags user <$> getUserChatTags db user + APISetChatTags (ChatRef cType chatId) tagIds -> withUser $ \user -> withFastStore' $ \db -> case cType of + CTDirect -> do + updateDirectChatTags db chatId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId + CTGroup -> do + updateGroupChatTags db chatId (maybe [] L.toList tagIds) + CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId + _ -> pure $ chatCmdError (Just user) "not supported" + APIDeleteChatTag tagId -> withUser $ \user -> do + withFastStore' $ \db -> deleteChatTag db user tagId + ok user + APIUpdateChatTag tagId (ChatTagData emoji text) -> withUser $ \user -> do + withFastStore' $ \db -> updateChatTag db user tagId emoji text + ok user + APIReorderChatTags tagIds -> withUser $ \user -> do + withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds + ok user APICreateChatItems folderId cms -> withUser $ \user -> createNoteFolderContentItems user folderId (L.map (,Nothing) cms) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of @@ -8391,6 +8414,7 @@ chatCommandP = "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, + "/_get tags " *> (APIGetChatTags <$> A.decimal), "/_get chats " *> ( APIGetChats <$> A.decimal @@ -8402,6 +8426,11 @@ chatCommandP = "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), + "/_create tag " *> (APICreateChatTag <$> jsonP), + "/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP), + "/_delete tag " *> (APIDeleteChatTag <$> A.decimal), + "/_update tag " *> (APIUpdateChatTag <$> A.decimal <* A.space <*> jsonP), + "/_reorder tags " *> (APIReorderChatTags <$> strP), "/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 593c328d0c..ffefddd701 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -294,11 +294,17 @@ data ChatCommand | ExecChatStoreSQL Text | ExecAgentStoreSQL Text | SlowSQLQueries + | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId | APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} + | APICreateChatTag ChatTagData + | APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId)) + | APIDeleteChatTag ChatTagId + | APIUpdateChatTag ChatTagId ChatTagData + | APIReorderChatTags (NonEmpty ChatTagId) | APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage} | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode @@ -587,6 +593,7 @@ data ChatResponse | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} + | CRChatTags {user :: User, userTags :: [ChatTag]} | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) @@ -617,6 +624,7 @@ data ChatResponse | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} + | CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]} | CRNewChatItems {user :: User, chatItems :: [AChatItem]} | CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} @@ -1068,6 +1076,16 @@ instance FromJSON ComposedMessage where parseJSON invalid = JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid) +data ChatTagData = ChatTagData + { emoji :: Maybe Text, + text :: Text + } + deriving (Show) + +instance FromJSON ChatTagData where + parseJSON (J.Object v) = ChatTagData <$> v .:? "emoji" <*> v .: "text" + parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid) + data NtfConn = NtfConn { user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, @@ -1603,3 +1621,5 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig) $(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig) $(JQ.deriveToJSON defaultJSON ''ComposedMessage) + +$(JQ.deriveToJSON defaultJSON ''ChatTagData) diff --git a/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs b/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs new file mode 100644 index 0000000000..2476512814 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20241206_chat_tags.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20241206_chat_tags where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20241206_chat_tags :: Query +m20241206_chat_tags = + [sql| +CREATE TABLE chat_tags ( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); + +CREATE TABLE chat_tags_chats ( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); + +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji); + +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id); +|] + +down_m20241206_chat_tags :: Query +down_m20241206_chat_tags = + [sql| +DROP INDEX idx_chat_tags_user_id; +DROP INDEX idx_chat_tags_user_id_chat_tag_text; +DROP INDEX idx_chat_tags_user_id_chat_tag_emoji; + +DROP INDEX idx_chat_tags_chats_chat_tag_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id; +DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id; + +DROP TABLE chat_tags_chats; +DROP TABLE chat_tags; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 94ccc65b7f..5f48f4d63e 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -623,6 +623,18 @@ CREATE TABLE operator_usage_conditions( accepted_at TEXT, created_at TEXT NOT NULL DEFAULT(datetime('now')) ); +CREATE TABLE chat_tags( + chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users, + chat_tag_text TEXT NOT NULL, + chat_tag_emoji TEXT, + tag_order INTEGER NOT NULL +); +CREATE TABLE chat_tags_chats( + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -929,3 +941,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items( created_at ); CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id); +CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags( + user_id, + chat_tag_text +); +CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags( + user_id, + chat_tag_emoji +); +CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats( + contact_id, + chat_tag_id +); +CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats( + group_id, + chat_tag_id +); diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index db787b0112..6783dae99e 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -21,11 +21,14 @@ where import Control.Applicative ((<|>)) import Control.Monad import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Bitraversable (bitraverse) import Data.Int (Int64) import Data.Maybe (catMaybes, fromMaybe) import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Protocol +import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles @@ -93,8 +96,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do (userId, agentConnId) getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact getContactRec_ contactId c = ExceptT $ do - toContact' contactId c - <$> DB.query + chatTags <- getDirectChatTags db contactId + firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $ + DB.query db [sql| SELECT @@ -105,17 +109,16 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [ContactRow'] -> Either StoreError Contact - toContact' contactId conn [(profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)] = + toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact + toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData} - toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) - getGroupAndMember_ groupMemberId c = ExceptT $ do - firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ + getGroupAndMember_ groupMemberId c = do + gm <- ExceptT $ firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -141,9 +144,10 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? |] (groupMemberId, userId, userContactId) + liftIO $ bitraverse (addGroupChatTags db) pure gm toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember) toGroupAndMember c (groupInfoRow :. memberRow) = - let groupInfo = toGroupInfo vr userContactId groupInfoRow + let groupInfo = toGroupInfo vr userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index d5396a0fef..7697f5d5d8 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -79,6 +79,8 @@ module Simplex.Chat.Store.Direct setContactCustomData, setContactUIThemes, setContactChatDeleted, + getDirectChatTags, + updateDirectChatTags, ) where @@ -180,8 +182,8 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash = do (userId, cReqHash) getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact) -getContactByConnReqHash db vr user@User {userId} cReqHash = - maybeFirstRow (toContact vr user) $ +getContactByConnReqHash db vr user@User {userId} cReqHash = do + ct_ <- maybeFirstRow (toContact vr user []) $ DB.query db [sql| @@ -201,6 +203,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = LIMIT 1 |] (userId, cReqHash, CSActive) + mapM (addDirectChatTags db) ct_ createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do @@ -251,6 +254,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, + chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -636,8 +640,8 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact ) insertedRowId db getContact' :: XContactId -> IO (Maybe Contact) - getContact' xContactId = - maybeFirstRow (toContact vr user) $ + getContact' xContactId = do + ct_ <- maybeFirstRow (toContact vr user []) $ DB.query db [sql| @@ -657,13 +661,15 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact LIMIT 1 |] (userId, xContactId) + mapM (addDirectChatTags db) ct_ getGroupInfo' :: XContactId -> IO (Maybe GroupInfo) - getGroupInfo' xContactId = - maybeFirstRow (toGroupInfo vr userContactId) $ + getGroupInfo' xContactId = do + g_ <- maybeFirstRow (toGroupInfo vr userContactId []) $ DB.query db (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") (xContactId, userId, userContactId) + mapM (addGroupChatTags db) g_ getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) getContactRequestByXContactId xContactId = maybeFirstRow toContactRequest $ @@ -819,6 +825,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False, + chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing @@ -845,8 +852,9 @@ getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT Stor getContact db vr user contactId = getContact_ db vr user contactId False getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact -getContact_ db vr user@User {userId} contactId deleted = - ExceptT . firstRow (toContact vr user) (SEContactNotFound contactId) $ +getContact_ db vr user@User {userId} contactId deleted = do + chatTags <- liftIO $ getDirectChatTags db contactId + ExceptT . firstRow (toContact vr user chatTags) (SEContactNotFound contactId) $ DB.query db [sql| @@ -1018,3 +1026,39 @@ setContactChatDeleted :: DB.Connection -> User -> Contact -> Bool -> IO () setContactChatDeleted db User {userId} Contact {contactId} chatDeleted = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (chatDeleted, updatedAt, userId, contactId) + +updateDirectChatTags :: DB.Connection -> ContactId -> [ChatTagId] -> IO () +updateDirectChatTags db contactId tIds = do + currentTags <- getDirectChatTags db contactId + let tagsToAdd = filter (`notElem` currentTags) tIds + tagsToDelete = filter (`notElem` tIds) currentTags + forM_ tagsToDelete $ untagDirectChat db contactId + forM_ tagsToAdd $ tagDirectChat db contactId + +tagDirectChat :: DB.Connection -> ContactId -> ChatTagId -> IO () +tagDirectChat db contactId tId = + DB.execute + db + [sql| + INSERT INTO chat_tags_chats (contact_id, chat_tag_id) + VALUES (?,?) + |] + (contactId, tId) + +untagDirectChat :: DB.Connection -> ContactId -> ChatTagId -> IO () +untagDirectChat db contactId tId = + DB.execute + db + [sql| + DELETE FROM chat_tags_chats + WHERE contact_id = ? AND chat_tag_id = ? + |] + (contactId, tId) + +getDirectChatTags :: DB.Connection -> ContactId -> IO [ChatTagId] +getDirectChatTags db contactId = map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ?" (Only contactId) + +addDirectChatTags :: DB.Connection -> Contact -> IO Contact +addDirectChatTags db ct = do + chatTags <- getDirectChatTags db $ contactId' ct + pure (ct :: Contact) {chatTags} diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 36ce7f3575..98173800cc 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -122,6 +122,8 @@ module Simplex.Chat.Store.Groups updateUserMemberProfileSentAt, setGroupCustomData, setGroupUIThemes, + updateGroupChatTags, + getGroupChatTags, ) where @@ -130,6 +132,7 @@ import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Bifunctor (second) +import Data.Bitraversable (bitraverse) import Data.Either (rights) import Data.Int (Int64) import Data.List (partition, sortOn) @@ -249,8 +252,8 @@ setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId) getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) -getGroupAndMember db User {userId, userContactId} groupMemberId vr = - ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ +getGroupAndMember db User {userId, userContactId} groupMemberId vr = do + gm <- ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ DB.query db [sql| @@ -285,10 +288,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? |] (userId, groupMemberId, userId, userContactId) + liftIO $ bitraverse (addGroupChatTags db) pure gm where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) toGroupAndMember (groupInfoRow :. memberRow :. connRow) = - let groupInfo = toGroupInfo vr userContactId groupInfoRow + let groupInfo = toGroupInfo vr userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) @@ -333,6 +337,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc updatedAt = currentTs, chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, + chatTags = [], uiThemes = Nothing, customData = Nothing } @@ -401,6 +406,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ updatedAt = currentTs, chatTs = Just currentTs, userMemberProfileSentAt = Just currentTs, + chatTags = [], uiThemes = Nothing, customData = Nothing }, @@ -624,8 +630,8 @@ getUserGroups db vr user@User {userId} = do rights <$> mapM (runExceptT . getGroup db vr user) groupIds getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] -getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = - map (toGroupInfo vr userContactId) +getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do + g_ <- map (toGroupInfo vr userContactId []) <$> DB.query db [sql| @@ -643,6 +649,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%') |] (userId, userContactId, search, search, search) + mapM (addGroupChatTags db) g_ where search = fromMaybe "" search_ @@ -1362,8 +1369,8 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) -getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = - maybeFirstRow toGroupAndMember $ +getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do + gm_ <- maybeFirstRow toGroupAndMember $ DB.query db [sql| @@ -1399,10 +1406,11 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 |] (userId, userId, contactId, userContactId) + mapM (bitraverse (addGroupChatTags db) pure) gm_ where toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) toGroupAndMember (groupInfoRow :. memberRow :. connRow) = - let groupInfo = toGroupInfo vr userContactId groupInfoRow + let groupInfo = toGroupInfo vr userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) @@ -1482,22 +1490,24 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName updateGroupProfile db user g' p' where getGroupProfile = - ExceptT $ firstRow toGroupProfile (SEGroupNotFound groupId) $ - DB.query - db - [sql| + ExceptT $ + firstRow toGroupProfile (SEGroupNotFound groupId) $ + DB.query + db + [sql| SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] - (Only groupId) + (Only groupId) toGroupProfile (displayName, fullName, description, image, groupPreferences) = GroupProfile {displayName, fullName, description, image, groupPreferences} getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db vr User {userId, userContactId} groupId = - ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $ +getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do + chatTags <- getGroupChatTags db groupId + firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ DB.query db (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") @@ -2053,7 +2063,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2090,7 +2100,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -2301,3 +2311,31 @@ setGroupUIThemes :: DB.Connection -> User -> GroupInfo -> Maybe UIThemeEntityOve setGroupUIThemes db User {userId} GroupInfo {groupId} uiThemes = do updatedAt <- getCurrentTime DB.execute db "UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (uiThemes, updatedAt, userId, groupId) + +updateGroupChatTags :: DB.Connection -> GroupId -> [ChatTagId] -> IO () +updateGroupChatTags db gId tIds = do + currentTags <- getGroupChatTags db gId + let tagsToAdd = filter (`notElem` currentTags) tIds + tagsToDelete = filter (`notElem` tIds) currentTags + forM_ tagsToDelete $ untagGroupChat db gId + forM_ tagsToAdd $ tagGroupChat db gId + +tagGroupChat :: DB.Connection -> GroupId -> ChatTagId -> IO () +tagGroupChat db groupId tId = + DB.execute + db + [sql| + INSERT INTO chat_tags_chats (group_id, chat_tag_id) + VALUES (?,?) + |] + (groupId, tId) + +untagGroupChat :: DB.Connection -> GroupId -> ChatTagId -> IO () +untagGroupChat db groupId tId = + DB.execute + db + [sql| + DELETE FROM chat_tags_chats + WHERE group_id = ? AND chat_tag_id = ? + |] + (groupId, tId) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 65fe8223fe..aaf7ecdca1 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -119,6 +119,7 @@ import Simplex.Chat.Migrations.M20241027_server_operators import Simplex.Chat.Migrations.M20241125_indexes import Simplex.Chat.Migrations.M20241128_business_chats import Simplex.Chat.Migrations.M20241205_business_chat_members +import Simplex.Chat.Migrations.M20241206_chat_tags import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -237,7 +238,8 @@ schemaMigrations = ("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators), ("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes), ("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats), - ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members) + ("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members), + ("20241206_chat_tags", m20241206_chat_tags, Just down_m20241206_chat_tags) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 851078ec1f..c6ac85dbd3 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -9,6 +9,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Shared where @@ -391,14 +392,14 @@ type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Mayb type ContactRow = Only ContactId :. ContactRow' -toContact :: VersionRangeChat -> User -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) = +toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} incognito = maybe False connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences incognito - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData} getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById db userId profileId = @@ -552,14 +553,14 @@ type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageD type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = +toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = @@ -592,3 +593,76 @@ groupInfoQuery = JOIN group_members mu ON mu.group_id = g.group_id JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) |] + +createChatTag :: DB.Connection -> User -> Maybe Text -> Text -> IO ChatTagId +createChatTag db User {userId} emoji text = do + DB.execute + db + [sql| + INSERT INTO chat_tags (user_id, chat_tag_emoji, chat_tag_text, tag_order) + VALUES (?,?,?, COALESCE((SELECT MAX(tag_order) + 1 FROM chat_tags WHERE user_id = ?), 1)) + |] + (userId, emoji, text, userId) + insertedRowId db + +deleteChatTag :: DB.Connection -> User -> ChatTagId -> IO () +deleteChatTag db User {userId} tId = + DB.execute + db + [sql| + DELETE FROM chat_tags + WHERE user_id = ? AND chat_tag_id = ? + |] + (userId, tId) + +updateChatTag :: DB.Connection -> User -> ChatTagId -> Maybe Text -> Text -> IO () +updateChatTag db User {userId} tId emoji text = + DB.execute + db + [sql| + UPDATE chat_tags + SET chat_tag_emoji = ?, chat_tag_text = ? + WHERE user_id = ? AND chat_tag_id = ? + |] + (emoji, text, userId, tId) + +updateChatTagOrder :: DB.Connection -> User -> ChatTagId -> Int -> IO () +updateChatTagOrder db User {userId} tId order = + DB.execute + db + [sql| + UPDATE chat_tags + SET tag_order = ? + WHERE user_id = ? AND chat_tag_id = ? + |] + (order, userId, tId) + +reorderChatTags :: DB.Connection -> User -> [ChatTagId] -> IO () +reorderChatTags db user tIds = + forM_ (zip [1 ..] tIds) $ \(order, tId) -> + updateChatTagOrder db user tId order + +getUserChatTags :: DB.Connection -> User -> IO [ChatTag] +getUserChatTags db User {userId} = + map toChatTag + <$> DB.query + db + [sql| + SELECT chat_tag_id, chat_tag_emoji, chat_tag_text + FROM chat_tags + WHERE user_id = ? + ORDER BY tag_order + |] + (Only userId) + where + toChatTag :: (ChatTagId, Maybe Text, Text) -> ChatTag + toChatTag (chatTagId, chatTagEmoji, chatTagText) = ChatTag {chatTagId, chatTagEmoji, chatTagText} + +getGroupChatTags :: DB.Connection -> GroupId -> IO [ChatTagId] +getGroupChatTags db groupId = + map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ?" (Only groupId) + +addGroupChatTags :: DB.Connection -> GroupInfo -> IO GroupInfo +addGroupChatTags db g@GroupInfo {groupId} = do + chatTags <- getGroupChatTags db groupId + pure (g :: GroupInfo) {chatTags} diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 77a02a4bc1..716925a0d7 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -160,6 +160,8 @@ type ContactId = Int64 type ProfileId = Int64 +type ChatTagId = Int64 + data Contact = Contact { contactId :: ContactId, localDisplayName :: ContactName, @@ -176,6 +178,7 @@ data Contact = Contact chatTs :: Maybe UTCTime, contactGroupMemberId :: Maybe GroupMemberId, contactGrpInvSent :: Bool, + chatTags :: [ChatTagId], uiThemes :: Maybe UIThemeEntityOverrides, chatDeleted :: Bool, customData :: Maybe CustomData @@ -380,6 +383,7 @@ data GroupInfo = GroupInfo updatedAt :: UTCTime, chatTs :: Maybe UTCTime, userMemberProfileSentAt :: Maybe UTCTime, + chatTags :: [ChatTagId], uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData } @@ -1637,6 +1641,13 @@ data CommandData = CommandData } deriving (Show) +data ChatTag = ChatTag + { chatTagId :: Int64, + chatTagText :: Text, + chatTagEmoji :: Maybe Text + } + deriving (Show) + -- ad-hoc type for data required for XGrpMemIntro continuation data XGrpMemIntroCont = XGrpMemIntroCont { groupId :: GroupId, @@ -1791,3 +1802,5 @@ $(JQ.deriveJSON defaultJSON ''Contact) $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) + +$(JQ.deriveJSON defaultJSON ''ChatTag) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8b6a545637..45dc6408d2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -96,6 +96,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] + CRChatTags u tags -> ttyUser u $ [viewJSON tags] CRApiParsedMarkdown ft -> [viewJSON ft] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca @@ -149,6 +150,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe | otherwise -> [] CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci + CRTagsUpdated u _ _ -> ttyUser u ["chat tags updated"] CRChatItemsDeleted u deletions byUser timed -> case deletions of [ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView