diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c3331c822..9fe50c2ff 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -490,6 +490,14 @@ final class ChatModel: ObservableObject { return nil } } + + func getNextChatItem(_ ci: ChatItem) -> ChatItem? { + if let i = getChatItemIndex(ci), i - 1 >= 0 { + return reversedChatItems[i - 1] + } else { + return nil + } + } func popChat(_ id: String) { if let i = getChatIndex(id) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMembersConnectedView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMembersConnectedView.swift new file mode 100644 index 000000000..c13239b37 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMembersConnectedView.swift @@ -0,0 +1,51 @@ +// +// CIMembersConnectedView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.08.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CIMembersConnectedView: View { + @EnvironmentObject var chat: Chat + var chatItem: ChatItem + var members: [GroupMember] + @State private var selectedMember: GroupMember? = nil + + var body: some View { + VStack(alignment: .leading) { + HStack { + ForEach(members, id: \.groupMemberId) { member in + memberPicture(member) + } + } + chatEventText(chatItem) + } + } + + @ViewBuilder func memberPicture(_ member: GroupMember) -> some View { + let v = ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + if case let .group(groupInfo) = chat.chatInfo { + v + .onTapGesture { selectedMember = member } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) + } + } else { + v + } + } +} + +struct CIMembersConnectedView_Previews: PreviewProvider { + static var previews: some View { + CIMembersConnectedView( + chatItem: ChatItem.getMembersConnectedSample(), + members: [GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData] + ) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 20a04250f..8ad955803 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -92,6 +92,7 @@ struct ChatItemContentView: View { case .sndModerated: deletedItemView() case .rcvModerated: deletedItemView() case let .invalidJSON(json): CIInvalidJSONView(json: json) + case let .membersConnected(members): CIMembersConnectedView(chatItem: chatItem, members: members) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ae6308e21..577432a44 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat import SwiftyGif -private let memberImageSize: CGFloat = 34 +let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @@ -430,35 +430,46 @@ struct ChatView: View { @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo) = chat.chatInfo { - let prevItem = chatModel.getPrevChatItem(ci) - HStack(alignment: .top, spacing: 0) { - let showMember = prevItem == nil || showMemberImage(member, prevItem) - if showMember { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - .onTapGesture { selectedMember = member } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) - } + let nextItem = chatModel.getNextChatItem(ci) + if ci.isMemberConnected != nil && (nextItem?.isMemberConnected != nil) { + EmptyView() + } else { + let prevItem = chatModel.getPrevChatItem(ci) + if ci.isMemberConnected != nil, + let prevItem = prevItem, + let prevMember = prevItem.isMemberConnected { + membersConnectedItem(ci, maxWidth, member, prevMember, prevItem) } else { - Rectangle().fill(.clear) - .frame(width: memberImageSize, height: memberImageSize) + HStack(alignment: .top, spacing: 0) { + let showMember = prevItem == nil || showMemberImage(member, prevItem) + if showMember { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + .onTapGesture { selectedMember = member } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) + } + } else { + Rectangle().fill(.clear) + .frame(width: memberImageSize, height: memberImageSize) + } + ChatItemWithMenu( + ci: ci, + showMember: showMember, + maxWidth: maxWidth, + scrollProxy: scrollProxy, + deleteMessage: deleteMessage, + deletingItem: $deletingItem, + composeState: $composeState, + showDeleteMessage: $showDeleteMessage + ) + .padding(.leading, 8) + .environmentObject(chat) + } + .padding(.trailing) + .padding(.leading, 12) } - ChatItemWithMenu( - ci: ci, - showMember: showMember, - maxWidth: maxWidth, - scrollProxy: scrollProxy, - deleteMessage: deleteMessage, - deletingItem: $deletingItem, - composeState: $composeState, - showDeleteMessage: $showDeleteMessage - ) - .padding(.leading, 8) - .environmentObject(chat) } - .padding(.trailing) - .padding(.leading, 12) } else { ChatItemWithMenu( ci: ci, @@ -473,6 +484,49 @@ struct ChatView: View { .environmentObject(chat) } } + + @ViewBuilder private func membersConnectedItem( + _ ci: ChatItem, + _ maxWidth: CGFloat, + _ member: GroupMember, + _ prevMember: GroupMember, + _ prevItem: ChatItem + ) -> some View { + let membersConnected: [GroupMember] = [member, prevMember] + collectPrevMembersConnected(prevItem) + let replacingItem = ChatItem( + chatDir: ci.chatDir, + meta: ci.meta, + content: .membersConnected(members: membersConnected) + ) + let alignment: Alignment = .leading + VStack(alignment: alignment.horizontal, spacing: 3) { + ChatItemView(chatInfo: chat.chatInfo, chatItem: replacingItem, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: .constant(false)) + } + .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) + .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) + .padding(.horizontal) + .environmentObject(chat) +// return ChatItemWithMenu( +// ci: replacingItem, +// maxWidth: maxWidth, +// scrollProxy: scrollProxy, +// deleteMessage: deleteMessage, +// deletingItem: $deletingItem, +// composeState: $composeState, +// showDeleteMessage: $showDeleteMessage +// ) +// .padding(.horizontal) +// .environmentObject(chat) + } + + private func collectPrevMembersConnected(_ ci: ChatItem) -> [GroupMember] { + guard let prevItem = chatModel.getPrevChatItem(ci), + let memberConnected = prevItem.isMemberConnected else { + return [] + } + let prevMembers = collectPrevMembersConnected(prevItem) + return [memberConnected] + prevMembers + } private struct ChatItemWithMenu: View { @EnvironmentObject var chat: Chat diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9af88bd9f..a96f585a0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -180,6 +180,7 @@ 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; + 64EC940B2A86805D0025EAA3 /* CIMembersConnectedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EC940A2A86805D0025EAA3 /* CIMembersConnectedView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; @@ -459,6 +460,7 @@ 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; + 64EC940A2A86805D0025EAA3 /* CIMembersConnectedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMembersConnectedView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -823,6 +825,7 @@ 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */, 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */, 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */, + 64EC940A2A86805D0025EAA3 /* CIMembersConnectedView.swift */, ); path = ChatItem; sourceTree = ""; @@ -1145,6 +1148,7 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, + 64EC940B2A86805D0025EAA3 /* CIMembersConnectedView.swift in Sources */, 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0e09d5ec8..cd4782de0 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2009,6 +2009,17 @@ public struct ChatItem: Identifiable, Decodable { } } + public var isMemberConnected: GroupMember? { + switch chatDir { + case .groupRcv(let groupMember): + switch content { + case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember + default: return nil + } + default: return nil + } + } + private var showNtfDir: Bool { return !chatDir.sent } @@ -2052,6 +2063,7 @@ public struct ChatItem: Identifiable, Decodable { case .sndModerated: return true case .rcvModerated: return true case .invalidJSON: return false + case .membersConnected: return false } } @@ -2173,6 +2185,16 @@ public struct ChatItem: Identifiable, Decodable { ) } + public static func getMembersConnectedSample () -> ChatItem { + ChatItem( + chatDir: .groupRcv(groupMember: GroupMember.sampleData), + meta: CIMeta.getSample(1, .now, "group event text", .rcvRead), + content: .membersConnected(members: [GroupMember.sampleData, GroupMember.sampleData]), + quotedItem: nil, + file: nil + ) + } + public static func deletedItemDummy() -> ChatItem { ChatItem( chatDir: CIDirection.directRcv, @@ -2451,6 +2473,7 @@ public enum CIContent: Decodable, ItemContent { case sndModerated case rcvModerated case invalidJSON(json: String) + case membersConnected(members: [GroupMember]) public var text: String { get { @@ -2480,10 +2503,24 @@ public enum CIContent: Decodable, ItemContent { case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") + case let .membersConnected(members): + if members.count > 4 { + return String.localizedStringWithFormat( + "%@ and %d other members connected", + CIContent.membersConnectedNames(Array(members.prefix(3))), + members.count - 3 + ) + } else { + return String.localizedStringWithFormat("%@ members connected", CIContent.membersConnectedNames(members)) + } } } } + static func membersConnectedNames(_ members: [GroupMember]) -> String { + members.map { $0.chatViewName }.joined(separator: ", ") + } + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam ? "\(feature.text): \(timeText(param))"