From 77698a9883dea0d08807acadf2b2bedad1588e5b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 11 Sep 2023 17:55:40 +0400 Subject: [PATCH] wip --- apps/ios/Shared/Model/ChatModel.swift | 4 + apps/ios/Shared/Model/SimpleXAPI.swift | 57 +++++++------ .../Shared/Views/Chat/ChatItemInfoView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 79 ++++++++++++++++--- .../Chat/ComposeMessage/ComposeView.swift | 45 ++++++++++- .../ContextDirectMemberView.swift | 49 ++++++++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/ChatTypes.swift | 43 ++++++++-- 8 files changed, 235 insertions(+), 48 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextDirectMemberView.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1ec2f8f53..8cb7e614d6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -563,6 +563,10 @@ final class ChatModel: ObservableObject { } } + func getGroupMember(_ groupMemberId: Int64) -> GroupMember? { + groupMembers.first(where: { $0.groupMemberId == groupMemberId }) + } + func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { // user member was updated if groupInfo.membership.groupMemberId == member.groupMemberId { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 08139eff19..c505f28c00 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -318,33 +318,15 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - func apiSendMessage(sendRef: SendRef, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(sendRef: sendRef, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) - let r: ChatResponse switch sendRef { - // TODO group-direct: re-use direct logic for directMember case .direct: - var cItem: ChatItem? = nil - let endTask = beginBGTask({ - if let cItem = cItem { - DispatchQueue.main.async { - chatModel.messageDelivery.removeValue(forKey: cItem.id) - } - } - }) - r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItem(_, aChatItem) = r { - cItem = aChatItem.chatItem - chatModel.messageDelivery[aChatItem.chatItem.id] = endTask - return cItem - } - if let networkErrorAlert = networkErrorAlert(r) { - AlertManager.shared.showAlert(networkErrorAlert) - } else { - sendMessageErrorAlert(r) - } - endTask() - return nil - default: - r = await chatSendCmd(cmd, bgDelay: msgDelay) + let cItem = await sendMessageDirect(cmd) + return cItem + case .group(_, .some(_)): + let cItem = await sendMessageDirect(cmd) + return cItem + case .group(_, .none): + let r = await chatSendCmd(cmd, bgDelay: msgDelay) if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem } @@ -353,6 +335,31 @@ func apiSendMessage(sendRef: SendRef, file: CryptoFile?, quotedItemId: Int64?, m } } +private func sendMessageDirect(_ cmd: ChatCommand) async -> ChatItem? { + let chatModel = ChatModel.shared + var cItem: ChatItem? = nil + let endTask = beginBGTask({ + if let cItem = cItem { + DispatchQueue.main.async { + chatModel.messageDelivery.removeValue(forKey: cItem.id) + } + } + }) + let r = await chatSendCmd(cmd, bgTask: false) + if case let .newChatItem(_, aChatItem) = r { + cItem = aChatItem.chatItem + chatModel.messageDelivery[aChatItem.chatItem.id] = endTask + return cItem + } + if let networkErrorAlert = networkErrorAlert(r) { + AlertManager.shared.showAlert(networkErrorAlert) + } else { + sendMessageErrorAlert(r) + } + endTask() + return nil +} + private func sendMessageErrorAlert(_ r: ChatResponse) { logger.error("apiSendMessage error: \(String(describing: r))") AlertManager.shared.showAlertMsg( diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 61802c61fe..202afb7fb5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -290,7 +290,7 @@ struct ChatItemInfoView: View { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { memberDeliveryStatuses.compactMap({ mds in - if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) { + if let mem = ChatModel.shared.getGroupMember(mds.groupMemberId) { return (mem, mds.memberDeliveryStatus) } else { return nil diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 278c96c609..02dee61f1c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -207,6 +207,13 @@ struct ChatView: View { logger.error("apiContactInfo error: \(responseError(error))") } } + } else if case let .group(groupInfo) = cInfo { + Task { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + ChatModel.shared.groupMembers = groupMembers + } + } } if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { composeState = draft @@ -428,22 +435,27 @@ struct ChatView: View { } @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { - // TODO group-direct: display message as sent/received privately - if case let .groupRcv(member, _) = ci.chatDir, + if case let .groupRcv(member, msgScope) = ci.chatDir, case let .group(groupInfo) = chat.chatInfo { let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci) if ci.memberConnected != nil && nextItem?.memberConnected != nil { // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView ZStack {} // scroll doesn't work if it's EmptyView() } else { - if prevItem == nil || showMemberImage(member, prevItem) { + if prevItem == nil || showMemberImage(member, msgScope, prevItem) { VStack(alignment: .leading, spacing: 4) { if ci.content.showMemberName { - Text(member.displayName) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, memberImageSize + 14) - .padding(.top, 7) + Group { + if msgScope == .msPrivate { + Text("\(member.displayName) **privately**") + } else { + Text(member.displayName) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14) + .padding(.top, 7) } HStack(alignment: .top, spacing: 8) { ProfileImage(imageStr: member.memberProfile.image) @@ -465,6 +477,38 @@ struct ChatView: View { .padding(.leading, memberImageSize + 8 + 12) } } + } else if case let .groupSnd(directMember) = ci.chatDir, + case let .group(groupInfo) = chat.chatInfo { + let (prevItem, _) = chatModel.getChatItemNeighbors(ci) + if prevItem == nil || showSndScope(directMember, prevItem) { + VStack(alignment: .trailing, spacing: 4) { + if ci.content.showMemberName { + HStack { + if let directMember = directMember { + ProfileImage(imageStr: directMember.image) + .frame(width: 30, height: 30) + .onTapGesture { selectedMember = directMember } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, member: directMember, navigation: true) + } + Text("**privately** to \(directMember.displayName)") + } else { + Text("to group") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 7) + } + chatItemWithMenu(ci, maxWidth) + } + .padding(.horizontal) + .padding(.top, 5) + } else { + chatItemWithMenu(ci, maxWidth) + .padding(.horizontal) + .padding(.top, 5) + } } else { chatItemWithMenu(ci, maxWidth) .padding(.horizontal) @@ -875,11 +919,24 @@ struct ChatView: View { } } - // TODO group-direct: show image if scope changes from group to private or vice versa - private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { + private func showMemberImage(_ member: GroupMember, _ msgScope: MessageScope, _ prevItem: ChatItem?) -> Bool { switch (prevItem?.chatDir) { case .groupSnd: return true - case let .groupRcv(prevMember, _): return prevMember.groupMemberId != member.groupMemberId + case let .groupRcv(prevMember, prevMsgScope): return prevMember.groupMemberId != member.groupMemberId || prevMsgScope != msgScope + default: return false + } + } + + private func showSndScope(_ directMember: GroupMember?, _ prevItem: ChatItem?) -> Bool { + switch (prevItem?.chatDir) { + case .groupRcv: return true + case let .groupSnd(prevDirectMember): + switch (prevDirectMember, directMember) { + case (.none, .some(_)): return true + case (.some(_), .none): return true + case let (.some(prevDM), .some(dm)): return prevDM.groupMemberId != dm.groupMemberId + default: return false + } default: return false } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index f9d39b6520..56b785b02b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -11,6 +11,12 @@ import SimpleXChat import SwiftyGif import PhotosUI +enum ComposeDirectMember { + case noDirectMember + case directMember(groupMember: GroupMember) + case directMemberCancelled +} + enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -40,6 +46,7 @@ struct LiveMessage { struct ComposeState { var message: String var liveMessage: LiveMessage? = nil + var directMember: ComposeDirectMember var preview: ComposePreview var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState @@ -49,12 +56,14 @@ struct ComposeState { init( message: String = "", liveMessage: LiveMessage? = nil, + directMember: ComposeDirectMember = .noDirectMember, preview: ComposePreview = .noPreview, contextItem: ComposeContextItem = .noContextItem, voiceMessageRecordingState: VoiceMessageRecordingState = .noRecording ) { self.message = message self.liveMessage = liveMessage + self.directMember = directMember self.preview = preview self.contextItem = contextItem self.voiceMessageRecordingState = voiceMessageRecordingState @@ -62,6 +71,11 @@ struct ComposeState { init(editingItem: ChatItem) { self.message = editingItem.content.text + if let directMember = editingItem.directMember { + self.directMember = .directMember(groupMember: directMember) + } else { + self.directMember = .noDirectMember + } self.preview = chatItemPreview(chatItem: editingItem) self.contextItem = .editingItem(chatItem: editingItem) if let emc = editingItem.content.msgContent, @@ -75,6 +89,7 @@ struct ComposeState { func copy( message: String? = nil, liveMessage: LiveMessage? = nil, + directMember: ComposeDirectMember? = nil, preview: ComposePreview? = nil, contextItem: ComposeContextItem? = nil, voiceMessageRecordingState: VoiceMessageRecordingState? = nil @@ -82,6 +97,7 @@ struct ComposeState { ComposeState( message: message ?? self.message, liveMessage: liveMessage ?? self.liveMessage, + directMember: directMember ?? self.directMember, preview: preview ?? self.preview, contextItem: contextItem ?? self.contextItem, voiceMessageRecordingState: voiceMessageRecordingState ?? self.voiceMessageRecordingState @@ -582,6 +598,23 @@ struct ComposeView: View { } } + @ViewBuilder private func contextDirectMemberView() -> some View { + switch composeState.directMember { + case .noDirectMember: + EmptyView() + case let .directMember(groupMember): + ContextDirectMemberView( + directMember: groupMember, + cancelDirectMemberContext: { composeState = composeState.copy(directMember: .directMemberCancelled) } + ) + case .directMemberCancelled: + ContextDirectMemberView( + directMember: nil, + cancelDirectMemberContext: { composeState = composeState.copy(directMember: .noDirectMember) } + ) + } + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { case .noContextItem: @@ -747,12 +780,18 @@ struct ComposeView: View { } func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { - // TODO group-direct: directMember in compose state var sendRef: SendRef? let chatId = chat.chatInfo.apiId switch chat.chatInfo.chatType { - case .direct: sendRef = .direct(contactId: chatId) - case .group: sendRef = .group(groupId: chatId, directMemberId: nil) + case .direct: + sendRef = .direct(contactId: chatId) + case .group: + switch composeState.directMember { + case let .directMember(groupMember): + sendRef = .group(groupId: chatId, directMemberId: groupMember.groupMemberId) + default: + sendRef = .group(groupId: chatId, directMemberId: nil) + } default: sendRef = nil } if let sendRef = sendRef { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextDirectMemberView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextDirectMemberView.swift new file mode 100644 index 0000000000..b92297c454 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextDirectMemberView.swift @@ -0,0 +1,49 @@ +// +// ContextDirectMemberView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.09.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ContextDirectMemberView: View { + @Environment(\.colorScheme) var colorScheme + let directMember: GroupMember? + let cancelDirectMemberContext: () -> Void + + var body: some View { + HStack { + if let directMember = directMember { + ProfileImage(imageStr: directMember.image) + .frame(width: 30, height: 30) + .padding(.trailing, 2) + Text("Message will be sent privately to \(directMember.chatViewName)") + .lineLimit(1) + } else { + Text("Message will be sent to all members") + } + Spacer() + Button { + withAnimation { + cancelDirectMemberContext() + } + } label: { + Image(systemName: "multiply") + } + } + .padding(12) + .frame(minHeight: 50) + .frame(maxWidth: .infinity) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .padding(.top, 8) + } +} + +struct ContextDirectMemberView_Previews: PreviewProvider { + static var previews: some View { + ContextDirectMemberView(directMember: GroupMember.sampleData, cancelDirectMemberContext: {}) + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 026bc963a0..508102a941 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -172,6 +172,7 @@ 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A2723E2AAF16CD00BE1136 /* ContextDirectMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A2723D2AAF16CD00BE1136 /* ContextDirectMemberView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; @@ -449,6 +450,7 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; + 64A2723D2AAF16CD00BE1136 /* ContextDirectMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextDirectMemberView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; @@ -837,6 +839,7 @@ 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */, D72A9087294BD7A70047C86D /* NativeTextEditor.swift */, + 64A2723D2AAF16CD00BE1136 /* ContextDirectMemberView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1119,6 +1122,7 @@ 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */, + 64A2723E2AAF16CD00BE1136 /* ContextDirectMemberView.swift in Sources */, 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */, 5CB0BA90282713D900B3292C /* SimpleXInfo.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index afa71ae5cc..89d7b22df8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2133,10 +2133,17 @@ public struct ChatItem: Identifiable, Decodable { } } - // TODO group-direct: prohibit moderation if directMember is present + public var directMember: GroupMember? { + switch chatDir { + case let .groupSnd(directMember): return directMember + case let .groupRcv(groupMember, .msPrivate): return groupMember + default: return nil + } + } + public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember)? { switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember, _)): + case let (.group(groupInfo), .groupRcv(groupMember, .msGroup)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) @@ -2588,12 +2595,17 @@ public enum CIContent: Decodable, ItemContent { public var showMemberName: Bool { switch self { case .rcvMsgContent: return true + case .sndMsgContent: return true case .rcvDeleted: return true + case .sndDeleted: return true case .rcvCall: return true + case .sndCall: return true case .rcvIntegrityError: return true case .rcvDecryptionError: return true case .rcvGroupInvitation: return true + case .sndGroupInvitation: return true case .rcvModerated: return true + case .sndModerated: return true case .invalidJSON: return true default: return false } @@ -2631,13 +2643,28 @@ public struct CIQuote: Decodable, ItemContent { } } - // TODO group-direct: " privately" possibly here? public func getSender(_ membership: GroupMember?) -> String? { - switch (chatDir) { - case .directSnd: return "you" - case .directRcv: return nil - case .groupSnd: return membership?.displayName ?? "you" - case let .groupRcv(member, _): return member.displayName + switch chatDir { + case .directSnd: + return NSLocalizedString("you", comment: "quote sender") + case .directRcv: + return nil + case .groupSnd(.msGroup): + if let membershipName = membership?.displayName { + return membershipName + } else { + return NSLocalizedString("you", comment: "quote sender") + } + case .groupSnd(.msPrivate): + if let membershipName = membership?.displayName { + return String.localizedStringWithFormat(NSLocalizedString("%@, privately", comment: "quote sender"), membershipName) + } else { + return NSLocalizedString("you, privately", comment: "quote sender") + } + case let .groupRcv(member, .msGroup): + return member.displayName + case let .groupRcv(member, .msPrivate): + return String.localizedStringWithFormat(NSLocalizedString("%@, privately", comment: "quote sender"), member.displayName) case nil: return nil } }