From 32fb5a5d126985cc4174660394cb2a9b74cfaf85 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sun, 4 May 2025 21:27:20 +0000 Subject: [PATCH] ios: knocking ui (#5851) * ios: knocking ui types * update types * member admission * remove chatItemStatuses * member support view * member support chat view wip * ios: secondary ItemsModel (#5862) * toolbar * more chats * remove theme * preview icon * chat toolbar fast markers * change icon * change icon * remove/accept buttons * item style * get item ItemsModel in chat model methods (chat view doesn't work dynamically) * fix support chat * fix other chats * refresh on exit * refresh button * dynamic marker in chat list * prohibit multi select actions * prohibited send field --- apps/ios/Shared/Model/AppAPITypes.swift | 84 ++- apps/ios/Shared/Model/ChatModel.swift | 279 ++++++--- apps/ios/Shared/Model/SimpleXAPI.swift | 91 +-- apps/ios/Shared/SimpleXApp.swift | 2 +- .../Shared/Views/Chat/ChatInfoToolbar.swift | 27 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 14 +- .../Chat/ChatItem/CIChatFeatureView.swift | 5 +- .../Views/Chat/ChatItem/CIFileView.swift | 21 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItem/CIVoiceView.swift | 9 +- .../Chat/ChatItem/FramedCIVoiceView.swift | 11 +- .../Views/Chat/ChatItem/FramedItemView.swift | 64 ++- .../Chat/ChatItem/MarkedDeletedItemView.swift | 8 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 47 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 87 +-- .../Shared/Views/Chat/ChatItemsMerger.swift | 39 +- .../Shared/Views/Chat/ChatScrollHelpers.swift | 33 +- apps/ios/Shared/Views/Chat/ChatView.swift | 543 ++++++++++-------- .../Chat/ComposeMessage/ComposeView.swift | 59 +- .../ContextPendingMemberActionsView.swift | 97 ++++ .../Views/Chat/Group/GroupChatInfoView.swift | 153 ++++- .../Chat/Group/GroupMemberInfoView.swift | 2 +- .../Chat/Group/GroupPreferencesView.swift | 64 +++ .../Chat/Group/MemberAdmissionView.swift | 92 +++ .../Chat/Group/MemberSupportChatToolbar.swift | 44 ++ .../Views/Chat/Group/MemberSupportView.swift | 267 +++++++++ .../Views/Chat/Group/SecondaryChatView.swift | 39 ++ .../Chat/SelectableChatItemToolbars.swift | 22 +- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 12 +- .../Views/ChatList/ChatPreviewView.swift | 21 +- .../Views/LocalAuth/LocalAuthView.swift | 2 + .../Shared/Views/NewChat/AddGroupView.swift | 2 +- .../UserSettings/AppearanceSettings.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 3 + apps/ios/SimpleX SE/ShareAPI.swift | 23 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 + apps/ios/SimpleXChat/ChatTypes.swift | 210 ++++++- apps/ios/SimpleXChat/ChatUtils.swift | 6 +- apps/ios/SimpleXChat/Notifications.swift | 2 +- .../chat/simplex/common/model/ChatModel.kt | 1 + .../views/chat/group/MemberSupportView.kt | 2 +- 42 files changed, 1873 insertions(+), 644 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index d7f96284cf..df0ba5c9d7 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -39,9 +39,9 @@ enum ChatCommand: ChatCmdProtocol { case apiGetSettings(settings: AppSettings) case apiGetChatTags(userId: Int64) case apiGetChats(userId: Int64) - case apiGetChat(chatId: ChatId, 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 apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) + case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -49,15 +49,15 @@ enum ChatCommand: ChatCmdProtocol { case apiReorderChatTags(tagIds: [Int64]) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) - case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) - case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) + case apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool) + case apiDeleteChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiArchiveReceivedReports(groupId: Int64) case apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) - case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) - case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) + case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -68,6 +68,7 @@ enum ChatCommand: ChatCmdProtocol { case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) + case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole) case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool) case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool) @@ -147,8 +148,8 @@ enum ChatCommand: ChatCmdProtocol { case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) // WebRTC calls / case apiGetNetworkStatuses - case apiChatRead(type: ChatType, id: Int64) - case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) + case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) + case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) @@ -209,15 +210,16 @@ enum ChatCommand: ChatCmdProtocol { 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(chatId, pagination, search): return "/_get chat \(chatId) \(pagination.cmdString)" + - (search == "" ? "" : " search=\(search)") - case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" - case let .apiSendMessages(type, id, live, ttl, composedMessages): + case let .apiGetChat(chatId, scope, contentTag, pagination, search): + let tag = contentTag != nil ? " content=\(contentTag!.rawValue)" : "" + return "/_get chat \(chatId)\(scopeRef(scope: scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") + case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope)) 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 .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(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: ","))" @@ -226,17 +228,17 @@ enum ChatCommand: ChatCmdProtocol { return "/_create *\(noteFolderId) json \(msgs)" case let .apiReportMessage(groupId, chatItemId, reportReason, reportText): return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)" - case let .apiUpdateChatItem(type, id, itemId, um, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(um.cmdString)" - case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" + case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)" + case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiArchiveReceivedReports(groupId): return "/_archive reports #\(groupId)" case let .apiDeleteReceivedReports(groupId, itemIds, mode): return "/_delete reports #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" - case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" - case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl): + case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" + case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" 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)" @@ -247,6 +249,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" + case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)" case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))" case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))" @@ -270,13 +273,13 @@ enum ChatCommand: ChatCmdProtocol { case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))" case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" - case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))" + case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id, scope: nil)) \(chatItemTTLStr(seconds: seconds))" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" case .reconnectAllServers: return "/reconnect" case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" - case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" + case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" @@ -308,8 +311,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" - case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)" - case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" + case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)" + case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" @@ -334,9 +337,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case .apiGetNetworkStatuses: return "/_network_statuses" - case let .apiChatRead(type, id): return "/_read chat \(ref(type, id))" - case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))" - case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" + case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" + case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" + case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" @@ -421,6 +424,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" + case .apiAcceptMember: return "apiAcceptMember" case .apiMembersRole: return "apiMembersRole" case .apiBlockMembersForAll: return "apiBlockMembersForAll" case .apiRemoveMembers: return "apiRemoveMembers" @@ -523,8 +527,20 @@ enum ChatCommand: ChatCmdProtocol { } } - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } } func joinedIds(_ ids: [Int64]) -> String { @@ -676,6 +692,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) case joinedGroupMemberConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberAcceptedByOther(user: UserRef, groupInfo: GroupInfo, acceptingMember: GroupMember, member: GroupMember) case memberRole(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case memberBlockedForAll(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, blocked: Bool) @@ -856,6 +874,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case .receivedGroupInvitation: return "receivedGroupInvitation" case .groupDeletedUser: return "groupDeletedUser" case .joinedGroupMemberConnecting: return "joinedGroupMemberConnecting" + case .memberAccepted: return "memberAccepted" + case .memberAcceptedByOther: return "memberAcceptedByOther" case .memberRole: return "memberRole" case .membersRoleUser: return "membersRoleUser" case .memberBlockedForAll: return "memberBlockedForAll" @@ -1043,6 +1063,8 @@ enum ChatResponse: Decodable, Error, ChatRespProtocol { case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberAcceptedByOther(u, groupInfo, acceptingMember, member): return withUser(u, "groupInfo: \(groupInfo)\nacceptingMember: \(acceptingMember)\nmember: \(member)") case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .memberBlockedForAll(u, groupInfo, byMember, member, blocked): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nblocked: \(blocked)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c73cb32c58..3c27a9875b 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,8 +43,26 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +// analogue for SecondaryContextFilter in Kotlin +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) + + func descr() -> String { + switch self { + case let .groupChatScopeContext(groupScopeInfo): + return "groupChatScopeContext \(groupScopeInfo.toChatScope())" + case let .msgContentTagContext(contentTag): + return "msgContentTagContext \(contentTag.rawValue)" + } + } +} + +// analogue for ChatsContext in Kotlin class ItemsModel: ObservableObject { - static let shared = ItemsModel() + static let shared = ItemsModel(secondaryIMFilter: nil) + public var secondaryIMFilter: SecondaryItemsModelFilter? + public var preloadState = PreloadState() private let publisher = ObservableObjectPublisher() private var bag = Set() var reversedChatItems: [ChatItem] = [] { @@ -68,7 +86,8 @@ class ItemsModel: ObservableObject { chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id } - init() { + init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) { + self.secondaryIMFilter = secondaryIMFilter publisher .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .sink { self.objectWillChange.send() } @@ -83,6 +102,9 @@ class ItemsModel: ObservableObject { try await Task.sleep(nanoseconds: 250_000000) await MainActor.run { ChatModel.shared.chatId = chatId + if secondaryIMFilter != nil { + ChatModel.shared.secondaryIM = self + } willNavigate() } } catch {} @@ -90,7 +112,7 @@ class ItemsModel: ObservableObject { loadChatTask = Task { await MainActor.run { self.isLoading = true } // try? await Task.sleep(nanoseconds: 1000_000000) - await loadChat(chatId: chatId) + await loadChat(chatId: chatId, im: self) if !Task.isCancelled { await MainActor.run { self.isLoading = false @@ -105,7 +127,7 @@ class ItemsModel: ObservableObject { loadChatTask?.cancel() loadChatTask = Task { // try? await Task.sleep(nanoseconds: 1000_000000) - await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) + await loadChat(chatId: chatId, im: self, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil) if !Task.isCancelled { await MainActor.run { if openAroundItemId == nil { @@ -115,6 +137,34 @@ class ItemsModel: ObservableObject { } } } + + public var contentTag: MsgContentTag? { + switch secondaryIMFilter { + case nil: nil + case .groupChatScopeContext: nil + case let .msgContentTagContext(contentTag): contentTag + } + } + + public var groupScopeInfo: GroupChatScopeInfo? { + switch secondaryIMFilter { + case nil: nil + case let .groupChatScopeContext(scopeInfo): scopeInfo + case .msgContentTagContext: nil + } + } +} + +class PreloadState { + var prevFirstVisible: Int64 = Int64.min + var prevItemsCount: Int = 0 + var preloading: Bool = false + + func clear() { + prevFirstVisible = Int64.min + prevItemsCount = 0 + preloading = false + } } class ChatTagsModel: ObservableObject { @@ -278,7 +328,6 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var openAroundItemId: ChatItem.ID? = nil - var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @@ -327,6 +376,9 @@ final class ChatModel: ObservableObject { let im = ItemsModel.shared + // ItemsModel for secondary chat view (such as support scope chat), as opposed to ItemsModel.shared used for primary chat + @Published var secondaryIM: ItemsModel? = nil + static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } let ntfEnableLocal = true @@ -384,7 +436,7 @@ final class ChatModel: ObservableObject { func getGroupChat(_ groupId: Int64) -> Chat? { chats.first { chat in - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { return groupInfo.groupId == groupId } else { return false @@ -459,7 +511,7 @@ final class ChatModel: ObservableObject { } func updateGroup(_ groupInfo: GroupInfo) { - updateChat(.group(groupInfo: groupInfo)) + updateChat(.group(groupInfo: groupInfo, groupChatScope: nil)) } private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { @@ -502,77 +554,105 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - // mark chat non deleted - if case let .direct(contact) = cInfo, contact.chatDeleted { - var updatedContact = contact - updatedContact.chatDeleted = false - updateContact(updatedContact) - } - // update previews - if let i = getChatIndex(cInfo.id) { - chats[i].chatItems = switch cInfo { - case .group: - if let currentPreviewItem = chats[i].chatItems.first { - if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { - [cItem] + // update chat list + if cInfo.groupChatScope() == nil { + // mark chat non deleted + if case let .direct(contact) = cInfo, contact.chatDeleted { + var updatedContact = contact + updatedContact.chatDeleted = false + updateContact(updatedContact) + } + // update preview + if let i = getChatIndex(cInfo.id) { + chats[i].chatItems = switch cInfo { + case .group: + if let currentPreviewItem = chats[i].chatItems.first { + if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { + [cItem] + } else { + [currentPreviewItem] + } } else { - [currentPreviewItem] + [cItem] } - } else { + default: [cItem] } - default: - [cItem] + if case .rcvNew = cItem.meta.itemStatus { + unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) + } + popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) + } else { + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) } - if case .rcvNew = cItem.meta.itemStatus { - unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) - } - popChatCollector.throttlePopChat(cInfo.id, currentPosition: i) - } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) } - // add to current chat - if chatId == cInfo.id { - _ = _upsertChatItem(cInfo, cItem) + // add to current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + _ = _upsertChatItem(ciIM, cInfo, cItem) + } + } + + func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { + let cInfoScope = cInfo.groupChatScope() + if let cInfoScope = cInfoScope { + switch cInfoScope { + case .memberSupport: + switch secondaryIM?.secondaryIMFilter { + case .none: + return nil + case let .groupChatScopeContext(groupScopeInfo): + return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil + case let .msgContentTagContext(contentTag): + return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + } + } + } else { + return cInfo.id == chatId ? im : nil } } func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - // update previews - var res: Bool - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last { - if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + // update chat list + var itemAdded: Bool = false + if cInfo.groupChatScope() == nil { + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last { + if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + chat.chatItems = [cItem] + } + } else { chat.chatItems = [cItem] } } else { - chat.chatItems = [cItem] + addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) + itemAdded = true + } + if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { + VoiceItemState.stopVoiceInChatView(cInfo, cItem) } - res = false - } else { - addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) - res = true } - if cItem.isDeletedContent || cItem.meta.itemDeleted != nil { - VoiceItemState.stopVoiceInChatView(cInfo, cItem) + // update current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + itemAdded = _upsertChatItem(ciIM, cInfo, cItem) } - // update current chat - return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res + return itemAdded } - private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { - if let i = getChatItemIndex(cItem) { - _updateChatItem(at: i, with: cItem) - ChatItemDummyModel.shared.sendUpdate() + private func _upsertChatItem(_ ciIM: ItemsModel, _ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { + if let i = getChatItemIndex(ciIM, cItem) { + let oldStatus = ciIM.reversedChatItems[i].meta.itemStatus + let newStatus = cItem.meta.itemStatus + var ci = cItem + if shouldKeepOldSndCIStatus(oldStatus: oldStatus, newStatus: newStatus) { + ci.meta.itemStatus = oldStatus + } + _updateChatItem(ciIM: ciIM, at: i, with: ci) + ChatItemDummyModel.shared.sendUpdate() // TODO [knocking] review what's this return false } else { - var ci = cItem - if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { - ci.meta.itemStatus = status - } - im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) - im.itemAdded = true + ciIM.reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + ciIM.chatState.itemAdded((cItem.id, cItem.isRcvNew), hasLiveDummy ? 1 : 0) + ciIM.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true } @@ -586,40 +666,42 @@ final class ChatModel: ObservableObject { } func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { - if chatId == cInfo.id, let i = getChatItemIndex(cItem) { + if let ciIM = getCIItemsModel(cInfo, cItem), + let i = getChatItemIndex(ciIM, cItem) { withConditionalAnimation { - _updateChatItem(at: i, with: cItem) + _updateChatItem(ciIM: ciIM, at: i, with: cItem) } - } else if let status = status { - chatItemStatuses.updateValue(status, forKey: cItem.id) } } - private func _updateChatItem(at i: Int, with cItem: ChatItem) { - im.reversedChatItems[i] = cItem - im.reversedChatItems[i].viewTimestamp = .now + private func _updateChatItem(ciIM: ItemsModel, at i: Int, with cItem: ChatItem) { + ciIM.reversedChatItems[i] = cItem + ciIM.reversedChatItems[i].viewTimestamp = .now } - func getChatItemIndex(_ cItem: ChatItem) -> Int? { - im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) + func getChatItemIndex(_ ciIM: ItemsModel, _ cItem: ChatItem) -> Int? { + ciIM.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { - if cItem.isRcvNew { - unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) - } - // update previews - if let chat = getChat(cInfo.id) { - if let pItem = chat.chatItems.last, pItem.id == cItem.id { - chat.chatItems = [ChatItem.deletedItemDummy()] + // update chat list + if cInfo.groupChatScope() == nil { + if cItem.isRcvNew { + unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) + } + // update previews + if let chat = getChat(cInfo.id) { + if let pItem = chat.chatItems.last, pItem.id == cItem.id { + chat.chatItems = [ChatItem.deletedItemDummy()] + } } } - // remove from current chat - if chatId == cInfo.id { - if let i = getChatItemIndex(cItem) { + // remove from current scope + if let ciIM = getCIItemsModel(cInfo, cItem) { + if let i = getChatItemIndex(ciIM, cItem) { withAnimation { - let item = im.reversedChatItems.remove(at: i) - im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) + let item = ciIM.reversedChatItems.remove(at: i) + ciIM.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } @@ -635,7 +717,7 @@ final class ChatModel: ObservableObject { if chatId == groupInfo.id { for i in 0.. (Int?, ChatItem?) { - if let i = getChatItemIndex(ci) { + if let i = getChatItemIndex(im, ci) { // TODO [knocking] review: use getCIItemsModel? (i, i > 0 ? im.reversedChatItems[i - 1] : nil) } else { (nil, nil) @@ -1091,7 +1189,7 @@ final class ChatModel: ObservableObject { func removeWallpaperFilesFromChat(_ chat: Chat) { if case let .direct(contact) = chat.chatInfo { removeWallpaperFilesFromTheme(contact.uiThemes) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { removeWallpaperFilesFromTheme(groupInfo.uiThemes) } } @@ -1146,9 +1244,9 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var userCanSend: Bool { switch chatInfo { case .direct: return true - case let .group(groupInfo): + case let .group(groupInfo, groupChatScope): let m = groupInfo.membership - return m.memberActive && m.memberRole >= .member + return (m.memberActive && m.memberRole >= .member && !m.memberPending) || groupChatScope != nil case .local: return true default: return false @@ -1157,13 +1255,20 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var userIsObserver: Bool { switch chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): let m = groupInfo.membership return m.memberActive && m.memberRole == .observer default: return false } } + var userIsPending: Bool { + switch chatInfo { + case let .group(groupInfo, _): groupInfo.membership.memberPending + default: false + } + } + var unreadTag: Bool { switch chatInfo.chatSettings?.enableNtfs { case .all: chatStats.unreadChat || chatStats.unreadCount > 0 diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4e9c8ce7b6..65f66fc0c0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -328,43 +328,54 @@ func apiGetChatTagsAsync() async throws -> [ChatTag] { let loadItemsPerPage = 50 -func apiGetChat(chatId: ChatId, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { - let r = await chatSendCmd(.apiGetChat(chatId: chatId, pagination: pagination, search: search)) +func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) { + let r = await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search)) if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) } throw r } -func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems) +func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { - let m = ChatModel.shared - let im = ItemsModel.shared +func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { await MainActor.run { - m.chatItemStatuses = [:] if clearItems { im.reversedChatItems = [] - ItemsModel.shared.chatState.clear() + im.chatState.clear() } } - await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) + await apiLoadMessages( + chatId, + im, + ( // pagination + openAroundItemId != nil + ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) + : ( + search == "" + ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) + ) + ), + search, + openAroundItemId, + { 0...0 } + ) } -func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo { - let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, itemId: itemId)) +func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo { + let r = await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId)) if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo } throw r } -func apiPlanForwardChatItems(type: ChatType, id: Int64, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { - let r = await chatSendCmd(.apiPlanForwardChatItems(toChatType: type, toChatId: id, itemIds: itemIds)) +func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) { + let r = await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds)) if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) } throw r } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl) +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -396,8 +407,8 @@ 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) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } @@ -474,14 +485,14 @@ private func createChatItemsErrorAlert(_ r: ChatResponse) { ) } -func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { - let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) +func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay) if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } -func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { - let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) +func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem { + let r = await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay) if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem } throw r } @@ -493,8 +504,8 @@ func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) throw r } -func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { - let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay) +func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] { + let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay) if case let .chatItemsDeleted(_, items, _) = r { return items } throw r } @@ -1212,12 +1223,12 @@ func apiRejectContactRequest(contactReqId: Int64) async throws { throw r } -func apiChatRead(type: ChatType, id: Int64) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id)) +func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) } -func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws { - try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds)) +func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws { + try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) } func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { @@ -1523,7 +1534,7 @@ func markChatRead(_ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) await MainActor.run { withAnimation { ChatModel.shared.markAllChatItemsRead(cInfo) } } @@ -1550,7 +1561,7 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { - try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds) + try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo, itemIds, mentionsRead) } @@ -1600,6 +1611,12 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } +func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { + let r = await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole)) + if case let .memberAccepted(_, _, member) = r { return member } + throw r +} + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> [GroupMember] { let r = await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, _, members, withMessages) = r { return members } @@ -2086,6 +2103,9 @@ func processReceivedMsg(_ res: ChatResponse) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } + if cInfo.groupChatScope() != nil && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { + m.increaseGroupSupportChatsUnreadCounter(cInfo.id) + } } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } @@ -2104,7 +2124,7 @@ func processReceivedMsg(_ res: ChatResponse) async { let cInfo = chatItem.chatInfo let cItem = chatItem.chatItem if !cItem.isDeletedContent && active(user) { - await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } + _ = await MainActor.run { m.upsertChatItem(cInfo, cItem) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { @@ -2200,6 +2220,12 @@ func processReceivedMsg(_ res: ChatResponse) async { _ = m.upsertGroupMember(groupInfo, member) } } + case let .memberAcceptedByOther(user, groupInfo, _, member): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) + } + } case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member if active(user) { await MainActor.run { @@ -2231,6 +2257,7 @@ func processReceivedMsg(_ res: ChatResponse) async { } } case let .userJoinedGroup(user, groupInfo): + // TODO [knocking] close support scope for this group if it's currently opened if active(user) { await MainActor.run { m.updateGroup(groupInfo) @@ -2517,7 +2544,7 @@ func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemID return } let im = ItemsModel.shared - let cInfo = ChatInfo.group(groupInfo: groupInfo) + let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil) await MainActor.run { m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count) } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f8d69c5fc8..47c0f61c79 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -159,7 +159,7 @@ struct SimpleXApp: App { if let id = chatModel.chatId, let chat = chatModel.getChat(id), !NtfManager.shared.navigatingToChat { - Task { await loadChat(chat: chat, clearItems: false) } + Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) } } if let ncr = chatModel.ntfContactRequest { await MainActor.run { chatModel.ntfContactRequest = nil } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 62a41c504a..9a2032ea4c 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -22,11 +22,28 @@ struct ChatInfoToolbar: View { Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) Spacer().frame(width: 16) } - ChatInfoImage( - chat: chat, - size: imageSize, - color: Color(uiColor: .tertiaryLabel) - ) + ZStack(alignment: .bottomTrailing) { + ChatInfoImage( + chat: chat, + size: imageSize, + color: Color(uiColor: .tertiaryLabel) + ) + if chat.chatStats.reportsCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .red) + } else if chat.chatStats.supportChatsUnreadCount > 0 { + Image(systemName: "flag.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, theme.colors.primary) + } + } .padding(.trailing, 4) let t = Text(cInfo.displayName).font(.headline) (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 8194c8fe6f..0498dc5d70 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -687,7 +687,7 @@ struct ChatTTLOption: View { let m = ChatModel.shared do { try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl) - await loadChat(chat: chat, clearItems: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { progressIndicator = false currentChatItemTTL = chatItemTTL @@ -700,7 +700,7 @@ struct ChatTTLOption: View { } catch let error { logger.error("setChatTTL error \(responseError(error))") - await loadChat(chat: chat, clearItems: true) + await loadChat(chat: chat, im: ItemsModel.shared, clearItems: true) await MainActor.run { chatItemTTL = currentChatItemTTL progressIndicator = false @@ -938,7 +938,7 @@ struct ChatWallpaperEditorSheet: View { self.chat = chat self.themes = if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -974,7 +974,7 @@ struct ChatWallpaperEditorSheet: View { private func themesFromChat(_ chat: Chat) -> ThemeModeOverrides { if case let ChatInfo.direct(contact) = chat.chatInfo, let uiThemes = contact.uiThemes { uiThemes - } else if case let ChatInfo.group(groupInfo) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { + } else if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, let uiThemes = groupInfo.uiThemes { uiThemes } else { ThemeModeOverrides() @@ -1052,12 +1052,12 @@ struct ChatWallpaperEditorSheet: View { chat.wrappedValue = Chat.init(chatInfo: ChatInfo.direct(contact: contact)) themes = themesFromChat(chat.wrappedValue) } - } else if case var ChatInfo.group(groupInfo) = chat.wrappedValue.chatInfo { + } else if case var ChatInfo.group(groupInfo, _) = chat.wrappedValue.chatInfo { groupInfo.uiThemes = changedThemesConstant await MainActor.run { - ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo)) - chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo)) + ChatModel.shared.updateChatInfo(ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) + chat.wrappedValue = Chat.init(chatInfo: ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)) themes = themesFromChat(chat.wrappedValue) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 02be8af73b..b2b4441646 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -12,8 +12,8 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool - @ObservedObject var im = ItemsModel.shared @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme var chatItem: ChatItem var feature: Feature @@ -53,7 +53,7 @@ struct CIChatFeatureView: View { private func mergedFeatures() -> [FeatureInfo]? { var fs: [FeatureInfo] = [] var icons: Set = [] - if var i = m.getChatItemIndex(chatItem) { + if var i = m.getChatItemIndex(im, chatItem) { while i < im.reversedChatItems.count, let f = featureInfo(im.reversedChatItems[i]) { if !icons.contains(f.icon) { @@ -108,6 +108,7 @@ struct CIChatFeatureView_Previews: PreviewProvider { let enabled = FeatureEnabled(forUser: false, forContact: false) CIChatFeatureView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index bc44afdd7f..18daae4b4d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -278,6 +278,7 @@ func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { struct CIFileView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentFile: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -293,16 +294,16 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentFile, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItemId: { _ in }) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 6920ab999b..1534872003 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -45,7 +45,7 @@ struct CIRcvDecryptionError: View { viewBody() .onAppear { // for direct chat ConnectionStats are populated on opening chat, see ChatView onAppear - if case let .group(groupInfo) = chat.chatInfo, + if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir { do { let (member, stats) = try apiGroupMemberInfoSync(groupInfo.apiId, groupMember.groupMemberId) @@ -83,7 +83,7 @@ struct CIRcvDecryptionError: View { } else { basicDecryptionErrorItem() } - } else if case let .group(groupInfo) = chat.chatInfo, + } else if case let .group(groupInfo, _) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, let mem = m.getGroupMember(groupMember.groupMemberId), let memberStats = mem.wrapped.activeConn?.connectionStats { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index e2fec02ba1..30adf253b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -476,6 +476,7 @@ class VoiceItemState { struct CIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -498,10 +499,10 @@ struct CIVoiceView_Previews: PreviewProvider { duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }, allowMenu: .constant(true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItemId: { _ in }, allowMenu: .constant(true)) } .previewLayout(.fixed(width: 360, height: 360)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index f4e2a4135a..2378666990 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -77,6 +77,7 @@ struct FramedCIVoiceView: View { struct FramedCIVoiceView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), @@ -92,11 +93,11 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItemId: { _ in }) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index e411befbfa..3dfe961f72 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -13,6 +13,7 @@ struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var scrollToItemId: (ChatItem.ID) -> Void var preview: UIImage? @@ -72,7 +73,7 @@ struct FramedItemView: View { framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) } - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } @@ -292,7 +293,7 @@ struct FramedItemView: View { private func membership() -> GroupMember? { switch chat.chatInfo { - case let .group(groupInfo: groupInfo): return groupInfo.membership + case let .group(groupInfo: groupInfo, _): return groupInfo.membership default: return nil } } @@ -395,15 +396,16 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ theme: AppTheme) -> Color { struct FramedItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -411,17 +413,18 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -430,17 +433,18 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }, allowMenu: Binding.constant(true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 87a9b2ce61..c6a5d0353c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -14,6 +14,7 @@ struct MarkedDeletedItemView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var body: some View { @@ -29,14 +30,14 @@ struct MarkedDeletedItemView: View { var mergedMarkedDeletedText: LocalizedStringKey { if !revealed, let ciCategory = chatItem.mergeCategory, - var i = m.getChatItemIndex(chatItem) { + var i = m.getChatItemIndex(im, chatItem) { var moderated = 0 var blocked = 0 var blockedByAdmin = 0 var deleted = 0 var moderatedBy: Set = [] - while i < ItemsModel.shared.reversedChatItems.count, - let ci = .some(ItemsModel.shared.reversedChatItems[i]), + while i < im.reversedChatItems.count, + let ci = .some(im.reversedChatItems[i]), ci.mergeCategory == ciCategory, let itemDeleted = ci.meta.itemDeleted { switch itemDeleted { @@ -85,6 +86,7 @@ struct MarkedDeletedItemView_Previews: PreviewProvider { Group { MarkedDeletedItemView( chat: Chat.sampleData, + im: ItemsModel.shared, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)) ).environment(\.revealed, true) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5d09e153d5..92e364a846 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -31,6 +31,7 @@ extension EnvironmentValues { struct ChatItemView: View { @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @EnvironmentObject var theme: AppTheme @Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.revealed) var revealed: Bool @@ -41,6 +42,7 @@ struct ChatItemView: View { init( chat: Chat, + im: ItemsModel, chatItem: ChatItem, scrollToItemId: @escaping (ChatItem.ID) -> Void, showMember: Bool = false, @@ -48,6 +50,7 @@ struct ChatItemView: View { allowMenu: Binding = .constant(false) ) { self.chat = chat + self.im = im self.chatItem = chatItem self.scrollToItemId = scrollToItemId self.maxWidth = maxWidth @@ -57,14 +60,14 @@ struct ChatItemView: View { var body: some View { let ci = chatItem if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { - MarkedDeletedItemView(chat: chat, chatItem: chatItem) + MarkedDeletedItemView(chat: chat, im: im, chatItem: chatItem) } else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -92,6 +95,7 @@ struct ChatItemView: View { }() return FramedItemView( chat: chat, + im: im, chatItem: chatItem, scrollToItemId: scrollToItemId, preview: preview, @@ -108,6 +112,7 @@ struct ChatItemContentView: View { @EnvironmentObject var theme: AppTheme @Environment(\.revealed) var revealed: Bool @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel var chatItem: ChatItem var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -131,7 +136,9 @@ struct ChatItemContentView: View { case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) + case .rcvGroupEvent(.newMemberPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .rcvGroupEvent: eventItemView() + case .sndGroupEvent(.userPendingReview): CIEventView(eventText: pendingReviewEventItemText()) case .sndGroupEvent: eventItemView() case .rcvConnEvent: eventItemView() case .sndConnEvent: eventItemView() @@ -140,7 +147,7 @@ struct ChatItemContentView: View { case let .rcvChatPreference(feature, allowed, param): CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary) case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary)) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -172,6 +179,13 @@ struct ChatItemContentView: View { CIEventView(eventText: eventItemViewText(theme.colors.secondary)) } + private func pendingReviewEventItemText() -> Text { + Text(chatItem.content.text) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .fontWeight(.bold) + } + private func eventItemViewText(_ secondaryColor: Color) -> Text { if !revealed, let t = mergedGroupEventText { return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor) @@ -187,7 +201,7 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chat: chat, im: im, chatItem: chatItem, feature: feature, iconColor: iconColor) } private var mergedGroupEventText: Text? { @@ -247,16 +261,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared Group{ - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItemId: { _ in }).environment(\.revealed, true) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -266,10 +281,12 @@ struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { static var previews: some View { + let im = ItemsModel.shared let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -281,6 +298,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), @@ -292,6 +310,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -303,6 +322,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -314,6 +334,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { ) ChatItemView( chat: Chat.sampleData, + im: im, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 07034cf8ec..93ecf870eb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -13,8 +13,8 @@ let TRIM_KEEP_COUNT = 200 func apiLoadMessages( _ chatId: ChatId, + _ im: ItemsModel, _ pagination: ChatPagination, - _ chatState: ActiveChatState, _ search: String = "", _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } @@ -22,7 +22,7 @@ func apiLoadMessages( let chat: Chat let navInfo: NavigationInfo do { - (chat, navInfo) = try await apiGetChat(chatId: chatId, pagination: pagination, search: search) + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) } catch let error { logger.error("apiLoadMessages error: \(responseError(error))") return @@ -38,30 +38,31 @@ func apiLoadMessages( return } - let unreadAfterItemId = chatState.unreadAfterItemId + let unreadAfterItemId = im.chatState.unreadAfterItemId - let oldItems = Array(ItemsModel.shared.reversedChatItems.reversed()) + let oldItems = Array(im.reversedChatItems.reversed()) var newItems: [ChatItem] = [] switch pagination { case .initial: let newSplits: [Int64] = if !chat.chatItems.isEmpty && navInfo.afterTotal > 0 { [chat.chatItems.last!.id] } else { [] } - if chatModel.getChat(chat.id) == nil { + if im.secondaryIMFilter == nil && chatModel.getChat(chat.id) == nil { chatModel.addChat(chat) } await MainActor.run { - chatModel.chatItemStatuses.removeAll() - ItemsModel.shared.reversedChatItems = chat.chatItems.reversed() - chatModel.updateChatInfo(chat.chatInfo) - chatState.splits = newSplits - if !chat.chatItems.isEmpty { - chatState.unreadAfterItemId = chat.chatItems.last!.id + im.reversedChatItems = chat.chatItems.reversed() + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) } - chatState.totalAfter = navInfo.afterTotal - chatState.unreadTotal = chat.chatStats.unreadCount - chatState.unreadAfter = navInfo.afterUnread - chatState.unreadAfterNewestLoaded = navInfo.afterUnread + im.chatState.splits = newSplits + if !chat.chatItems.isEmpty { + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + } + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread - PreloadState.shared.clear() + im.preloadState.clear() } case let .before(paginationChatItemId, _): newItems.append(contentsOf: oldItems) @@ -71,15 +72,15 @@ func apiLoadMessages( let wasSize = newItems.count let visibleItemIndexes = await MainActor.run { visibleItemIndexesNonReversed() } let modifiedSplits = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, &newItems, newIds, chatState.splits, visibleItemIndexes + unreadAfterItemId, &newItems, newIds, im.chatState.splits, visibleItemIndexes ) let insertAt = max((indexInCurrentItems - (wasSize - newItems.count) + modifiedSplits.trimmedIds.count), 0) newItems.insert(contentsOf: chat.chatItems, at: insertAt) let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = modifiedSplits.newSplits - chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) + im.reversedChatItems = newReversed + im.chatState.splits = modifiedSplits.newSplits + im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) } case let .after(paginationChatItemId, _): newItems.append(contentsOf: oldItems) @@ -89,7 +90,7 @@ func apiLoadMessages( let mappedItems = mapItemsToIds(chat.chatItems) let newIds = mappedItems.0 let (newSplits, unreadInLoaded) = removeDuplicatesAndModifySplitsOnAfterPagination( - mappedItems.1, paginationChatItemId, &newItems, newIds, chat, chatState.splits + mappedItems.1, paginationChatItemId, &newItems, newIds, chat, im.chatState.splits ) let indexToAdd = min(indexInCurrentItems + 1, newItems.count) let indexToAddIsLast = indexToAdd == newItems.count @@ -97,19 +98,19 @@ func apiLoadMessages( let new: [ChatItem] = newItems let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = newSplits - chatState.moveUnreadAfterItem(chatState.splits.first ?? new.last!.id, new) + im.reversedChatItems = newReversed + im.chatState.splits = newSplits + im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) // loading clear bottom area, updating number of unread items after the newest loaded item if indexToAddIsLast { - chatState.unreadAfterNewestLoaded -= unreadInLoaded + im.chatState.unreadAfterNewestLoaded -= unreadInLoaded } } case .around: var newSplits: [Int64] if openAroundItemId == nil { newItems.append(contentsOf: oldItems) - newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) + newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, im.chatState.splits, visibleItemIndexesNonReversed) } else { newSplits = [] } @@ -120,33 +121,37 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() let orderedSplits = newSplits await MainActor.run { - ItemsModel.shared.reversedChatItems = newReversed - chatState.splits = orderedSplits - chatState.unreadAfterItemId = chat.chatItems.last!.id - chatState.totalAfter = navInfo.afterTotal - chatState.unreadTotal = chat.chatStats.unreadCount - chatState.unreadAfter = navInfo.afterUnread + im.reversedChatItems = newReversed + im.chatState.splits = orderedSplits + im.chatState.unreadAfterItemId = chat.chatItems.last!.id + im.chatState.totalAfter = navInfo.afterTotal + im.chatState.unreadTotal = chat.chatStats.unreadCount + im.chatState.unreadAfter = navInfo.afterUnread if let openAroundItemId { - chatState.unreadAfterNewestLoaded = navInfo.afterUnread - ChatModel.shared.openAroundItemId = openAroundItemId - ChatModel.shared.chatId = chatId + im.chatState.unreadAfterNewestLoaded = navInfo.afterUnread + if im.secondaryIMFilter == nil { + ChatModel.shared.openAroundItemId = openAroundItemId // TODO [knocking] move openAroundItemId from ChatModel to ItemsModel? + ChatModel.shared.chatId = chat.id + } } else { // no need to set it, count will be wrong // chatState.unreadAfterNewestLoaded = navInfo.afterUnread } - PreloadState.shared.clear() + im.preloadState.clear() } case .last: newItems.append(contentsOf: oldItems) - let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits) + let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, im.chatState.splits) newItems.append(contentsOf: chat.chatItems) let items = newItems await MainActor.run { - ItemsModel.shared.reversedChatItems = items.reversed() - chatState.splits = newSplits - chatModel.updateChatInfo(chat.chatInfo) - chatState.unreadAfterNewestLoaded = 0 + im.reversedChatItems = items.reversed() + im.chatState.splits = newSplits + if im.secondaryIMFilter == nil { + chatModel.updateChatInfo(chat.chatInfo) + } + im.chatState.unreadAfterNewestLoaded = 0 } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 0a55ed48cc..5f2102b8bc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct MergedItems: Hashable, Equatable { + let im: ItemsModel let items: [MergedItem] let splits: [SplitRange] // chat item id, index in list @@ -23,15 +24,15 @@ struct MergedItems: Hashable, Equatable { hasher.combine("\(items.hashValue)") } - static func create(_ items: [ChatItem], _ revealedItems: Set, _ chatState: ActiveChatState) -> MergedItems { - if items.isEmpty { - return MergedItems(items: [], splits: [], indexInParentItems: [:]) + static func create(_ im: ItemsModel, _ revealedItems: Set) -> MergedItems { + if im.reversedChatItems.isEmpty { + return MergedItems(im: im, items: [], splits: [], indexInParentItems: [:]) } - let unreadCount = chatState.unreadTotal + let unreadCount = im.chatState.unreadTotal - let unreadAfterItemId = chatState.unreadAfterItemId - let itemSplits = chatState.splits + let unreadAfterItemId = im.chatState.unreadAfterItemId + let itemSplits = im.chatState.splits var mergedItems: [MergedItem] = [] // Indexes of splits here will be related to reversedChatItems, not chatModel.chatItems var splitRanges: [SplitRange] = [] @@ -40,19 +41,19 @@ struct MergedItems: Hashable, Equatable { var unclosedSplitIndex: Int? = nil var unclosedSplitIndexInParent: Int? = nil var visibleItemIndexInParent = -1 - var unreadBefore = unreadCount - chatState.unreadAfterNewestLoaded + var unreadBefore = unreadCount - im.chatState.unreadAfterNewestLoaded var lastRevealedIdsInMergedItems: BoxedValue<[Int64]>? = nil var lastRangeInReversedForMergedItems: BoxedValue>? = nil var recent: MergedItem? = nil - while index < items.count { - let item = items[index] - let prev = index >= 1 ? items[index - 1] : nil - let next = index + 1 < items.count ? items[index + 1] : nil + while index < im.reversedChatItems.count { + let item = im.reversedChatItems[index] + let prev = index >= 1 ? im.reversedChatItems[index - 1] : nil + let next = index + 1 < im.reversedChatItems.count ? im.reversedChatItems[index + 1] : nil let category = item.mergeCategory let itemIsSplit = itemSplits.contains(item.id) if item.id == unreadAfterItemId { - unreadBefore = unreadCount - chatState.unreadAfter + unreadBefore = unreadCount - im.chatState.unreadAfter } if item.isRcvNew { unreadBefore -= 1 @@ -106,18 +107,19 @@ struct MergedItems: Hashable, Equatable { // found item that is considered as a split if let unclosedSplitIndex, let unclosedSplitIndexInParent { // it was at least second split in the list - splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index - 1, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent - 1)) } unclosedSplitIndex = index unclosedSplitIndexInParent = visibleItemIndexInParent - } else if index + 1 == items.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { + } else if index + 1 == im.reversedChatItems.count, let unclosedSplitIndex, let unclosedSplitIndexInParent { // just one split for the whole list, there will be no more, it's the end - splitRanges.append(SplitRange(itemId: items[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) + splitRanges.append(SplitRange(itemId: im.reversedChatItems[unclosedSplitIndex].id, indexRangeInReversed: unclosedSplitIndex ... index, indexRangeInParentItems: unclosedSplitIndexInParent ... visibleItemIndexInParent)) } indexInParentItems[item.id] = visibleItemIndexInParent index += 1 } return MergedItems( + im: im, items: mergedItems, splits: splitRanges, indexInParentItems: indexInParentItems @@ -127,7 +129,6 @@ struct MergedItems: Hashable, Equatable { // Use this check to ensure that mergedItems state based on currently actual state of global // splits and reversedChatItems func isActualState() -> Bool { - let im = ItemsModel.shared // do not load anything if global splits state is different than in merged items because it // will produce undefined results in terms of loading and placement of items. // Same applies to reversedChatItems @@ -434,7 +435,7 @@ class BoxedValue: Equatable, Hashable { } @MainActor -func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { +func visibleItemIndexesNonReversed(_ im: ItemsModel, _ listState: EndlessScrollView.ListState, _ mergedItems: MergedItems) -> ClosedRange { let zero = 0 ... 0 let items = mergedItems.items if items.isEmpty { @@ -445,12 +446,12 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.Li guard let newest, let oldest else { return zero } - let size = ItemsModel.shared.reversedChatItems.count + let size = im.reversedChatItems.count let range = size - oldest ... size - newest if range.lowerBound < 0 || range.upperBound < 0 { return zero } - // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed() + // visible items mapped to their underlying data structure which is im.reversedChatItems.reversed() return range } diff --git a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift index c1a1eec7d2..2fb1c3fb35 100644 --- a/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift +++ b/apps/ios/Shared/Views/Chat/ChatScrollHelpers.swift @@ -9,7 +9,7 @@ import SwiftUI import SimpleXChat -func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat) async { +func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Binding, _ chat: Chat, _ im: ItemsModel) async { await MainActor.run { loadingMoreItems.wrappedValue = true loadingBottomItems.wrappedValue = true @@ -22,27 +22,15 @@ func loadLastItems(_ loadingMoreItems: Binding, loadingBottomItems: Bindin } return } - await apiLoadMessages(chat.chatInfo.id, ChatPagination.last(count: 50), ItemsModel.shared.chatState) + await apiLoadMessages(chat.chatInfo.id, im, ChatPagination.last(count: 50)) await MainActor.run { loadingMoreItems.wrappedValue = false loadingBottomItems.wrappedValue = false } } -class PreloadState { - static let shared = PreloadState() - var prevFirstVisible: Int64 = Int64.min - var prevItemsCount: Int = 0 - var preloading: Bool = false - - func clear() { - prevFirstVisible = Int64.min - prevItemsCount = 0 - preloading = false - } -} - func preloadIfNeeded( + _ im: ItemsModel, _ allowLoadMoreItems: Binding, _ ignoreLoadingRequests: Binding, _ listState: EndlessScrollView.ListState, @@ -50,7 +38,7 @@ func preloadIfNeeded( loadItems: @escaping (Bool, ChatPagination) async -> Bool, loadLastItems: @escaping () async -> Void ) { - let state = PreloadState.shared + let state = im.preloadState guard !listState.isScrolling && !listState.isAnimatedScrolling, !state.preloading, listState.totalItemsCount > 0 @@ -63,7 +51,7 @@ func preloadIfNeeded( Task { defer { state.preloading = false } var triedToLoad = true - await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in + await preloadItems(im, mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in triedToLoad = await loadItems(false, pagination) return triedToLoad } @@ -73,11 +61,11 @@ func preloadIfNeeded( } // it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one // split will be merged with last items and position of scroll will change unexpectedly. - if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded { + if listState.itemsCanCoverScreen && !im.lastItemsLoaded { await loadLastItems() } } - } else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded { + } else if listState.itemsCanCoverScreen && !im.lastItemsLoaded { state.preloading = true Task { defer { state.preloading = false } @@ -87,6 +75,7 @@ func preloadIfNeeded( } func preloadItems( + _ im: ItemsModel, _ mergedItems: MergedItems, _ allowLoadMoreItems: Bool, _ listState: EndlessScrollView.ListState, @@ -105,7 +94,7 @@ async { let splits = mergedItems.splits let lastVisibleIndex = listState.lastVisibleItemIndex var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) - let items: [ChatItem] = ItemsModel.shared.reversedChatItems.reversed() + let items: [ChatItem] = im.reversedChatItems.reversed() if splits.isEmpty && !items.isEmpty && lastVisibleIndex > mergedItems.items.count - remaining { lastIndexToLoadFrom = items.count - 1 } @@ -122,7 +111,7 @@ async { let sizeWas = items.count let firstItemIdWas = items.first?.id let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) - if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id { + if triedToLoad && sizeWas == im.reversedChatItems.count && firstItemIdWas == im.reversedChatItems.last?.id { ignoreLoadingRequests.wrappedValue = loadFromItemId return false } @@ -133,7 +122,7 @@ async { let splits = mergedItems.splits let split = splits.last(where: { $0.indexRangeInParentItems.contains(firstVisibleIndex) }) // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) - let reversedItems: [ChatItem] = ItemsModel.shared.reversedChatItems + let reversedItems: [ChatItem] = im.reversedChatItems if let split, split.indexRangeInParentItems.lowerBound + remaining > firstVisibleIndex { let index = split.indexRangeInReversed.lowerBound if index >= 0 { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1349996683..11a54f6fb7 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,8 +15,6 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel - @ObservedObject var im = ItemsModel.shared - @State var mergedItems: BoxedValue = BoxedValue(MergedItems.create(ItemsModel.shared.reversedChatItems, [], ItemsModel.shared.chatState)) @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @@ -24,6 +22,9 @@ struct ChatView: View { @Environment(\.presentationMode) var presentationMode @Environment(\.scenePhase) var scenePhase @State @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel + @State var mergedItems: BoxedValue + @State var floatingButtonModel: FloatingButtonModel @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() @@ -55,7 +56,6 @@ struct ChatView: View { @State private var allowLoadMoreItems: Bool = false @State private var ignoreLoadingRequests: Int64? = nil @State private var animatedScrollingInProgress: Bool = false - @State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel() @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @@ -80,7 +80,7 @@ struct ChatView: View { let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background) let tintColor = theme.wallpaper.tint ?? wallpaperType.defaultTintColor(theme.base) Color.clear.ignoresSafeArea(.all) - .if(wallpaperImage != nil) { view in + .if(wallpaperImage != nil && im.secondaryIMFilter == nil) { view in view.modifier( ChatViewBackground(image: wallpaperImage!, imageType: wallpaperType, background: backgroundColor, tint: tintColor) ) @@ -91,8 +91,8 @@ struct ChatView: View { if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) } - FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + FloatingButtons(im: im, theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: { + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) } ) @@ -101,6 +101,7 @@ struct ChatView: View { if selectedChatItems == nil { ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, @@ -109,7 +110,7 @@ struct ChatView: View { .disabled(!cInfo.sendMsgEnabled) } else { SelectedItemsBottomToolbar( - chatItems: ItemsModel.shared.reversedChatItems, + im: im, selectedChatItems: $selectedChatItems, chatInfo: chat.chatInfo, deleteItems: { forAll in @@ -120,7 +121,7 @@ struct ChatView: View { showArchiveSelectedReports = true }, moderateItems: { - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { showModerateSelectedMessagesAlert(groupInfo) } }, @@ -163,7 +164,7 @@ struct ChatView: View { archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) } } - if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let selected = selectedChatItems { archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) @@ -173,7 +174,7 @@ struct ChatView: View { } .appSheet(item: $selectedMember) { member in Group { - if case let .group(groupInfo) = chat.chatInfo { + if case let .group(groupInfo, _) = chat.chatInfo { GroupMemberInfoView( groupInfo: groupInfo, chat: chat, @@ -186,7 +187,7 @@ struct ChatView: View { // it should be presented on top level in order to prevent a bug in SwiftUI on iOS 16 related to .focused() modifier in AddGroupMembersView's search field .appSheet(isPresented: $showAddMembersSheet) { Group { - if case let .group(groupInfo) = cInfo { + if case let .group(groupInfo, _) = cInfo { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } @@ -236,7 +237,7 @@ struct ChatView: View { initChatView() theme = buildTheme() closeSearch() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { @@ -256,7 +257,7 @@ struct ChatView: View { .onChange(of: chatModel.openAroundItemId) { openAround in if let openAround { closeSearch() - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) chatModel.openAroundItemId = nil @@ -279,9 +280,8 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { - chatModel.chatItemStatuses = [:] - ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatState.clear() + im.reversedChatItems = [] + im.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false @@ -292,124 +292,178 @@ struct ChatView: View { .onChange(of: colorScheme) { _ in theme = buildTheme() } - .toolbar { - ToolbarItem(placement: .principal) { - if selectedChatItems != nil { - SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) - } else if case let .direct(contact) = cInfo { - Button { - Task { - showChatInfoSheet = true - } - } label: { - ChatInfoToolbar(chat: chat) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - ChatInfoView( - chat: chat, - contact: contact, - localAlias: chat.chatInfo.localAlias, - featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), - onSearch: { focusSearch() } - ) - } - } else if case let .group(groupInfo) = cInfo { - Button { - Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } - } label: { - ChatInfoToolbar(chat: chat) - .tint(theme.colors.primary) - } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { - GroupChatInfoView( - chat: chat, - groupInfo: Binding( - get: { groupInfo }, - set: { gInfo in - chat.chatInfo = .group(groupInfo: gInfo) - chat.created = Date.now - } - ), - onSearch: { focusSearch() }, - localAlias: groupInfo.localAlias - ) - } - } else if case .local = cInfo { - ChatInfoToolbar(chat: chat) - } - } - ToolbarItem(placement: .navigationBarTrailing) { - if selectedChatItems != nil { - Button { - withAnimation { - selectedChatItems = nil - } - } label: { - Text("Cancel") - } - } else { - switch cInfo { - case let .direct(contact): - HStack { - let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + .if(im.secondaryIMFilter == nil) { + $0.toolbar { + ToolbarItem(placement: .principal) { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else if case let .direct(contact) = cInfo { + Button { + Task { + showChatInfoSheet = true } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { - Button { - CallController.shared.startCall(contact, .video) - } label: { - Label("Video call", systemImage: "video") + } label: { + ChatInfoToolbar(chat: chat) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + ChatInfoView( + chat: chat, + contact: contact, + localAlias: chat.chatInfo.localAlias, + featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences), + onSearch: { focusSearch() } + ) + } + } else if case let .group(groupInfo, _) = cInfo { + Button { + Task { await chatModel.loadGroupMembers(groupInfo) { showChatInfoSheet = true } } + } label: { + ChatInfoToolbar(chat: chat) + .tint(theme.colors.primary) + } + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo, groupChatScope: nil) + chat.created = Date.now } - .disabled(!contact.ready || !contact.active) - } - searchButton() - ToggleNtfsButton(chat: chat) - .disabled(!contact.ready || !contact.active) - } label: { - Image(systemName: "ellipsis") - } + ), + onSearch: { focusSearch() }, + localAlias: groupInfo.localAlias + ) } - case let .group(groupInfo): - HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) + } else if case .local = cInfo { + ChatInfoToolbar(chat: chat) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + switch cInfo { + case let .direct(contact): + HStack { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { + if chatModel.activeCall == nil { + callButton(contact, .audio, imageName: "phone") + .disabled(!contact.ready || !contact.active) + } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } + } + Menu { + if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .video) + } label: { + Label("Video call", systemImage: "video") } - } else { - addMembersButton() + .disabled(!contact.ready || !contact.active) + } + searchButton() + ToggleNtfsButton(chat: chat) + .disabled(!contact.ready || !contact.active) + } label: { + Image(systemName: "ellipsis") } } - Menu { - searchButton() - ToggleNtfsButton(chat: chat) - } label: { - Image(systemName: "ellipsis") + case let .group(groupInfo, _): + HStack { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } + Menu { + searchButton() + ToggleNtfsButton(chat: chat) + } label: { + Image(systemName: "ellipsis") + } } + case .local: + searchButton() + default: + EmptyView() } - case .local: - searchButton() - default: - EmptyView() } } } } + .if(im.secondaryIMFilter != nil) { + $0.toolbar { + ToolbarItem(placement: .principal) { + if selectedChatItems != nil { + SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems) + } else { + switch im.secondaryIMFilter { + case let .groupChatScopeContext(groupScopeInfo): + switch groupScopeInfo { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + MemberSupportChatToolbar(groupMember: groupMember) + } else { + textChatToolbar("Support") + } + } + case let .msgContentTagContext(contentTag): + switch contentTag { + case .report: + textChatToolbar("Member reports") + default: + EmptyView() + } + case .none: + EmptyView() + } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if selectedChatItems != nil { + Button { + withAnimation { + selectedChatItems = nil + } + } label: { + Text("Cancel") + } + } else { + searchButton() + } + } + } + } + } + + func textChatToolbar(_ text: LocalizedStringKey) -> some View { + HStack { + Text(text).font(.headline) + .lineLimit(1) + } + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) } private func initChatView() { @@ -448,13 +502,13 @@ struct ChatView: View { var index = mergedItems.boxedValue.indexInParentItems[itemId] if index == nil { let pagination = ChatPagination.around(chatItemId: itemId, count: ChatPagination.PRELOAD_COUNT * 2) - let oldSize = ItemsModel.shared.reversedChatItems.count + let oldSize = im.reversedChatItems.count let triedToLoad = await loadChatItems(chat, pagination) if !triedToLoad { return } var repeatsLeft = 50 - while oldSize == ItemsModel.shared.reversedChatItems.count && repeatsLeft > 0 { + while oldSize == im.reversedChatItems.count && repeatsLeft > 0 { try await Task.sleep(nanoseconds: 20_000000) repeatsLeft -= 1 } @@ -464,7 +518,7 @@ struct ChatView: View { closeKeyboardAndRun { Task { await MainActor.run { animatedScrollingInProgress = true } - await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index)) + await scrollView.scrollToItemAnimated(min(im.reversedChatItems.count - 1, index)) await MainActor.run { animatedScrollingInProgress = false } } } @@ -539,6 +593,7 @@ struct ChatView: View { ? (g.size.width - 32) : (g.size.width - 32) * 0.84 return ChatItemWithMenu( + im: im, chat: $chat, index: index, isLastItem: index == mergedItems.boxedValue.items.count - 1, @@ -571,7 +626,7 @@ struct ChatView: View { } } .onChange(of: im.reversedChatItems) { items in - mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) scrollView.updateItems(mergedItems.boxedValue.items) if im.itemAdded { im.itemAdded = false @@ -583,7 +638,7 @@ struct ChatView: View { } } .onChange(of: revealedItems) { revealed in - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealed) scrollView.updateItems(mergedItems.boxedValue.items) } .onChange(of: chat.id) { _ in @@ -618,7 +673,7 @@ struct ChatView: View { private func updateWithInitiallyLoadedItems() { if mergedItems.boxedValue.items.isEmpty { - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, ItemsModel.shared.chatState) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) } let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) let unreadItemId: Int64? = if let unreadIndex { mergedItems.boxedValue.items[unreadIndex].newest().item.id } else { nil } @@ -638,8 +693,8 @@ struct ChatView: View { private func searchTextChanged(_ s: String) { Task { - await loadChat(chat: chat, search: s) - mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState) + await loadChat(chat: chat, im: im, search: s) + mergedItems.boxedValue = MergedItems.create(im, revealedItems) await MainActor.run { scrollView.updateItems(mergedItems.boxedValue.items) } @@ -654,79 +709,8 @@ struct ChatView: View { } } - class FloatingButtonModel: ObservableObject { - @Published var unreadAbove: Int = 0 - @Published var unreadBelow: Int = 0 - @Published var isNearBottom: Bool = true - @Published var date: Date? = nil - @Published var isDateVisible: Bool = false - var hideDateWorkItem: DispatchWorkItem? = nil - - func updateOnListChange(_ listState: EndlessScrollView.ListState) { - let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) - let unreadBelow = if let lastVisibleItem { - max(0, ItemsModel.shared.chatState.unreadTotal - lastVisibleItem.unreadBefore) - } else { - 0 - } - let unreadAbove = ItemsModel.shared.chatState.unreadTotal - unreadBelow - let date: Date? = - if let lastVisible = listState.visibleItems.last { - Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) - } else { - nil - } - - // set the counters and date indicator - DispatchQueue.main.async { [weak self] in - guard let it = self else { return } - it.setDate(visibility: true) - it.unreadAbove = unreadAbove - it.unreadBelow = unreadBelow - it.date = date - } - - // set floating button indication mode - let nearBottom = listState.firstVisibleItemIndex < 1 - if nearBottom != self.isNearBottom { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in - self?.isNearBottom = nearBottom - } - } - - // hide Date indicator after 1 second of no scrolling - hideDateWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - guard let it = self else { return } - it.setDate(visibility: false) - it.hideDateWorkItem = nil - } - DispatchQueue.main.async { [weak self] in - self?.hideDateWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) - } - } - - func resetDate() { - date = nil - isDateVisible = false - } - - private func setDate(visibility isVisible: Bool) { - if isVisible { - if !isNearBottom, - !isDateVisible, - let date, !Calendar.current.isDateInToday(date) { - withAnimation { self.isDateVisible = true } - } - } else if isDateVisible { - withAnimation { self.isDateVisible = false } - } - } - - } - private struct FloatingButtons: View { + @ObservedObject var im: ItemsModel let theme: AppTheme let scrollView: EndlessScrollView let chat: Chat @@ -796,7 +780,7 @@ struct ChatView: View { } } .onTapGesture { - if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded { + if loadingBottomItems || !im.lastItemsLoaded { requestedTopScroll = false requestedBottomScroll = true } else { @@ -816,7 +800,7 @@ struct ChatView: View { } } .onChange(of: loadingBottomItems) { loading in - if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded { + if !loading && requestedBottomScroll && im.lastItemsLoaded { requestedBottomScroll = false scrollToBottom() } @@ -826,9 +810,9 @@ struct ChatView: View { private func scrollToTopUnread() { Task { - if !ItemsModel.shared.chatState.splits.isEmpty { + if !im.chatState.splits.isEmpty { await MainActor.run { loadingMoreItems = true } - await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false) + await loadChat(chatId: chat.id, im: im, openAroundItemId: nil, clearItems: false) await MainActor.run { reloadItems() } if let index = listState.items.lastIndex(where: { $0.hasUnread() }) { await MainActor.run { animatedScrollingInProgress = true } @@ -938,7 +922,7 @@ struct ChatView: View { private func addMembersButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { @@ -948,7 +932,7 @@ struct ChatView: View { private func groupLinkButton() -> some View { Button { - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { Task { do { if let link = try apiGetGroupLink(gInfo.groupId) { @@ -999,6 +983,7 @@ struct ChatView: View { let (validItems, confirmation) = try await apiPlanForwardChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: Array(selectedChatItems) ) if let confirmation { @@ -1088,7 +1073,6 @@ struct ChatView: View { } func openForwardingSheet(_ items: [Int64]) async { - let im = ItemsModel.shared var items = Set(items) var fci = [ChatItem]() for reversedChatItem in im.reversedChatItems { @@ -1127,11 +1111,11 @@ struct ChatView: View { private func loadChatItemsUnchecked(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { await apiLoadMessages( chat.chatInfo.id, + im, pagination, - im.chatState, searchText, nil, - { visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) } + { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } ) return true } @@ -1143,11 +1127,12 @@ struct ChatView: View { func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { - //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(ItemsModel.shared.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(ItemsModel.shared.reversedChatItems.count)") + //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") return } floatingButtonModel.updateOnListChange(scrollView.listState) preloadIfNeeded( + im, $allowLoadMoreItems, $ignoreLoadingRequests, scrollView.listState, @@ -1161,13 +1146,14 @@ struct ChatView: View { }, loadLastItems: { if !loadingMoreItems { - await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat) + await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat, im) } } ) } private struct ChatItemWithMenu: View { + @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner @@ -1252,8 +1238,6 @@ struct ChatView: View { } var body: some View { - let im = ItemsModel.shared - let last = isLastItem ? im.reversedChatItems.last : nil let listItem = merged.newest() let item = listItem.item @@ -1298,11 +1282,17 @@ struct ChatView: View { if !itemIds.isEmpty { waitToMarkRead { await apiMarkChatItemsRead(chat.chatInfo, itemIds, mentionsRead: unreadMentions) + if (im.secondaryIMFilter != nil) { + m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count ) + } } } } else if chatItem.isRcvNew { waitToMarkRead { await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) + if (im.secondaryIMFilter != nil) { + m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id) + } } } } @@ -1324,7 +1314,6 @@ struct ChatView: View { } private func unreadItemIds(_ range: ClosedRange) -> ([ChatItem.ID], Int) { - let im = ItemsModel.shared var unreadItems: [ChatItem.ID] = [] var unreadMentions: Int = 0 @@ -1537,6 +1526,7 @@ struct ChatView: View { } ChatItemView( chat: chat, + im: im, chatItem: ci, scrollToItemId: scrollToItemId, maxWidth: maxWidth, @@ -1578,7 +1568,7 @@ struct ChatView: View { self.archivingReports = [] } } - if case let ChatInfo.group(groupInfo) = chat.chatInfo, groupInfo.membership.memberActive { + if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let reports = self.archivingReports { archiveReports(chat.chatInfo, reports.sorted(), true) @@ -1627,7 +1617,7 @@ struct ChatView: View { } } switch chat.chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): v.contextMenu { ReactionContextMenu( groupInfo: groupInfo, @@ -1650,7 +1640,7 @@ struct ChatView: View { @ViewBuilder private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> some View { - if case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { + if case let .group(gInfo, _) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil { if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator { archiveReportButton(ci) } @@ -1709,7 +1699,7 @@ struct ChatView: View { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { moderateButton(ci, groupInfo) } else if ci.meta.itemDeleted == nil && chat.groupFeatureEnabled(.reports), - case let .group(gInfo) = chat.chatInfo, + case let .group(gInfo, _) = chat.chatInfo, gInfo.membership.memberRole == .member && !live && composeState.voiceMessageRecordingState == .noRecording { @@ -1820,6 +1810,7 @@ struct ChatView: View { let chatItem = try await apiChatItemReaction( type: cInfo.chatType, id: cInfo.apiId, + scope: cInfo.groupChatScope(), itemId: ci.id, add: add, reaction: reaction @@ -1933,11 +1924,11 @@ struct ChatView: View { Task { do { let cInfo = chat.chatInfo - let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, itemId: ci.id) + let ciInfo = try await apiGetChatItemInfo(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemId: ci.id) await MainActor.run { chatItemInfo = ciInfo } - if case let .group(gInfo) = chat.chatInfo { + if case let .group(gInfo, _) = chat.chatInfo { await m.loadGroupMembers(gInfo) } } catch let error { @@ -1991,13 +1982,13 @@ struct ChatView: View { private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button { Button(role: .destructive) { if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { var itemIds: [Int64] = [] for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } showDeleteMessages = true deletingItems = itemIds @@ -2135,12 +2126,12 @@ struct ChatView: View { selectedChatItems = selectedChatItems ?? [] var itemIds: [Int64] = [] if !revealed, - let currIndex = m.getChatItemIndex(ci), + let currIndex = m.getChatItemIndex(im, ci), let ciCategory = ci.mergeCategory { let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) if let range = itemsRange(currIndex, prevHidden) { for i in range { - itemIds.append(ItemsModel.shared.reversedChatItems[i].id) + itemIds.append(im.reversedChatItems[i].id) } } else { itemIds.append(ci.id) @@ -2174,6 +2165,7 @@ struct ChatView: View { try await apiDeleteChatItems( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemIds: [di.id], mode: mode ) @@ -2231,14 +2223,14 @@ struct ChatView: View { if searchIsNotBlank { goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) { closeKeyboardAndRun { - ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id) + im.loadOpenChatNoWait(chat.id, chatItem.id) } } } else if let chatTypeApiIdMsgId { goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) { closeKeyboardAndRun { let (chatType, apiId, msgId) = chatTypeApiIdMsgId - ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) } } } @@ -2265,6 +2257,84 @@ struct ChatView: View { } } +class FloatingButtonModel: ObservableObject { + @ObservedObject var im: ItemsModel + + public init(im: ItemsModel) { + self.im = im + } + + @Published var unreadAbove: Int = 0 + @Published var unreadBelow: Int = 0 + @Published var isNearBottom: Bool = true + @Published var date: Date? = nil + @Published var isDateVisible: Bool = false + var hideDateWorkItem: DispatchWorkItem? = nil + + func updateOnListChange(_ listState: EndlessScrollView.ListState) { + let lastVisibleItem = oldestPartiallyVisibleListItemInListStateOrNull(listState) + let unreadBelow = if let lastVisibleItem { + max(0, im.chatState.unreadTotal - lastVisibleItem.unreadBefore) + } else { + 0 + } + let unreadAbove = im.chatState.unreadTotal - unreadBelow + let date: Date? = + if let lastVisible = listState.visibleItems.last { + Calendar.current.startOfDay(for: lastVisible.item.oldest().item.meta.itemTs) + } else { + nil + } + + // set the counters and date indicator + DispatchQueue.main.async { [weak self] in + guard let it = self else { return } + it.setDate(visibility: true) + it.unreadAbove = unreadAbove + it.unreadBelow = unreadBelow + it.date = date + } + + // set floating button indication mode + let nearBottom = listState.firstVisibleItemIndex < 1 + if nearBottom != self.isNearBottom { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.isNearBottom = nearBottom + } + } + + // hide Date indicator after 1 second of no scrolling + hideDateWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let it = self else { return } + it.setDate(visibility: false) + it.hideDateWorkItem = nil + } + DispatchQueue.main.async { [weak self] in + self?.hideDateWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem) + } + } + + func resetDate() { + date = nil + isDateVisible = false + } + + private func setDate(visibility isVisible: Bool) { + if isVisible { + if !isNearBottom, + !isDateVisible, + let date, !Calendar.current.isDateInToday(date) { + withAnimation { self.isDateVisible = true } + } + } else if isDateVisible { + withAnimation { self.isDateVisible = false } + } + } + +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } @@ -2286,6 +2356,7 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe try await apiDeleteChatItems( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), itemIds: itemIds, mode: mode ) @@ -2347,7 +2418,7 @@ private func buildTheme() -> AppTheme { if let cId = ChatModel.shared.chatId, let chat = ChatModel.shared.getChat(cId) { let perChatTheme = if case let .direct(contact) = chat.chatInfo { contact.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) - } else if case let .group(groupInfo) = chat.chatInfo { + } else if case let .group(groupInfo, _) = chat.chatInfo { groupInfo.uiThemes?.preferredMode(!AppTheme.shared.colors.isLight) } else { nil as ThemeModeOverride? @@ -2500,7 +2571,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { case var .direct(contact): contact.chatSettings = chatSettings ChatModel.shared.updateContact(contact) - case var .group(groupInfo): + case var .group(groupInfo, _): groupInfo.chatSettings = chatSettings ChatModel.shared.updateGroup(groupInfo) default: () @@ -2517,7 +2588,8 @@ struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - ItemsModel.shared.reversedChatItems = [ + let im = ItemsModel.shared + im.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), @@ -2529,7 +2601,12 @@ struct ChatView_Previews: PreviewProvider { ChatItem.getSample(9, .directSnd, .now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") ] @State var showChatInfo = false - return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) - .environmentObject(chatModel) + return ChatView( + chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6ded9cae72..845023a161 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -323,6 +323,7 @@ struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel @Binding var composeState: ComposeState @Binding var keyboardVisible: Bool @Binding var keyboardHiddenDate: Date @@ -355,6 +356,20 @@ struct ComposeView: View { var body: some View { VStack(spacing: 0) { Divider() + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + if chat.chatInfo.contact?.nextSendGrpInv ?? false { ContextInvitingContactMemberView() Divider() @@ -396,7 +411,7 @@ struct ComposeView: View { .padding(.bottom, 16) .padding(.leading, 12) .tint(theme.colors.primary) - if case let .group(g) = chat.chatInfo, + if case let .group(g, _) = chat.chatInfo, !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( @@ -443,17 +458,30 @@ struct ComposeView: View { .padding(.trailing, 12) .disabled(!chat.userCanSend) - if chat.userIsObserver { - Text("you are observer") - .italic() - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 12) - .onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: "Please contact group admin." - ) - } + if im.secondaryIMFilter == nil { + if chat.userIsPending { + Text("reviewed by moderators") + .italic() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } else if chat.userIsObserver { + Text("you are observer") + .italic() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .onTapGesture { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: "Please contact group admin." + ) + } + } } } } @@ -944,6 +972,7 @@ struct ComposeView: View { let chatItem = try await apiUpdateChatItem( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), itemId: ei.id, updatedMessage: UpdatedMessage(msgContent: mc, mentions: composeState.memberMentions), live: live @@ -1031,6 +1060,7 @@ struct ComposeView: View { : await apiSendMessages( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + scope: chat.chatInfo.groupChatScope(), live: live, ttl: ttl, composedMessages: msgs @@ -1055,8 +1085,10 @@ struct ComposeView: View { if let chatItems = await apiForwardChatItems( toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, + toScope: chat.chatInfo.groupChatScope(), fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, + fromScope: fromChatInfo.groupChatScope(), itemIds: forwardedItems.map { $0.id }, ttl: ttl ) { @@ -1274,12 +1306,14 @@ struct ComposeView: View { struct ComposeView_Previews: PreviewProvider { static var previews: some View { let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) + let im = ItemsModel.shared @State var composeState = ComposeState(message: "hello") @State var selectedRange = NSRange() return Group { ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: Binding.constant(true), keyboardHiddenDate: Binding.constant(Date.now), @@ -1288,6 +1322,7 @@ struct ComposeView_Previews: PreviewProvider { .environmentObject(ChatModel()) ComposeView( chat: chat, + im: im, composeState: $composeState, keyboardVisible: Binding.constant(true), keyboardHiddenDate: Binding.constant(Date.now), diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift new file mode 100644 index 0000000000..efb7cf1d96 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -0,0 +1,97 @@ +// +// ContextPendingMemberActionsView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 02.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [knocking] go back (close secondary ChatView) on actions +struct ContextPendingMemberActionsView: View { + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + var member: GroupMember + + var body: some View { + HStack(spacing: 0) { + ZStack { + Text("Remove") + .foregroundColor(.red) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showRemoveMemberAlert(groupInfo, member) + } + + ZStack { + Text("Accept") + .foregroundColor(theme.colors.primary) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + showAcceptMemberAlert(groupInfo, member) + } + } + .frame(minHeight: 54) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + } +} + +func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { + showAlert( + NSLocalizedString("Accept member", comment: "alert title"), + message: NSLocalizedString("Member will join the group, accept member?", comment: "alert message"), + actions: {[ + UIAlertAction( + title: NSLocalizedString("Accept as member", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .member) + } + ), + UIAlertAction( + title: NSLocalizedString("Accept as observer", comment: "alert action"), + style: .default, + handler: { _ in + acceptMember(groupInfo, member, .observer) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ) + ]} + ) +} + +func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole) { + Task { + do { + let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + } + } catch let error { + logger.error("apiAcceptMember error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error accepting member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + +#Preview { + ContextPendingMemberActionsView( + groupInfo: GroupInfo.sampleData, + member: GroupMember.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 9fa07bc391..bdc315a918 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -87,7 +87,26 @@ struct GroupChatInfoView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - + + Section { + if groupInfo.membership.supportChat != nil { + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + UserSupportChatNavLink( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + ) + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .msgContentTagContext(contentTag: .report)) + ) + } + } + Section { if groupInfo.isOwner && groupInfo.businessChat == nil { editGroupButton() @@ -96,17 +115,6 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() - } - - NavigationLink { - ChatWallpaperEditorSheet(chat: chat) - } label: { - Label("Chat theme", systemImage: "photo") - } } header: { Text("") } footer: { @@ -120,6 +128,16 @@ struct GroupChatInfoView: View { } Section { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } + NavigationLink { + ChatWallpaperEditorSheet(chat: chat) + } label: { + Label("Chat theme", systemImage: "photo") + } ChatTTLOption(chat: chat, progressIndicator: $progressIndicator) } footer: { Text("Delete chat messages from your device.") @@ -519,6 +537,81 @@ struct GroupChatInfoView: View { .navigationBarTitleDisplayMode(.large) } + struct UserSupportChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var userSupportChatNavLinkActive = false + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + userSupportChatNavLinkActive = true + } + } label: { + Label("Support chat", systemImage: "flag") + } + + NavigationLink(isActive: $userSupportChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + + private func memberSupportButton() -> some View { + NavigationLink { + MemberSupportView(groupInfo: groupInfo) + .navigationBarTitle("Member support") + .modifier(ThemedBackground()) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Member support", systemImage: "flag") + } + } + + struct GroupReportsChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var groupReportsChatNavLinkActive = false + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + groupReportsChatNavLinkActive = true + } + } label: { + Label("Member reports", systemImage: "flag") + } + + NavigationLink(isActive: $groupReportsChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + private func editGroupButton() -> some View { NavigationLink { GroupProfileView( @@ -679,26 +772,34 @@ struct GroupChatInfoView: View { title: Text("Remove member?"), message: Text(messageLabel), primaryButton: .destructive(Text("Remove")) { - Task { - do { - let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) - await MainActor.run { - updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(groupInfo, updatedMember) - } - } - } catch let error { - logger.error("apiRemoveMembers error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } + removeMember(groupInfo, mem) }, secondaryButton: .cancel() ) } } +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember) { + Task { + do { + let updatedMembers = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + await MainActor.run { + updatedMembers.forEach { updatedMember in + _ = ChatModel.shared.upsertGroupMember(groupInfo, updatedMember) + } + } + } catch let error { + logger.error("apiRemoveMembers error: \(responseError(error))") + await MainActor.run { + showAlert( + NSLocalizedString("Error removing member", comment: "alert title"), + message: responseError(error) + ) + } + } + } +} + func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { groupInfo.businessChat == nil ? ( groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 79ad242366..ca9c0ecce3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -278,7 +278,7 @@ struct GroupMemberInfoView: View { } } .onChange(of: chat.chatInfo) { c in - if case let .group(gI) = chat.chatInfo { + if case let .group(gI, _) = chat.chatInfo { groupInfo = gI } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index ed39c401ce..3ad4de639d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -30,6 +30,14 @@ struct GroupPreferencesView: View { let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" VStack { List { + Section { + MemberAdmissionButton( + groupInfo: $groupInfo, + admission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), + currentAdmission: groupInfo.groupProfile.memberAdmission ?? GroupMemberAdmission(), + creatingGroup: creatingGroup + ) + } featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) @@ -77,6 +85,62 @@ struct GroupPreferencesView: View { } } + struct MemberAdmissionButton: View { + @Binding var groupInfo: GroupInfo + @State var admission: GroupMemberAdmission + @State var currentAdmission: GroupMemberAdmission + var creatingGroup: Bool = false + + var body: some View { + NavigationLink { + MemberAdmissionView( + groupInfo: $groupInfo, + admission: $admission, + currentAdmission: currentAdmission, + creatingGroup: creatingGroup, + saveAdmission: saveAdmission + ) + .navigationBarTitle("Member admission") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + .onDisappear { + let saveText = NSLocalizedString( + creatingGroup ? "Save" : "Save and notify group members", + comment: "alert button" + ) + + if groupInfo.groupProfile.memberAdmission != admission { + showAlert( + title: NSLocalizedString("Save admission settings?", comment: "alert title"), + buttonTitle: saveText, + buttonAction: { saveAdmission() }, + cancelButton: true + ) + } + } + } label: { + Label("Member admission", systemImage: "switch.2") + } + } + + private func saveAdmission() { + Task { + do { + var gp = groupInfo.groupProfile + gp.memberAdmission = admission + let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + currentAdmission = admission + } + } catch { + logger.error("MemberAdmissionView apiUpdateGroup error: \(responseError(error))") + } + } + } + } + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift new file mode 100644 index 0000000000..dadf13d1a0 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -0,0 +1,92 @@ +// +// MemberAdmissionView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let memberCriterias: [(criteria: MemberCriteria?, text: LocalizedStringKey)] = [ + (nil, "off"), + (.all, "all") +] + +struct MemberAdmissionView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @Binding var groupInfo: GroupInfo + @Binding var admission: GroupMemberAdmission + var currentAdmission: GroupMemberAdmission + let creatingGroup: Bool + let saveAdmission: () -> Void + @State private var showSaveDialogue = false + + var body: some View { + let saveText: LocalizedStringKey = creatingGroup ? "Save" : "Save and notify group members" + VStack { + List { + admissionSection( + NSLocalizedString("Review", comment: "admission stage"), + NSLocalizedString("Review new members before admitting to group.", comment: "admission stage description"), + $admission.review + ) + + if groupInfo.isOwner { + Section { + Button("Reset") { admission = currentAdmission } + Button(saveText) { saveAdmission() } + } + .disabled(currentAdmission == admission) + } + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + if currentAdmission == admission { + dismiss() + } else { + showSaveDialogue = true + } + }) + .confirmationDialog("Save admission settings?", isPresented: $showSaveDialogue) { + Button(saveText) { + saveAdmission() + dismiss() + } + Button("Exit without saving") { + admission = currentAdmission + dismiss() + } + } + } + + private func admissionSection(_ admissionStageStr: String, _ admissionStageDescrStr: String, _ memberCriteria: Binding) -> some View { + Section { + if groupInfo.isOwner { + Picker(admissionStageStr, selection: memberCriteria) { + ForEach(memberCriterias, id: \.criteria) { mc in + Text(mc.text) + } + } + } else { + infoRow(Text(admissionStageStr), memberCriteria.wrappedValue?.text ?? NSLocalizedString("off", comment: "member criteria value")) + } + } footer: { + Text(admissionStageDescrStr) + .foregroundColor(theme.colors.secondary) + } + } +} + +#Preview { + MemberAdmissionView( + groupInfo: Binding.constant(GroupInfo.sampleData), + admission: Binding.constant(GroupMemberAdmission.sampleData), + currentAdmission: GroupMemberAdmission.sampleData, + creatingGroup: false, + saveAdmission: {} + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift new file mode 100644 index 0000000000..ef43950897 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -0,0 +1,44 @@ +// +// MemberSupportChatToolbar.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 01.05.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportChatToolbar: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + var groupMember: GroupMember + var imageSize: CGFloat = 32 + + var body: some View { + return HStack { + MemberProfileImage(groupMember, size: imageSize) + .padding(.trailing, 4) + let t = Text(groupMember.displayName).font(.headline) + (groupMember.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + } + .foregroundColor(theme.colors.onBackground) + .frame(width: 220) + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .baselineOffset(1) + .kerning(-2) + } +} + +#Preview { + MemberSupportChatToolbar( + groupMember: GroupMember.sampleData + ) + .environmentObject(CurrentColors.toAppTheme()) +} diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift new file mode 100644 index 0000000000..2a7f8e6f8a --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -0,0 +1,267 @@ +// +// MemberSupportView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct MemberSupportView: View { + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var searchText: String = "" + @FocusState private var searchFocussed + var groupInfo: GroupInfo + + var body: some View { + viewBody() + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await chatModel.loadGroupMembers(groupInfo) + } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + } + + @ViewBuilder private func viewBody() -> some View { + let membersWithChats = sortedMembersWithChats() + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let filteredMembersWithChats = s == "" + ? membersWithChats + : membersWithChats.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } + + if membersWithChats.isEmpty { + Text("No support chats") + .foregroundColor(.secondary) + } else { + List { + searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) + .padding(.leading, 8) + ForEach(filteredMembersWithChats) { memberWithChat in + let scopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: memberWithChat.wrapped) + MemberSupportChatNavLink( + groupInfo: groupInfo, + memberWithChat: memberWithChat, + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: scopeInfo), chatItems: [], chatStats: ChatStats()), + im: ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: scopeInfo)) + ) + } + } + } + } + + struct MemberSupportChatNavLink: View { + @EnvironmentObject var chatModel: ChatModel + @State private var memberSupportChatNavLinkActive = false + var groupInfo: GroupInfo + var memberWithChat: GMember + @ObservedObject var chat: Chat + var im: ItemsModel + + var body: some View { + ZStack { + Button { + im.loadOpenChat(chat.id) { + memberSupportChatNavLinkActive = true + } + } label: { + SupportChatRowView(groupMember: memberWithChat, groupInfo: groupInfo) + } + + NavigationLink(isActive: $memberSupportChatNavLinkActive) { + if let secondaryIM = chatModel.secondaryIM { + SecondaryChatView( + chat: chat, + im: secondaryIM + ) + } + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + } + + func sortedMembersWithChats() -> [GMember] { + chatModel.groupMembers + .filter { + $0.wrapped.supportChat != nil && + $0.wrapped.memberStatus != .memLeft && + $0.wrapped.memberStatus != .memRemoved + } + .sorted { (m0: GMember, m1: GMember) -> Bool in + if m0.wrapped.memberPending != m1.wrapped.memberPending { + return m0.wrapped.memberPending + } + + let mentions0 = (m0.wrapped.supportChat?.mentions ?? 0) > 0 + let mentions1 = (m1.wrapped.supportChat?.mentions ?? 0) > 0 + if mentions0 != mentions1 { + return mentions0 + } + + let attention0 = (m0.wrapped.supportChat?.memberAttention ?? 0) > 0 + let attention1 = (m1.wrapped.supportChat?.memberAttention ?? 0) > 0 + if attention0 != attention1 { + return attention0 + } + + let unread0 = (m0.wrapped.supportChat?.unread ?? 0) > 0 + let unread1 = (m1.wrapped.supportChat?.unread ?? 0) > 0 + if unread0 != unread1 { + return unread0 + } + + return (m0.wrapped.supportChat?.chatTs ?? .distantPast) > (m1.wrapped.supportChat?.chatTs ?? .distantPast) + } + } + + private struct SupportChatRowView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var groupMember: GMember + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var groupInfo: GroupInfo + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + var body: some View { + let member = groupMember.wrapped + HStack{ + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + Text(memberStatus(member)) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + + Spacer() + + if member.memberPending { + Image(systemName: "flag.fill") + .resizable() + .scaledToFill() + .frame(width: dynamicChatInfoSize * 0.8, height: dynamicChatInfoSize * 0.8) + .foregroundColor(theme.colors.primary) + } + if let supportChat = member.supportChat { + SupportChatUnreadIndicator(supportChat: supportChat) + } + } + // TODO [knocking] swipe actions are broken +// .swipeActions(edge: .trailing, allowsFullSwipe: true) { +// if member.memberPending { +// Button { +// showAcceptMemberAlert(groupInfo, member) +// } label: { +// Label("Accept", systemImage: "checkmark") +// } +// .tint(theme.colors.primary) +// } +// +// Button { +// showRemoveMemberAlert(groupInfo, member) +// } label: { +// Label("Remove", systemImage: "trash") +// } +// .tint(.red) +// } + } + + private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else if member.memberPending { + return member.memberStatus.text + } else { + return LocalizedStringKey(member.memberRole.text) + } + } + + struct SupportChatUnreadIndicator: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + var supportChat: GroupSupportChat + + var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize } + + private var indicatorTint: Color { + if supportChat.mentions > 0 || supportChat.memberAttention > 0 { + return theme.colors.primary + } else { + return theme.colors.secondary + } + } + + var body: some View { + HStack(alignment: .center, spacing: 2) { + if supportChat.unread > 0 || supportChat.mentions > 0 || supportChat.memberAttention > 0 { + if supportChat.mentions > 0 && supportChat.unread > 1 { + Text("\(MENTION_START)") + .font(userFont <= .xxxLarge ? .body : .callout) + .foregroundColor(indicatorTint) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .cornerRadius(dynamicSize(userFont).unreadCorner) + .padding(.bottom, 1) + } + let singleUnreadIsMention = supportChat.mentions > 0 && supportChat.unread == 1 + (singleUnreadIsMention ? Text("\(MENTION_START)") : unreadCountText(supportChat.unread)) + .font(userFont <= .xxxLarge ? .caption : .caption2) + .foregroundColor(.white) + .padding(.horizontal, dynamicSize(userFont).unreadPadding) + .frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize) + .background(indicatorTint) + .cornerRadius(dynamicSize(userFont).unreadCorner) + } + } + .frame(height: dynamicChatInfoSize) + .frame(minWidth: 22) + } + } + + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(theme.colors.secondary) + } + } +} + +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember) { + showAlert( + title: NSLocalizedString("Remove member?", comment: "alert title"), + buttonTitle: "Remove", + buttonAction: { removeMember(groupInfo, member) }, + cancelButton: true + ) +} + +#Preview { + MemberSupportView( + groupInfo: GroupInfo.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift new file mode 100644 index 0000000000..ef36cc3a31 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -0,0 +1,39 @@ +// +// SecondaryChatView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.04.2025. +// Copyright © 2025 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SecondaryChatView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject var chat: Chat + @ObservedObject var im: ItemsModel + + var body: some View { + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) + .onDisappear { + chatModel.secondaryIM = nil + } + } +} + +#Preview { + SecondaryChatView( + chat: Chat( + chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), + chatItems: [], + chatStats: ChatStats() + ), + im: ItemsModel.shared + ) +} diff --git a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift index 85d6b279c5..e397970acd 100644 --- a/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift +++ b/apps/ios/Shared/Views/Chat/SelectableChatItemToolbars.swift @@ -25,7 +25,7 @@ struct SelectedItemsTopToolbar: View { struct SelectedItemsBottomToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme - let chatItems: [ChatItem] + let im: ItemsModel @Binding var selectedChatItems: Set? var chatInfo: ChatInfo // Bool - delete for everyone is possible @@ -75,9 +75,9 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!moderateEnabled || deleteCountProhibited ? theme.colors.secondary : .red) + .foregroundColor(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : .red) } - .disabled(!moderateEnabled || deleteCountProhibited) + .disabled(!moderateEnabled || deleteCountProhibited || im.secondaryIMFilter != nil) .opacity(canModerate ? 1 : 0) Spacer() @@ -88,24 +88,24 @@ struct SelectedItemsBottomToolbar: View { .resizable() .scaledToFit() .frame(width: 20, height: 20, alignment: .center) - .foregroundColor(!forwardEnabled || forwardCountProhibited ? theme.colors.secondary : theme.colors.primary) + .foregroundColor(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil ? theme.colors.secondary : theme.colors.primary) } - .disabled(!forwardEnabled || forwardCountProhibited) + .disabled(!forwardEnabled || forwardCountProhibited || im.secondaryIMFilter != nil) } .frame(maxHeight: .infinity) .padding([.leading, .trailing], 12) } .onAppear { - recheckItems(chatInfo, chatItems, selectedChatItems) + recheckItems(chatInfo, im.reversedChatItems, selectedChatItems) } .onChange(of: chatInfo) { info in - recheckItems(info, chatItems, selectedChatItems) + recheckItems(info, im.reversedChatItems, selectedChatItems) } - .onChange(of: chatItems) { items in + .onChange(of: im.reversedChatItems) { items in recheckItems(chatInfo, items, selectedChatItems) } .onChange(of: selectedChatItems) { selected in - recheckItems(chatInfo, chatItems, selected) + recheckItems(chatInfo, im.reversedChatItems, selected) } .frame(height: 55.5) .background(.thinMaterial) @@ -116,7 +116,7 @@ struct SelectedItemsBottomToolbar: View { deleteCountProhibited = count == 0 || count > 200 forwardCountProhibited = count == 0 || count > 20 canModerate = possibleToModerate(chatInfo) - let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info) = chatInfo { + let groupInfo: GroupInfo? = if case let ChatInfo.group(groupInfo: info, _) = chatInfo { info } else { nil @@ -145,7 +145,7 @@ struct SelectedItemsBottomToolbar: View { private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool { return switch chatInfo { - case let .group(groupInfo): + case let .group(groupInfo, _): groupInfo.membership.memberRole >= .admin default: false } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 790af64b3f..210aa02290 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -66,7 +66,7 @@ struct ChatListNavLink: View { switch chat.chatInfo { case let .direct(contact): contactNavLink(contact) - case let .group(groupInfo): + case let .group(groupInfo, _): groupNavLink(groupInfo) case let .local(noteFolder): noteFolderNavLink(noteFolder) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 93c18f28cc..59364c9498 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -452,7 +452,13 @@ struct ChatListView: View { @ViewBuilder private func chatView() -> some View { if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) { - ChatView(chat: chat) + let im = ItemsModel.shared + ChatView( + chat: chat, + im: im, + mergedItems: BoxedValue(MergedItems.create(im, [])), + floatingButtonModel: FloatingButtonModel(im: im) + ) } } @@ -898,12 +904,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C 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 + case let .group(groupInfo, _): groupInfo.businessChat?.chatType == .customer default: false } case .groups: switch chatInfo { - case let .group(groupInfo): groupInfo.businessChat == nil + case let .group(groupInfo, _): groupInfo.businessChat == nil default: false } case .business: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 7f92862f66..40736e3887 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -141,7 +141,7 @@ struct ChatPreviewView: View { } else { EmptyView() } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { case .memRejected: inactiveIcon() case .memLeft: inactiveIcon() @@ -165,7 +165,7 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(deleting ? Color.secondary : nil) - case let .group(groupInfo): + case let .group(groupInfo, _): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { case .memInvited: v.foregroundColor(deleting ? theme.colors.secondary : chat.chatInfo.incognito ? .indigo : theme.colors.primary) @@ -335,7 +335,7 @@ struct ChatPreviewView: View { chatPreviewInfoText("connecting…") } } - case let .group(groupInfo): + case let .group(groupInfo, _): switch (groupInfo.membership.memberStatus) { case .memRejected: chatPreviewInfoText("rejected") case .memInvited: groupInvitationPreviewText(groupInfo) @@ -445,6 +445,8 @@ struct ChatPreviewView: View { ProgressView() } else if chat.chatStats.reportsCount > 0 { groupReportsIcon(size: size * 0.8) + } else if chat.chatStats.supportChatsUnreadCount > 0 { + GroupSupportUnreadIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } @@ -498,6 +500,19 @@ struct ChatPreviewView: View { .foregroundColor(.red) } +struct GroupSupportUnreadIcon: View { + @EnvironmentObject var theme: AppTheme + var size: CGFloat + + var body: some View { + Image(systemName: "flag") + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundColor(theme.colors.primary) + } +} + func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View { view() .frame(width: size, height: size) diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 16ab26eff7..c21ff9be8b 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -66,6 +66,8 @@ struct LocalAuthView: View { m.chatId = nil ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.chatState.clear() + ChatModel.shared.secondaryIM?.reversedChatItems = [] + ChatModel.shared.secondaryIM?.chatState.clear() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 87c0b80372..b3c33e95ea 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -193,7 +193,7 @@ struct AddGroupView: View { Task { await m.loadGroupMembers(gInfo) } - let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) + let c = Chat(chatInfo: .group(groupInfo: gInfo, groupChatScope: nil), chatItems: []) m.addChat(c) withAnimation { groupInfo = gInfo diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index c6d0e27289..df6210496b 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -367,13 +367,13 @@ struct ChatThemePreview: View { let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview")) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) HStack { - ChatItemView(chat: Chat.sampleData, chatItem: alice, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: alice, scrollToItemId: { _ in }) .modifier(ChatItemClipped(alice, tailVisible: true)) Spacer() } HStack { Spacer() - ChatItemView(chat: Chat.sampleData, chatItem: bob, scrollToItemId: { _ in }) + ChatItemView(chat: Chat.sampleData, im: ItemsModel.shared, chatItem: bob, scrollToItemId: { _ in }) .modifier(ChatItemClipped(bob, tailVisible: true)) .frame(alignment: .trailing) } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 961cad128f..20bb375d0c 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -59,6 +59,7 @@ let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" +let DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "showReportsInSupportChatAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage" @@ -118,6 +119,7 @@ let appDefaults: [String: Any] = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, DEFAULT_SHOW_UNREAD_AND_FAVORITES: false, @@ -145,6 +147,7 @@ let hintDefaults = [ DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, DEFAULT_SHOW_MUTE_PROFILE_ALERT, + DEFAULT_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE, DEFAULT_SHOW_DELETE_CONTACT_NOTICE ] diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 56f1c2f5f3..309ca14ae5 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -67,6 +67,7 @@ func apiSendMessages( : SEChatCommand.apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, + scope: chatInfo.groupChatScope(), live: false, ttl: nil, composedMessages: composedMessages @@ -123,7 +124,7 @@ enum SEChatCommand: ChatCmdProtocol { case apiSetEncryptLocalFiles(enable: Bool) case apiGetChats(userId: Int64) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) var cmdString: String { switch self { @@ -139,15 +140,27 @@ enum SEChatCommand: ChatCmdProtocol { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" - case let .apiSendMessages(type, id, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" } } - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" + func ref(_ type: ChatType, _ id: Int64, scope: GroupChatScope?) -> String { + "\(type.rawValue)\(id)\(scopeRef(scope: scope))" + } + + func scopeRef(scope: GroupChatScope?) -> String { + switch (scope) { + case .none: "" + case let .memberSupport(groupMemberId_): + if let groupMemberId = groupMemberId_ { + "(_support:\(groupMemberId))" + } else { + "(_support)" + } + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c7fd19e615..d4560d333a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -168,6 +168,11 @@ 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; }; + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */; }; + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */; }; + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */; }; + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.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 */; }; @@ -527,6 +532,11 @@ 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportView.swift; sourceTree = ""; }; + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryChatView.swift; sourceTree = ""; }; + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberSupportChatToolbar.swift; sourceTree = ""; }; + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.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 = ""; }; @@ -1076,6 +1086,7 @@ 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */, D72A9087294BD7A70047C86D /* NativeTextEditor.swift */, 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */, + 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -1117,6 +1128,10 @@ 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */, 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */, B70CE9E52D4BE5930080F36D /* GroupMentions.swift */, + 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */, + 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, + 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, + 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, ); path = Group; sourceTree = ""; @@ -1431,8 +1446,10 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */, 6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */, 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, + 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, + 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, @@ -1467,6 +1484,7 @@ 5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, + 64A779FC2DC1040000FDEF2F /* SecondaryChatView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, @@ -1509,6 +1527,7 @@ 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, + 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */, 8C74C3EA2C1B90AF00039E77 /* ThemeManager.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -1587,6 +1606,7 @@ 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, + 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 35c276b2f4..81842d552f 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1197,7 +1197,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) - case group(groupInfo: GroupInfo) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) case local(noteFolder: NoteFolder) case contactRequest(contactRequest: UserContactRequest) case contactConnection(contactConnection: PendingContactConnection) @@ -1211,7 +1211,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName + case let .group(groupInfo, _): return groupInfo.localDisplayName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localDisplayName case let .contactConnection(contactConnection): return contactConnection.localDisplayName @@ -1224,7 +1224,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName + case let .group(groupInfo, _): return groupInfo.displayName case .local: return ChatInfo.privateNotesChatName case let .contactRequest(contactRequest): return contactRequest.displayName case let .contactConnection(contactConnection): return contactConnection.displayName @@ -1237,7 +1237,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName + case let .group(groupInfo, _): return groupInfo.fullName case .local: return "" case let .contactRequest(contactRequest): return contactRequest.fullName case let .contactConnection(contactConnection): return contactConnection.fullName @@ -1250,7 +1250,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image + case let .group(groupInfo, _): return groupInfo.image case .local: return nil case let .contactRequest(contactRequest): return contactRequest.image case let .contactConnection(contactConnection): return contactConnection.image @@ -1263,7 +1263,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.localAlias - case let .group(groupInfo): return groupInfo.localAlias + case let .group(groupInfo, _): return groupInfo.localAlias case .local: return "" case let .contactRequest(contactRequest): return contactRequest.localAlias case let .contactConnection(contactConnection): return contactConnection.localAlias @@ -1276,7 +1276,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id + case let .group(groupInfo, _): return groupInfo.id case let .local(noteFolder): return noteFolder.id case let .contactRequest(contactRequest): return contactRequest.id case let .contactConnection(contactConnection): return contactConnection.id @@ -1302,7 +1302,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId + case let .group(groupInfo, _): return groupInfo.apiId case let .local(noteFolder): return noteFolder.apiId case let .contactRequest(contactRequest): return contactRequest.apiId case let .contactConnection(contactConnection): return contactConnection.apiId @@ -1315,7 +1315,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready + case let .group(groupInfo, _): return groupInfo.ready case let .local(noteFolder): return noteFolder.ready case let .contactRequest(contactRequest): return contactRequest.ready case let .contactConnection(contactConnection): return contactConnection.ready @@ -1337,7 +1337,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.sendMsgEnabled - case let .group(groupInfo): return groupInfo.sendMsgEnabled + case let .group(groupInfo, _): return groupInfo.sendMsgEnabled case let .local(noteFolder): return noteFolder.sendMsgEnabled case let .contactRequest(contactRequest): return contactRequest.sendMsgEnabled case let .contactConnection(contactConnection): return contactConnection.sendMsgEnabled @@ -1350,7 +1350,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { get { switch self { case let .direct(contact): return contact.contactConnIncognito - case let .group(groupInfo): return groupInfo.membership.memberIncognito + case let .group(groupInfo, _): return groupInfo.membership.memberIncognito case .local: return false case .contactRequest: return false case let .contactConnection(contactConnection): return contactConnection.incognito @@ -1375,7 +1375,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupInfo: GroupInfo? { switch self { - case let .group(groupInfo): return groupInfo + case let .group(groupInfo, _): return groupInfo default: return nil } } @@ -1392,7 +1392,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case .voice: return cups.voice.enabled.forUser case .calls: return cups.calls.enabled.forUser } - case let .group(groupInfo): + case let .group(groupInfo, _): let prefs = groupInfo.fullGroupPreferences switch feature { case .timedMessages: return prefs.timedMessages.on @@ -1415,7 +1415,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case let .direct(contact): let pref = contact.mergedPreferences.timedMessages return pref.enabled.forUser ? pref.userPreference.preference.ttl : nil - case let .group(groupInfo): + case let .group(groupInfo, _): let pref = groupInfo.fullGroupPreferences.timedMessages return pref.on ? pref.ttl : nil default: @@ -1440,7 +1440,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { return .other } - case let .group(groupInfo): + case let .group(groupInfo, _): if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) { return .groupOwnerCan } else { @@ -1471,7 +1471,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return .other } } - + + public func groupChatScope() -> GroupChatScope? { + switch self { + case let .group(_, groupChatScope): groupChatScope?.toChatScope() + default: nil + } + } + public func ntfsEnabled(chatItem: ChatItem) -> Bool { ntfsEnabled(chatItem.meta.userMention) } @@ -1487,7 +1494,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatSettings: ChatSettings? { switch self { case let .direct(contact): return contact.chatSettings - case let .group(groupInfo): return groupInfo.chatSettings + case let .group(groupInfo, _): return groupInfo.chatSettings default: return nil } } @@ -1503,7 +1510,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTags: [Int64]? { switch self { case let .direct(contact): return contact.chatTags - case let .group(groupInfo): return groupInfo.chatTags + case let .group(groupInfo, _): return groupInfo.chatTags default: return nil } } @@ -1511,7 +1518,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { var createdAt: Date { switch self { case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt + case let .group(groupInfo, _): return groupInfo.createdAt case let .local(noteFolder): return noteFolder.createdAt case let .contactRequest(contactRequest): return contactRequest.createdAt case let .contactConnection(contactConnection): return contactConnection.createdAt @@ -1522,7 +1529,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var updatedAt: Date { switch self { case let .direct(contact): return contact.updatedAt - case let .group(groupInfo): return groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.updatedAt case let .local(noteFolder): return noteFolder.updatedAt case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1533,7 +1540,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTs: Date { switch self { case let .direct(contact): return contact.chatTs ?? contact.updatedAt - case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt + case let .group(groupInfo, _): return groupInfo.chatTs ?? groupInfo.updatedAt case let .local(noteFolder): return noteFolder.chatTs case let .contactRequest(contactRequest): return contactRequest.updatedAt case let .contactConnection(contactConnection): return contactConnection.updatedAt @@ -1549,7 +1556,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } else { ChatTTL.userDefault(globalTTL) } - case let .group(groupInfo): + case let .group(groupInfo, _): return if let ciTTL = groupInfo.chatItemTTL { ChatTTL.chat(ChatItemTTL(ciTTL)) } else { @@ -1569,7 +1576,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { public static var sampleData: ChatInfo.SampleData = SampleData( direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData, groupChatScope: nil), local: ChatInfo.local(noteFolder: NoteFolder.sampleData), contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData), contactConnection: ChatInfo.contactConnection(contactConnection: PendingContactConnection.getSampleData()) @@ -1599,10 +1606,18 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } public struct ChatStats: Decodable, Hashable { - public init(unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) { + public init( + unreadCount: Int = 0, + unreadMentions: Int = 0, + reportsCount: Int = 0, + supportChatsUnreadCount: Int = 0, + minUnreadItemId: Int64 = 0, + unreadChat: Bool = false + ) { self.unreadCount = unreadCount self.unreadMentions = unreadMentions self.reportsCount = reportsCount + self.supportChatsUnreadCount = supportChatsUnreadCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } @@ -1611,11 +1626,39 @@ public struct ChatStats: Decodable, Hashable { public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 + // actual only via getChats() and getChat(.initial), otherwise, zero + public var supportChatsUnreadCount: Int = 0 public var minUnreadItemId: Int64 = 0 // actual only via getChats(), otherwise, false public var unreadChat: Bool = false } +public enum GroupChatScope: Decodable { + case memberSupport(groupMemberId_: Int64?) +} + +public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool { + switch (scope1, scope2) { + case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)): + return groupMemberId1_ == groupMemberId2_ + } +} + +public enum GroupChatScopeInfo: Decodable, Hashable { + case memberSupport(groupMember_: GroupMember?) + + public func toChatScope() -> GroupChatScope { + switch self { + case let .memberSupport(groupMember_): + if let groupMember = groupMember_ { + return .memberSupport(groupMemberId_: groupMember.groupMemberId) + } else { + return .memberSupport(groupMemberId_: nil) + } + } + } +} + public struct Contact: Identifiable, Decodable, NamedChat, Hashable { public var contactId: Int64 var localDisplayName: ContactName @@ -2010,6 +2053,10 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { return membership.memberRole >= .admin && membership.memberActive } + public var canModerate: Bool { + return membership.memberRole >= .moderator && membership.memberActive + } + public static let sampleData = GroupInfo( groupId: 1, localDisplayName: "team", @@ -2030,12 +2077,20 @@ public struct GroupRef: Decodable, Hashable { } public struct GroupProfile: Codable, NamedChat, Hashable { - public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { + public init( + displayName: String, + fullName: String, + description: String? = nil, + image: String? = nil, + groupPreferences: GroupPreferences? = nil, + memberAdmission: GroupMemberAdmission? = nil + ) { self.displayName = displayName self.fullName = fullName self.description = description self.image = image self.groupPreferences = groupPreferences + self.memberAdmission = memberAdmission } public var displayName: String @@ -2043,6 +2098,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var description: String? public var image: String? public var groupPreferences: GroupPreferences? + public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } public static let sampleData = GroupProfile( @@ -2051,6 +2107,34 @@ public struct GroupProfile: Codable, NamedChat, Hashable { ) } +public struct GroupMemberAdmission: Codable, Hashable { + public var review: MemberCriteria? + + public init( + review: MemberCriteria? = nil + ) { + self.review = review + } + + public static let sampleData = GroupMemberAdmission( + review: .all + ) +} + +public enum MemberCriteria: String, Codable, Identifiable, Hashable { + case all + + public static var values: [MemberCriteria] { [.all] } + + public var id: Self { self } + + public var text: String { + switch self { + case .all: return NSLocalizedString("all", comment: "member criteria value") + } + } +} + public struct BusinessChatInfo: Decodable, Hashable { public var chatType: BusinessChatType public var businessId: String @@ -2077,6 +2161,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var memberContactId: Int64? public var memberContactProfileId: Int64 public var activeConn: Connection? + public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange public var id: String { "#\(groupId) @\(groupMemberId)" } @@ -2148,6 +2233,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memUnknown: return false case .memInvited: return false case .memPendingApproval: return true + case .memPendingReview: return true case .memIntroduced: return false case .memIntroInvited: return false case .memAccepted: return false @@ -2167,6 +2253,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { case .memUnknown: return false case .memInvited: return false case .memPendingApproval: return false + case .memPendingReview: return false case .memIntroduced: return true case .memIntroInvited: return true case .memAccepted: return true @@ -2177,6 +2264,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } } + public var memberPending: Bool { + switch memberStatus { + case .memPendingApproval: return true + case .memPendingReview: return true + default: return false + } + } + public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole return memberStatus != .memRemoved && memberStatus != .memLeft @@ -2230,6 +2325,13 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public struct GroupSupportChat: Codable, Hashable { + public var chatTs: Date + public var unread: Int + public var memberAttention: Int + public var mentions: Int +} + public struct GroupMemberSettings: Codable, Hashable { public var showMessages: Bool } @@ -2299,6 +2401,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case memUnknown = "unknown" case memInvited = "invited" case memPendingApproval = "pending_approval" + case memPendingReview = "pending_review" case memIntroduced = "introduced" case memIntroInvited = "intro-inv" case memAccepted = "accepted" @@ -2316,6 +2419,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memUnknown: return "unknown status" case .memInvited: return "invited" case .memPendingApproval: return "pending approval" + case .memPendingReview: return "pending review" case .memIntroduced: return "connecting (introduced)" case .memIntroInvited: return "connecting (introduction invitation)" case .memAccepted: return "connecting (accepted)" @@ -2335,6 +2439,7 @@ public enum GroupMemberStatus: String, Decodable, Hashable { case .memUnknown: return "unknown" case .memInvited: return "invited" case .memPendingApproval: return "pending" + case .memPendingReview: return "review" case .memIntroduced: return "connecting" case .memIntroInvited: return "connecting" case .memAccepted: return "connecting" @@ -2589,12 +2694,14 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .userDeleted: nil case .groupDeleted: nil case .memberCreatedContact: nil + case .newMemberPendingReview: nil default: .rcvGroupEvent } case let .sndGroupEvent(event): switch event { case .userRole: nil case .userLeft: nil + case .userPendingReview: nil default: .sndGroupEvent } default: @@ -2627,6 +2734,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { switch rcvGroupEvent { case .groupUpdated: return false case .memberConnected: return false + case .memberAccepted: return false + case .userAccepted: return false case .memberRole: return false case .memberBlocked: return false case .userRole: return true @@ -2638,6 +2747,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case .invitedViaGroupLink: return false case .memberCreatedContact: return false case .memberProfileUpdated: return false + case .newMemberPendingReview: return true } case .sndGroupEvent: return false case .rcvConnEvent: return false @@ -2706,12 +2816,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public func memberToModerate(_ chatInfo: ChatInfo) -> (GroupInfo, GroupMember?)? { switch (chatInfo, chatDir) { - case let (.group(groupInfo), .groupRcv(groupMember)): + case let (.group(groupInfo, _), .groupRcv(groupMember)): let m = groupInfo.membership return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil ? (groupInfo, groupMember) : nil - case let (.group(groupInfo), .groupSnd): + case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .admin ? (groupInfo, nil) : nil default: return nil @@ -3093,6 +3203,21 @@ public enum CIStatus: Decodable, Hashable { } } + // as in corresponds to SENT response from agent, opposed to `sent` which means snd status + public var isSent: Bool { + switch self { + case .sndNew: false + case .sndSent: true + case .sndRcvd: false + case .sndErrorAuth: true + case .sndError: true + case .sndWarning: true + case .rcvNew: false + case .rcvRead: false + case .invalid: false + } + } + public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? { switch self { case .sndNew: nil @@ -3146,6 +3271,17 @@ public enum CIStatus: Decodable, Hashable { } } +public func shouldKeepOldSndCIStatus(oldStatus: CIStatus, newStatus: CIStatus) -> Bool { + switch (oldStatus, newStatus) { + case (.sndRcvd, let new) where !new.isSndRcvd: + return true + case (let old, .sndNew) where old.isSent: + return true + default: + return false + } +} + public enum SndError: Decodable, Hashable { case auth case quota @@ -4045,6 +4181,16 @@ extension MsgContent: Encodable { } } +public enum MsgContentTag: String { + case text + case link + case image + case video + case voice + case file + case report +} + public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? @@ -4376,6 +4522,8 @@ public enum RcvDirectEvent: Decodable, Hashable { public enum RcvGroupEvent: Decodable, Hashable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected + case memberAccepted(groupMemberId: Int64, profile: Profile) + case userAccepted case memberLeft case memberRole(groupMemberId: Int64, profile: Profile, role: GroupMemberRole) case memberBlocked(groupMemberId: Int64, profile: Profile, blocked: Bool) @@ -4387,12 +4535,16 @@ public enum RcvGroupEvent: Decodable, Hashable { case invitedViaGroupLink case memberCreatedContact case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) + case newMemberPendingReview var text: String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) case .memberConnected: return NSLocalizedString("member connected", comment: "rcv group event chat item") + case let .memberAccepted(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("accepted %@", comment: "rcv group event chat item"), profile.profileViewName) + case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item") case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item") case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text) @@ -4412,6 +4564,7 @@ public enum RcvGroupEvent: Decodable, Hashable { case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("connected directly", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) + case .newMemberPendingReview: return NSLocalizedString("New member wants to join the group.", comment: "rcv group event chat item") } } @@ -4436,6 +4589,7 @@ public enum SndGroupEvent: Decodable, Hashable { case memberDeleted(groupMemberId: Int64, profile: Profile) case userLeft case groupUpdated(groupProfile: GroupProfile) + case userPendingReview var text: String { switch self { @@ -4453,6 +4607,8 @@ public enum SndGroupEvent: Decodable, Hashable { return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .userPendingReview: + return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") } } } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 6cbc76ec98..f27485a0f6 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -16,7 +16,7 @@ public protocol ChatLike { extension ChatLike { public func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { - if case let .group(groupInfo) = self.chatInfo { + if case let .group(groupInfo, _) = self.chatInfo { let p = groupInfo.fullGroupPreferences return switch feature { case .timedMessages: p.timedMessages.on @@ -83,7 +83,7 @@ public func foundChat(_ chat: ChatLike, _ searchStr: String) -> Bool { private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { switch cInfo { case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv - case let .group(groupInfo): groupInfo.sendMsgEnabled + case let .group(groupInfo, _): groupInfo.sendMsgEnabled case let .local(noteFolder): noteFolder.sendMsgEnabled case .contactRequest: false case .contactConnection: false @@ -94,7 +94,7 @@ private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { public func chatIconName(_ cInfo: ChatInfo) -> String { switch cInfo { case .direct: "person.crop.circle.fill" - case let .group(groupInfo): + case let .group(groupInfo, _): switch groupInfo.businessChat?.chatType { case .none: "person.2.circle.fill" case .business: "briefcase.circle.fill" diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 5579449caa..70db4476d5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -62,7 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String - if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { + if case let .group(groupInfo, _) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: previewMode == .hidden) } else { title = previewMode == .hidden ? contactHidden : "\(cInfo.chatViewName):" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index bc70933571..ec288abb31 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -3103,6 +3103,7 @@ sealed class CIStatus { @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus() + // as in corresponds to SENT response from agent fun isSent(): Boolean = when(this) { is SndNew -> false is SndSent -> true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index d69b6728b9..46b0c05a31 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -165,7 +165,7 @@ fun SupportChatRow(member: GroupMember) { fun memberStatus(): String { return if (member.activeConn?.connDisabled == true) { generalGetString(MR.strings.member_info_member_disabled) - } else if (member.activeConn?.connDisabled == true) { + } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) } else if (member.memberPending) { member.memberStatus.text