From 904405ebeefd569275e5a09afdab62c7b4f6ed43 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 16 May 2023 10:34:25 +0200 Subject: [PATCH] ios: reactions UI (#2442) * ios: reactions UI * remove JSON * remove print * align reactions, show all allowed reactions in menu * move react to the menu top * ios: update preference texts * always allow removing reactions, reduce spacing * revent allow removing (backend does not allow it anyway) --- .../DebugJSON.playground/Contents.swift | 23 +- apps/ios/Shared/Model/ChatModel.swift | 44 ++-- apps/ios/Shared/Model/SimpleXAPI.swift | 10 + apps/ios/Shared/Views/Chat/ChatView.swift | 78 ++++-- .../Views/Chat/ContactPreferencesView.swift | 4 + .../Chat/Group/GroupPreferencesView.swift | 4 + .../Views/UserSettings/PreferencesView.swift | 4 + apps/ios/SimpleXChat/APITypes.swift | 8 +- apps/ios/SimpleXChat/ChatTypes.swift | 234 +++++++++++++++--- src/Simplex/Chat/View.hs | 12 +- 10 files changed, 333 insertions(+), 88 deletions(-) diff --git a/apps/ios/Shared/DebugJSON.playground/Contents.swift b/apps/ios/Shared/DebugJSON.playground/Contents.swift index 832afce535..1b309a1559 100644 --- a/apps/ios/Shared/DebugJSON.playground/Contents.swift +++ b/apps/ios/Shared/DebugJSON.playground/Contents.swift @@ -1,20 +1,11 @@ //import UIKit +import SimpleXChat -let s = """ - { - "contactConnection" : { - "contactConnection" : { - "viaContactUri" : false, - "pccConnId" : 456, - "pccAgentConnId" : "cTdjbmR4ZzVzSmhEZHdzMQ==", - "pccConnStatus" : "new", - "updatedAt" : "2022-04-24T11:59:23.703162Z", - "createdAt" : "2022-04-24T11:59:23.703162Z" - } - } - } +let s = +""" +{} """ //let s = "\"2022-04-24T11:59:23.703162Z\"" -//let json = getJSONDecoder() -//let d = s.data(using: .utf8)! -//print (try! json.decode(ChatInfo.self, from: d)) +let json = getJSONDecoder() +let d = s.data(using: .utf8)! +print (try! json.decode(APIResponse.self, from: d)) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index eada346eca..8c3d0368e1 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -238,16 +238,9 @@ final class ChatModel: ObservableObject { } private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { - let ci = reversedChatItems[i] + if let i = getChatItemIndex(cItem) { withAnimation { - self.reversedChatItems[i] = cItem - self.reversedChatItems[i].viewTimestamp = .now - // on some occasions the confirmation of message being accepted by the server (tick) - // arrives earlier than the response from API, and item remains without tick - if case .sndNew = cItem.meta.itemStatus { - self.reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus - } + _updateChatItem(at: i, with: cItem) } return false } else { @@ -264,7 +257,30 @@ final class ChatModel: ObservableObject { } } } - + + func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + if chatId == cInfo.id, let i = getChatItemIndex(cItem) { + withAnimation { + _updateChatItem(at: i, with: cItem) + } + } + } + + private func _updateChatItem(at i: Int, with cItem: ChatItem) { + let ci = reversedChatItems[i] + reversedChatItems[i] = cItem + reversedChatItems[i].viewTimestamp = .now + // on some occasions the confirmation of message being accepted by the server (tick) + // arrives earlier than the response from API, and item remains without tick + if case .sndNew = cItem.meta.itemStatus { + reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus + } + } + + private func getChatItemIndex(_ cItem: ChatItem) -> Int? { + reversedChatItems.firstIndex(where: { $0.id == cItem.id }) + } + func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { if cItem.isRcvNew { decreaseUnreadCounter(cInfo) @@ -277,7 +293,7 @@ final class ChatModel: ObservableObject { } // remove from current chat if chatId == cInfo.id { - if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + if let i = getChatItemIndex(cItem) { _ = withAnimation { self.reversedChatItems.remove(at: i) } @@ -357,7 +373,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { if let cItem = aboveItem { - if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + if chatId == cInfo.id, let i = getChatItemIndex(cItem) { markCurrentChatRead(fromIndex: i) _updateChat(cInfo.id) { chat in var unreadBelow = 0 @@ -405,7 +421,7 @@ final class ChatModel: ObservableObject { // update preview decreaseUnreadCounter(cInfo) // update current chat - if chatId == cInfo.id, let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { + if chatId == cInfo.id, let i = getChatItemIndex(cItem) { markChatItemRead_(i) } } @@ -450,7 +466,7 @@ final class ChatModel: ObservableObject { } func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { - if let i = reversedChatItems.firstIndex(where: { $0.id == ci.id }), i < reversedChatItems.count - 1 { + if let i = getChatItemIndex(ci), i < reversedChatItems.count - 1 { return reversedChatItems[i + 1] } else { return nil diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d36b3ade8f..3e24d3a457 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -343,6 +343,12 @@ func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent throw r } +func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, reaction: MsgReaction, add: Bool) async throws -> ChatItem { + let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, reaction: reaction, add: add), bgDelay: msgDelay) + if case let .chatItemReaction(_, reaction, _) = r { return reaction.chatReaction.chatItem } + throw r +} + func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) { let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } @@ -1264,6 +1270,10 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .chatItemUpdated(user, aChatItem): chatItemSimpleUpdate(user, aChatItem) + case let .chatItemReaction(user, r, _): + if active(user) { + m.updateChatItem(r.chatInfo, r.chatReaction.chatItem) + } case let .chatItemDeleted(user, deletedChatItem, toChatItem, _): if !active(user) { if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b879327683..5df8d062fa 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -444,6 +444,7 @@ struct ChatView: View { private struct ChatItemWithMenu: View { @EnvironmentObject var chat: Chat @Environment(\.colorScheme) var colorScheme + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false var ci: ChatItem var showMember: Bool = false var maxWidth: CGFloat @@ -470,11 +471,11 @@ struct ChatView: View { set: { _ in } ) - VStack(alignment: .trailing, spacing: 4) { + VStack(alignment: alignment.horizontal, spacing: 3) { ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) - if ci.reactions.count > 0 { - chatItemReactions(ci.reactions) + if ci.content.msgContent != nil && ci.meta.itemDeleted == nil && ci.reactions.count > 0 { + chatItemReactions() .padding(.bottom, 4) } } @@ -505,28 +506,42 @@ struct ChatView: View { } } - private func chatItemReactions(_ reactions: [CIReaction]) -> some View { + private func chatItemReactions() -> some View { HStack(spacing: 4) { - ForEach(reactions, id: \.reaction) { r in - HStack(spacing: 4) { + ForEach(ci.reactions, id: \.reaction) { r in + let v = HStack(spacing: 4) { switch r.reaction { - case let .emoji(emoji): Text(emoji).font(.caption) + case let .emoji(emoji): Text(emoji.rawValue).font(.caption) + case .unknown: EmptyView() } if r.totalReacted > 1 { - Text("\(r.totalReacted)").font(.caption).foregroundColor(.secondary) + Text("\(r.totalReacted)") + .font(.caption) + .fontWeight(r.userReacted ? .bold : .light) + .foregroundColor(r.userReacted ? .accentColor : .secondary) } } - .padding(.horizontal, 8) + .padding(.horizontal, 6) .padding(.vertical, 4) - .background(!r.userReacted ? Color.clear : colorScheme == .dark ? sentColorDark : sentColorLight) - .cornerRadius(16) + + if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { + v.onTapGesture { + setReaction(r.reaction, add: !r.userReacted) + } + } else { + v + } } } } - private func menu(live: Bool) -> [UIAction] { - var menu: [UIAction] = [] + private func menu(live: Bool) -> [UIMenuElement] { + var menu: [UIMenuElement] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { + if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction && developerTools, + let rm = reactionUIMenu() { + menu.append(rm) + } if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { menu.append(replyUIAction()) } @@ -586,7 +601,42 @@ struct ChatView: View { } } } - + + private func reactionUIMenu() -> UIMenu? { + let rs = MsgReaction.values.compactMap { r in + ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) + ? nil + : UIAction(title: r.text) { _ in setReaction(r, add: true) } + } + if rs.count > 0 { + return UIMenu( + title: NSLocalizedString("React...", comment: "chat item menu"), + image: UIImage(systemName: "hand.thumbsup"), + children: rs + ) + } + return nil + } + + private func setReaction(_ r: MsgReaction, add: Bool) { + Task { + do { + let chatItem = try await apiChatItemReaction( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: ci.id, + reaction: r, + add: add + ) + await MainActor.run { + ChatModel.shared.updateChatItem(chat.chatInfo, chatItem) + } + } catch let error { + logger.error("apiChatItemReaction error: \(responseError(error))") + } + } + } + private func shareUIAction() -> UIAction { UIAction( title: NSLocalizedString("Share", comment: "chat item action"), diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index c669d95853..839c80a6a9 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ContactPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @Binding var contact: Contact @State var featuresAllowed: ContactFeaturesAllowed @State var currentFeaturesAllowed: ContactFeaturesAllowed @@ -24,6 +25,9 @@ struct ContactPreferencesView: View { List { timedMessagesFeatureSection() featureSection(.fullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, $featuresAllowed.fullDelete) + if developerTools { + featureSection(.reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, $featuresAllowed.reactions) + } featureSection(.voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, $featuresAllowed.voice) featureSection(.calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, $featuresAllowed.calls) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 652e649ec0..6d5832c809 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct GroupPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @Binding var groupInfo: GroupInfo @State var preferences: FullGroupPreferences @State var currentPreferences: FullGroupPreferences @@ -25,6 +26,9 @@ struct GroupPreferencesView: View { featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable) + if developerTools { + featureSection(.reactions, $preferences.reactions.enable) + } featureSection(.voice, $preferences.voice.enable) if groupInfo.canEdit { diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index d78fe421d6..22797d7d02 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct PreferencesView: View { @EnvironmentObject var chatModel: ChatModel + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State var profile: LocalProfile @State var preferences: FullPreferences @State var currentPreferences: FullPreferences @@ -20,6 +21,9 @@ struct PreferencesView: View { List { timedMessagesFeatureSection($preferences.timedMessages.allow) featureSection(.fullDelete, $preferences.fullDelete.allow) + if developerTools { + featureSection(.reactions, $preferences.reactions.allow) + } featureSection(.voice, $preferences.voice.allow) featureSection(.calls, $preferences.calls.allow) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ebee5973a5..cc1d0183f4 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -41,6 +41,7 @@ public enum ChatCommand { case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) + case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, reaction: MsgReaction, add: Bool) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -148,6 +149,7 @@ public enum ChatCommand { case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" + case let .apiChatItemReaction(type, id, itemId, reaction, add): return "/_reaction \(ref(type, id)) \(itemId) \(reaction.cmdString) \(onOff(add))" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -253,6 +255,7 @@ public enum ChatCommand { case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" + case .apiChatItemReaction: return "apiChatItemReaction" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" @@ -373,7 +376,7 @@ public enum ChatCommand { } } -struct APIResponse: Decodable { +public struct APIResponse: Decodable { var resp: ChatResponse } @@ -430,6 +433,7 @@ public enum ChatResponse: Decodable, Error { case newChatItem(user: User, chatItem: AChatItem) case chatItemStatusUpdated(user: User, chatItem: AChatItem) case chatItemUpdated(user: User, chatItem: AChatItem) + case chatItemReaction(user: User, reaction: ACIReaction, added: Bool) case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) case contactsList(user: User, contacts: [Contact]) // group events @@ -547,6 +551,7 @@ public enum ChatResponse: Decodable, Error { case .newChatItem: return "newChatItem" case .chatItemStatusUpdated: return "chatItemStatusUpdated" case .chatItemUpdated: return "chatItemUpdated" + case .chatItemReaction: return "chatItemReaction" case .chatItemDeleted: return "chatItemDeleted" case .contactsList: return "contactsList" case .groupCreated: return "groupCreated" @@ -663,6 +668,7 @@ public enum ChatResponse: Decodable, Error { case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemReaction(u, reaction, added): return withUser(u, "added: \(added)\n\(String(describing: reaction))") case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)") case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4bcbb1f628..b3a472f1e2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -183,12 +183,20 @@ public typealias ChatId = String public struct FullPreferences: Decodable, Equatable { public var timedMessages: TimedMessagesPreference public var fullDelete: SimplePreference + public var reactions: SimplePreference public var voice: SimplePreference public var calls: SimplePreference - public init(timedMessages: TimedMessagesPreference, fullDelete: SimplePreference, voice: SimplePreference, calls: SimplePreference) { + public init( + timedMessages: TimedMessagesPreference, + fullDelete: SimplePreference, + reactions: SimplePreference, + voice: SimplePreference, + calls: SimplePreference + ) { self.timedMessages = timedMessages self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice self.calls = calls } @@ -196,6 +204,7 @@ public struct FullPreferences: Decodable, Equatable { public static let sampleData = FullPreferences( timedMessages: TimedMessagesPreference(allow: .no), fullDelete: SimplePreference(allow: .no), + reactions: SimplePreference(allow: .yes), voice: SimplePreference(allow: .yes), calls: SimplePreference(allow: .yes) ) @@ -204,20 +213,35 @@ public struct FullPreferences: Decodable, Equatable { public struct Preferences: Codable { public var timedMessages: TimedMessagesPreference? public var fullDelete: SimplePreference? + public var reactions: SimplePreference? public var voice: SimplePreference? public var calls: SimplePreference? - public init(timedMessages: TimedMessagesPreference?, fullDelete: SimplePreference?, voice: SimplePreference?, calls: SimplePreference?) { + public init( + timedMessages: TimedMessagesPreference?, + fullDelete: SimplePreference?, + reactions: SimplePreference?, + voice: SimplePreference?, + calls: SimplePreference? + ) { self.timedMessages = timedMessages self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice self.calls = calls } - func copy(timedMessages: TimedMessagesPreference? = nil, fullDelete: SimplePreference? = nil, voice: SimplePreference? = nil, calls: SimplePreference? = nil) -> Preferences { + func copy( + timedMessages: TimedMessagesPreference? = nil, + fullDelete: SimplePreference? = nil, + reactions: SimplePreference? = nil, + voice: SimplePreference? = nil, + calls: SimplePreference? = nil + ) -> Preferences { Preferences( timedMessages: timedMessages ?? self.timedMessages, fullDelete: fullDelete ?? self.fullDelete, + reactions: reactions ?? self.reactions, voice: voice ?? self.voice, calls: calls ?? self.calls ) @@ -227,6 +251,7 @@ public struct Preferences: Codable { switch feature { case .timedMessages: return copy(timedMessages: TimedMessagesPreference(allow: allowed, ttl: param ?? timedMessages?.ttl)) case .fullDelete: return copy(fullDelete: SimplePreference(allow: allowed)) + case .reactions: return copy(reactions: SimplePreference(allow: allowed)) case .voice: return copy(voice: SimplePreference(allow: allowed)) case .calls: return copy(calls: SimplePreference(allow: allowed)) } @@ -235,6 +260,7 @@ public struct Preferences: Codable { public static let sampleData = Preferences( timedMessages: TimedMessagesPreference(allow: .no), fullDelete: SimplePreference(allow: .no), + reactions: SimplePreference(allow: .yes), voice: SimplePreference(allow: .yes), calls: SimplePreference(allow: .yes) ) @@ -244,6 +270,7 @@ public func fullPreferencesToPreferences(_ fullPreferences: FullPreferences) -> Preferences( timedMessages: fullPreferences.timedMessages, fullDelete: fullPreferences.fullDelete, + reactions: fullPreferences.reactions, voice: fullPreferences.voice, calls: fullPreferences.calls ) @@ -253,6 +280,7 @@ public func contactUserPreferencesToPreferences(_ contactUserPreferences: Contac Preferences( timedMessages: contactUserPreferences.timedMessages.userPreference.preference, fullDelete: contactUserPreferences.fullDelete.userPreference.preference, + reactions: contactUserPreferences.reactions.userPreference.preference, voice: contactUserPreferences.voice.userPreference.preference, calls: contactUserPreferences.calls.userPreference.preference ) @@ -386,17 +414,20 @@ public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey { public struct ContactUserPreferences: Decodable { public var timedMessages: ContactUserPreference public var fullDelete: ContactUserPreference + public var reactions: ContactUserPreference public var voice: ContactUserPreference public var calls: ContactUserPreference public init( timedMessages: ContactUserPreference, fullDelete: ContactUserPreference, + reactions: ContactUserPreference, voice: ContactUserPreference, calls: ContactUserPreference ) { self.timedMessages = timedMessages self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice self.calls = calls } @@ -412,6 +443,11 @@ public struct ContactUserPreferences: Decodable { userPreference: ContactUserPref.user(preference: SimplePreference(allow: .no)), contactPreference: SimplePreference(allow: .no) ), + reactions: ContactUserPreference( + enabled: FeatureEnabled(forUser: true, forContact: true), + userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), + contactPreference: SimplePreference(allow: .yes) + ), voice: ContactUserPreference( enabled: FeatureEnabled(forUser: true, forContact: true), userPreference: ContactUserPref.user(preference: SimplePreference(allow: .yes)), @@ -491,11 +527,10 @@ public protocol Feature { public enum ChatFeature: String, Decodable, Feature { case timedMessages case fullDelete + case reactions case voice case calls - public var values: [ChatFeature] { [.fullDelete, .voice] } - public var id: Self { self } public var asymmetric: Bool { @@ -516,6 +551,7 @@ public enum ChatFeature: String, Decodable, Feature { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature") + case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature") case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .calls: return NSLocalizedString("Audio/video calls", comment: "chat feature") } @@ -525,6 +561,7 @@ public enum ChatFeature: String, Decodable, Feature { switch self { case .timedMessages: return "stopwatch" case .fullDelete: return "trash.slash" + case .reactions: return "hand.thumbsup" case .voice: return "mic" case .calls: return "phone" } @@ -534,6 +571,7 @@ public enum ChatFeature: String, Decodable, Feature { switch self { case .timedMessages: return "stopwatch.fill" case .fullDelete: return "trash.slash.fill" + case .reactions: return "hand.thumbsup.fill" case .voice: return "mic.fill" case .calls: return "phone.fill" } @@ -560,6 +598,12 @@ public enum ChatFeature: String, Decodable, Feature { case .yes: return "Allow irreversible message deletion only if your contact allows it to you." case .no: return "Contacts can mark messages for deletion; you will be able to view them." } + case .reactions: + switch allowed { + case .always: return "Allow your contacts adding message reactions." + case .yes: return "Allow message reactions only if your contact allows them." + case .no: return "Prohibit message reactions." + } case .voice: switch allowed { case .always: return "Allow your contacts to send voice messages." @@ -593,6 +637,14 @@ public enum ChatFeature: String, Decodable, Feature { : enabled.forContact ? "Only your contact can irreversibly delete messages (you can mark them for deletion)." : "Irreversible message deletion is prohibited in this chat." + case .reactions: + return enabled.forUser && enabled.forContact + ? "Both you and your contact can add message reactions." + : enabled.forUser + ? "Only you can add message reactions." + : enabled.forContact + ? "Only your contact can add message reactions." + : "Message reactions are prohibited in this chat." case .voice: return enabled.forUser && enabled.forContact ? "Both you and your contact can send voice messages." @@ -616,11 +668,10 @@ public enum ChatFeature: String, Decodable, Feature { public enum GroupFeature: String, Decodable, Feature { case timedMessages case fullDelete + case reactions case voice case directMessages - public var values: [GroupFeature] { [.directMessages, .fullDelete, .voice] } - public var id: Self { self } public var hasParam: Bool { @@ -635,6 +686,7 @@ public enum GroupFeature: String, Decodable, Feature { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") case .directMessages: return NSLocalizedString("Direct messages", comment: "chat feature") case .fullDelete: return NSLocalizedString("Delete for everyone", comment: "chat feature") + case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature") case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") } } @@ -644,6 +696,7 @@ public enum GroupFeature: String, Decodable, Feature { case .timedMessages: return "stopwatch" case .directMessages: return "arrow.left.and.right.circle" case .fullDelete: return "trash.slash" + case .reactions: return "hand.thumbsup" case .voice: return "mic" } } @@ -653,6 +706,7 @@ public enum GroupFeature: String, Decodable, Feature { case .timedMessages: return "stopwatch.fill" case .directMessages: return "arrow.left.and.right.circle.fill" case .fullDelete: return "trash.slash.fill" + case .reactions: return "hand.thumbsup.fill" case .voice: return "mic.fill" } } @@ -682,6 +736,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Allow to irreversibly delete sent messages." case .off: return "Prohibit irreversible message deletion." } + case .reactions: + switch enabled { + case .on: return "Allow message reactions." + case .off: return "Prohibit messages reactions." + } case .voice: switch enabled { case .on: return "Allow to send voice messages." @@ -705,6 +764,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Group members can irreversibly delete sent messages." case .off: return "Irreversible message deletion is prohibited in this group." } + case .reactions: + switch enabled { + case .on: return "Group members can add message reactions." + case .off: return "Message reactions are prohibited in this group." + } case .voice: switch enabled { case .on: return "Group members can send voice messages." @@ -750,13 +814,22 @@ public struct ContactFeaturesAllowed: Equatable { public var timedMessagesAllowed: Bool public var timedMessagesTTL: Int? public var fullDelete: ContactFeatureAllowed + public var reactions: ContactFeatureAllowed public var voice: ContactFeatureAllowed public var calls: ContactFeatureAllowed - public init(timedMessagesAllowed: Bool, timedMessagesTTL: Int?, fullDelete: ContactFeatureAllowed, voice: ContactFeatureAllowed, calls: ContactFeatureAllowed) { + public init( + timedMessagesAllowed: Bool, + timedMessagesTTL: Int?, + fullDelete: ContactFeatureAllowed, + reactions: ContactFeatureAllowed, + voice: ContactFeatureAllowed, + calls: ContactFeatureAllowed + ) { self.timedMessagesAllowed = timedMessagesAllowed self.timedMessagesTTL = timedMessagesTTL self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice self.calls = calls } @@ -765,6 +838,7 @@ public struct ContactFeaturesAllowed: Equatable { timedMessagesAllowed: false, timedMessagesTTL: nil, fullDelete: ContactFeatureAllowed.userDefault(.no), + reactions: ContactFeatureAllowed.userDefault(.yes), voice: ContactFeatureAllowed.userDefault(.yes), calls: ContactFeatureAllowed.userDefault(.yes) ) @@ -777,6 +851,7 @@ public func contactUserPrefsToFeaturesAllowed(_ contactUserPreferences: ContactU timedMessagesAllowed: allow == .yes || allow == .always, timedMessagesTTL: pref.preference.ttl, fullDelete: contactUserPrefToFeatureAllowed(contactUserPreferences.fullDelete), + reactions: contactUserPrefToFeatureAllowed(contactUserPreferences.reactions), voice: contactUserPrefToFeatureAllowed(contactUserPreferences.voice), calls: contactUserPrefToFeatureAllowed(contactUserPreferences.calls) ) @@ -798,6 +873,7 @@ public func contactFeaturesAllowedToPrefs(_ contactFeaturesAllowed: ContactFeatu Preferences( timedMessages: TimedMessagesPreference(allow: contactFeaturesAllowed.timedMessagesAllowed ? .yes : .no, ttl: contactFeaturesAllowed.timedMessagesTTL), fullDelete: contactFeatureAllowedToPref(contactFeaturesAllowed.fullDelete), + reactions: contactFeatureAllowedToPref(contactFeaturesAllowed.reactions), voice: contactFeatureAllowedToPref(contactFeaturesAllowed.voice), calls: contactFeatureAllowedToPref(contactFeaturesAllowed.calls) ) @@ -834,12 +910,20 @@ public struct FullGroupPreferences: Decodable, Equatable { public var timedMessages: TimedMessagesGroupPreference public var directMessages: GroupPreference public var fullDelete: GroupPreference + public var reactions: GroupPreference public var voice: GroupPreference - public init(timedMessages: TimedMessagesGroupPreference, directMessages: GroupPreference, fullDelete: GroupPreference, voice: GroupPreference) { + public init( + timedMessages: TimedMessagesGroupPreference, + directMessages: GroupPreference, + fullDelete: GroupPreference, + reactions: GroupPreference, + voice: GroupPreference + ) { self.timedMessages = timedMessages self.directMessages = directMessages self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice } @@ -847,6 +931,7 @@ public struct FullGroupPreferences: Decodable, Equatable { timedMessages: TimedMessagesGroupPreference(enable: .off), directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), + reactions: GroupPreference(enable: .on), voice: GroupPreference(enable: .on) ) } @@ -855,12 +940,20 @@ public struct GroupPreferences: Codable { public var timedMessages: TimedMessagesGroupPreference? public var directMessages: GroupPreference? public var fullDelete: GroupPreference? + public var reactions: GroupPreference? public var voice: GroupPreference? - public init(timedMessages: TimedMessagesGroupPreference?, directMessages: GroupPreference?, fullDelete: GroupPreference?, voice: GroupPreference?) { + public init( + timedMessages: TimedMessagesGroupPreference?, + directMessages: GroupPreference?, + fullDelete: GroupPreference?, + reactions: GroupPreference?, + voice: GroupPreference? + ) { self.timedMessages = timedMessages self.directMessages = directMessages self.fullDelete = fullDelete + self.reactions = reactions self.voice = voice } @@ -868,6 +961,7 @@ public struct GroupPreferences: Codable { timedMessages: TimedMessagesGroupPreference(enable: .off), directMessages: GroupPreference(enable: .off), fullDelete: GroupPreference(enable: .off), + reactions: GroupPreference(enable: .on), voice: GroupPreference(enable: .on) ) } @@ -877,6 +971,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group timedMessages: fullPreferences.timedMessages, directMessages: fullPreferences.directMessages, fullDelete: fullPreferences.fullDelete, + reactions: fullPreferences.reactions, voice: fullPreferences.voice ) } @@ -1086,6 +1181,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { switch feature { case .timedMessages: return cups.timedMessages.enabled.forUser case .fullDelete: return cups.fullDelete.enabled.forUser + case .reactions: return cups.reactions.enabled.forUser case .voice: return cups.voice.enabled.forUser case .calls: return cups.calls.enabled.forUser } @@ -1094,6 +1190,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { switch feature { case .timedMessages: return prefs.timedMessages.on case .fullDelete: return prefs.fullDelete.on + case .reactions: return prefs.reactions.on case .voice: return prefs.voice.on case .calls: return false } @@ -1249,6 +1346,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { switch feature { case .timedMessages: return mergedPreferences.timedMessages.contactPreference.allow != .no case .fullDelete: return mergedPreferences.fullDelete.contactPreference.allow != .no + case .reactions: return mergedPreferences.reactions.contactPreference.allow != .no case .voice: return mergedPreferences.voice.contactPreference.allow != .no case .calls: return mergedPreferences.calls.contactPreference.allow != .no } @@ -1258,6 +1356,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { switch feature { case .timedMessages: return mergedPreferences.timedMessages.userPreference.preference.allow != .no case .fullDelete: return mergedPreferences.fullDelete.userPreference.preference.allow != .no + case .reactions: return mergedPreferences.reactions.userPreference.preference.allow != .no case .voice: return mergedPreferences.voice.userPreference.preference.allow != .no case .calls: return mergedPreferences.calls.userPreference.preference.allow != .no } @@ -1802,24 +1901,26 @@ public struct AChatItem: Decodable { } } +public struct ACIReaction: Decodable { + public var chatInfo: ChatInfo + public var chatReaction: CIReaction +} + +public struct CIReaction: Decodable { + public var chatDir: CIDirection + public var chatItem: ChatItem + public var sentAt: Date + public var reaction: MsgReaction +} + public struct ChatItem: Identifiable, Decodable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText self.quotedItem = quotedItem - self.reactions = [] // [ -// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1), -// CIReaction(reaction: .emoji(emoji: "❤️"), userReacted: false, totalReacted: 1), -// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3), -// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2), -// ] + self.reactions = reactions self.file = file } @@ -1828,24 +1929,14 @@ public struct ChatItem: Identifiable, Decodable { public var content: CIContent public var formattedText: [FormattedText]? public var quotedItem: CIQuote? - public var reactions: [CIReaction] = [] // [ -// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1), -// CIReaction(reaction: .emoji(emoji: "❤️"), userReacted: false, totalReacted: 1), -// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3), -// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2), -// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2), -// ] + public var reactions: [CIReactionCount] public var file: CIFile? public var viewTimestamp = Date.now public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, quotedItem, file + case chatDir, meta, content, formattedText, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -1920,6 +2011,10 @@ public struct ChatItem: Identifiable, Decodable { } } + public var allowAddReaction: Bool { + meta.itemDeleted == nil && !isLiveDummy && reactions.filter({ $0.userReacted }).count < 3 + } + public func autoReceiveFile() -> CIFile? { if let file = file, let mc = content.msgContent, @@ -2367,14 +2462,79 @@ public struct CIQuote: Decodable, ItemContent { } } -public struct CIReaction: Decodable { +public struct CIReactionCount: Decodable { public var reaction: MsgReaction public var userReacted: Bool public var totalReacted: Int } -public enum MsgReaction: Decodable, Hashable { - case emoji(emoji: String) +public enum MsgReaction: Hashable { + case emoji(MREmojiChar) + case unknown + + public var text: String { + switch self { + case let .emoji(emoji): return emoji.rawValue + case .unknown: return "" + } + } + + public var cmdString: String { + switch self { + case let .emoji(emoji): return emoji.cmdString + case .unknown: return "" + } + } + + public static var values: [MsgReaction] = + MREmojiChar.allCases.map { .emoji($0) } + + enum CodingKeys: String, CodingKey { + case type + case emoji + } +} + +public enum MREmojiChar: String, Codable, CaseIterable { + case thumbsup = "👍" + case thumbsdown = "👎" + case smile = "😀" + case celebration = "🎉" + case confused = "😕" + case heart = "❤" + case launch = "🚀" + case looking = "👀" + + public var cmdString: String { + switch self { + case .thumbsup: return "+" + case .thumbsdown: return "-" + case .smile: return ")" + case .celebration: return "!" + case .confused: return "?" + case .heart: return "*" + case .launch: return "^" + case .looking: return "%" + } + } +} + +extension MsgReaction: Decodable { + public init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "emoji": + let emoji = try container.decode(MREmojiChar.self, forKey: CodingKeys.emoji) + self = .emoji(emoji) + default: + self = .unknown + } + } catch { + self = .unknown + } + } } public struct CIFile: Decodable { diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4ef4da295a..3e8d130eb5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -91,9 +91,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView - CRChatItemReaction u (ACIReaction _ _ chat reaction) added - | showReactions -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction chat reaction added ts tz - | otherwise -> [] + CRChatItemReaction u (ACIReaction _ _ chat reaction) added -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr @@ -514,8 +512,8 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem by Just (AChatItem _ _ _ ci) -> chatItemDeletedText ci $ chatInfoMembership chat prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)] -viewItemReaction :: forall c d. ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz = +viewItemReaction :: forall c d. Bool -> ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] +viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz = case (chat, chatDir) of (DirectChat c, CIDirectRcv) -> case content of CIRcvMsgContent mc -> view from $ reactionMsg mc @@ -534,7 +532,9 @@ viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {cha (_, CIDirectSnd) -> [sentText] (_, CIGroupSnd) -> [sentText] where - view from msg = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt + view from msg + | showReactions = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt + | otherwise = [] reactionText = plain $ (if added then "+ " else "- ") <> [emoji] MREmoji (MREmojiChar emoji) = reaction sentText = plain $ (if added then "added " else "removed ") <> [emoji]